83 Commits

Author SHA1 Message Date
ecef46b4bb feat: Add task for execute test 2026-05-28 13:39:34 -05:00
75c030b554 fix: execution tests 2026-05-28 13:38:37 -05:00
bdf7f6f7cb chore 2026-05-10 21:55:36 -05:00
fff2b2ea70 chore: Move data for tests 2026-05-10 21:51:03 -05:00
362932c014 chore: Add tasks for admin operations 2026-05-10 21:28:18 -05:00
3ba1e25647 feat: Add logs, shell task 2026-05-10 21:08:47 -05:00
36ed18b6a7 feat: add taskipy automate tasks 2026-05-10 20:53:23 -05:00
50d8c13f40 chore: Delete attribute version on docker-compose 2026-05-10 20:52:45 -05:00
bf69fe88d9 feat: Add staging environment for local production testing
- Add docker-compose.staging.yml with PostgreSQL and Django
- Add .env.staging.example with staging-specific environment variables
- Configure staging settings (DEBUG=False, no SSL redirect for localhost)
- Update settings/__init__.py to support staging environment detection
- Update AGENTS.md with staging environment documentation
- Update .gitignore to exclude .env.staging
- Optimize docker-compose.prod.yml configuration

Staging environment simulates production configuration locally:
- PostgreSQL database (port 5433 to avoid conflicts)
- Gunicorn with 4 workers
- DEBUG=False but HTTP allowed for easier testing
- Separate volumes for static files, media, and logs

Usage:
  cp .env.staging.example .env.staging
  docker compose -f docker-compose.staging.yml up -d
2026-05-10 20:32:42 -05:00
8818246870 feat: Add WhiteNoise for static files serving
- Add whitenoise==6.6.0 to requirements.txt
- Configure WhiteNoise middleware in base settings
- Add WhiteNoise STORAGES configuration for all environments
- Reorder CORS middleware to correct position (before CommonMiddleware)
- Enable automatic Gzip compression and cache busting
- Configure environment-specific settings:
  * Development: autorefresh enabled, use finders
  * Staging: 10min cache, autorefresh for testing
  * Production: 1 year cache, strict mode, optimized

Fixes issue with Django REST Framework static files returning 404
in staging/production environments (DEBUG=False).

WhiteNoise now serves all static files (CSS, JS, images) with:
- Automatic Gzip compression (~84% size reduction)
- Cache busting with content hashing
- Optimized cache headers
- No nginx required for static files
2026-05-10 20:32:30 -05:00
294dbdee91 feat: Add deploy environment, Add pyprojectoml 2026-05-09 19:09:31 -05:00
6bc76282d1 chore: Add external_id to representation 2026-05-09 15:52:56 -05:00
d45dbc4658 Merge pull request 'feat/add_external_id_serializer' (#38) from feat/add_external_id_serializer into main
Reviewed-on: #38
2026-04-11 16:49:46 -05:00
1d7beadf96 Merge pull request 'Agregar método de pago crédito #20' (#36) from add_credit_pay_method_#20 into main
Reviewed-on: #36
2026-03-14 23:46:27 -05:00
mono
5442a52ff6 feat(Payment): add CREDIT payment method 2026-03-14 23:45:52 -05:00
b852772c76 Merge pull request 'Agregando rol administrativo #31' (#35) from add_administrative_rol_#31 into main
Reviewed-on: #35
2026-03-14 18:26:31 -05:00
1e16e6e983 feat: Add exernal_id to serializer issue #34 2026-03-14 18:07:44 -05:00
648207192c doc: products endpoint 2026-03-14 18:06:38 -05:00
mono
0d5a34d366 feat: add IsAdministrator permission and protect admin endpoints 2026-03-14 16:37:44 -05:00
mono
7e2c03c81b test: add tests for user role field 2026-03-14 16:27:44 -05:00
mono
83029afd5b feat: add role field to user serializer for menu control
- Endpoint /me/ now returns 'role': 'administrator' or 'role': 'user'
- Uses Django's is_staff to determine administrator role
2026-03-07 17:58:12 -05:00
mono
2ab328b913 docs: add AGENTS.md with project context for opencode 2026-03-07 17:52:48 -05:00
a05061c14e Merge pull request 'Cambiando autenticación de api a JWT #29' (#33) from add_jwt_authentication_#29 into main
Reviewed-on: #33
2026-02-14 15:57:03 -05:00
7c0047b4d3 fix: rm duplicate file. 2026-02-14 15:55:59 -05:00
c021104b62 doc(API): add sample requests. 2026-02-14 15:50:30 -05:00
7a9034943a feat(API): change to jwt authentication. 2026-02-14 15:12:32 -05:00
4812160ea2 Merge pull request 'Se adiciona autenticación a django' (#30) from add_authentication_#29 into main
Reviewed-on: #30
2025-12-13 17:35:34 -05:00
fb4c82a94c #29 refactor(Tests): extract to mixin class. 2025-12-13 17:35:20 -05:00
fb3124246c #29 refactor(Tests): extract to mixin class. 2025-12-13 17:34:40 -05:00
f3d3681bc4 #29 fix(Tests): add auth to tests. 2025-12-13 17:10:00 -05:00
e6d2160d2e #29 feat(Auth): add logout and profile. 2025-12-13 16:31:20 -05:00
f323873d80 #29 feat(Auth): add logout and profile. 2025-12-13 16:30:53 -05:00
b730d24855 #29 feat(Auth): add login. 2025-12-13 12:24:35 -05:00
6261d64206 Merge pull request '#27 fix: reconciliation jar.' (#28) from test_decimal_error_jar_#27 into main
Reviewed-on: #28
2025-11-15 17:51:31 -05:00
64f07a2ce2 #27 fix: reconciliation jar. 2025-11-15 17:50:06 -05:00
308e2d08c1 Merge pull request 'feat(tryton): add comment to tryton sale.' (#26) from add_payment_method_to_tryton_sale_on_notes_field_#21 into main
Reviewed-on: #26
2025-11-08 15:38:44 -05:00
e1ff427856 feat(tryton): add comment to tryton sale. 2025-11-08 15:34:45 -05:00
bf70c47551 Merge pull request 'Se adiciona el método de pago en la descripción de la venta de tryton #21' (#25) from add_payment_method_to_tryton_sale_on_notes_field_#21 into main
Reviewed-on: #25
2025-09-05 20:26:10 -05:00
f02f754ae7 feat(tryton): add payment method to tryton sale description. 2025-09-05 20:24:13 -05:00
1668a37091 Merge pull request 'Adicionando autorecogida a las ventas que se envian a tryton #23' (#24) from add_self_pick_up_to_sales_in_tryton_#23 into main
Reviewed-on: #24
2025-09-05 19:24:54 -05:00
b33937d4a5 feat(Tryton): add self_pick_up in send sale to tryton. 2025-09-05 19:15:26 -05:00
a265b94460 Merge pull request 'Enviando ventas a Tryton así alguna de las ventas falle #16' (#19) from send_sales_to_tryton_in_a_no_bloqueant_way_#16 into main
Reviewed-on: #19
2025-08-30 15:48:26 -05:00
253fcbae27 fix(Tryton): add try except at send sales to tryton. #16 2025-08-30 15:45:13 -05:00
d127609508 Merge pull request 'Adicionando external id en venta y customers en la API #17' (#18) from add_external_id_in_api_sale_fields_#17 into main
Reviewed-on: #18
2025-08-30 15:32:57 -05:00
604bbd3ab9 #17 feat(API): add external id to sales on api. 2025-08-30 15:28:50 -05:00
e17b8f6973 style. 2025-08-30 15:25:33 -05:00
e3f571afc5 #17 feat(API): add external id to customers on api. 2025-08-30 15:24:42 -05:00
477405a094 Merge pull request 'agregada direccion de envio y facturación al enviar las compras a tryton #14' (#15) from add_shipment_address_to_tryton_sale_#14 into main
Reviewed-on: #15
2025-08-16 16:37:46 -05:00
4dae669397 #14 fix(Tryton): add address on updated customers from tryton. 2025-08-16 16:36:06 -05:00
937fe06de4 #14 feat(Tryton): add address on update customers from tryton. 2025-08-16 16:09:20 -05:00
69185f2460 fix(Tryton Shop): add shops. 2025-08-16 12:24:05 -05:00
7ac28154eb Merge branch 'handle_duplicate_product_name_from_tryton_#11' 2025-08-16 10:32:32 -05:00
e7eda79c69 Merge branch 'main' into handle_duplicate_product_name_from_tryton_#11 2025-08-16 10:28:28 -05:00
5f40b4098c feat(Tryton): handle duplicate named products from tryton. 2025-08-16 10:27:17 -05:00
rodia
80864137b6 Merge branch 'main' of https://gitea.onecluster.org/OneTeam/don_confiao_backend 2025-08-16 12:10:27 -03:00
rodia
2e8e956b69 feat: Add env_example 2025-08-16 12:09:41 -03:00
2e4c6592a3 fix test. 2025-08-16 09:55:20 -05:00
6b149b0134 Merge pull request 'Exportando ventas directamente al tryton' (#12) from export_sales_to_tryton_#9 into main
Reviewed-on: #12
2025-08-09 15:39:40 -05:00
1b30076876 environment: add .env to docker-compose. 2025-08-09 15:27:39 -05:00
271f9b2942 fix(Tryton): fix import customers from tryton. 2025-08-09 15:27:17 -05:00
4734636b4f feat (Tryton): get customers from tryton. 2025-08-09 14:12:31 -05:00
59fbc8872a #9 feat(Tryton): get products salables from tryton only. 2025-07-27 23:34:07 -05:00
d1e137d387 #9 feat(Tryton): untouched products on sync from tryton. 2025-07-27 23:22:58 -05:00
c33c6f630a #9 feat(Tryton): update products from tryton. 2025-07-27 23:11:34 -05:00
76e525735d #9 feat(Tryton): create products from tryton. 2025-07-27 22:57:44 -05:00
3a5e13624f #9 config(Tryton): read tryton params from environment. 2025-07-20 22:01:34 -05:00
3d9feeac43 #9 feat(Tryton): add address_external_id to tryton. 2025-07-19 19:09:53 -05:00
81e4c0bc0d #9 feat(Customer): add address_external_id field. 2025-07-19 19:04:58 -05:00
cf0f6dc4b5 #9 feat(Tryton): enviar_ventas_a_tryton 2025-07-19 19:00:33 -05:00
1d3160ae92 #9 feat(Product): add unit_external_id field. 2025-07-19 18:17:18 -05:00
ba9ef039f4 #9 feat(Product): add external_id field. 2025-07-19 17:32:35 -05:00
46e7181653 #9 feat(Sale): add external_id field. 2025-07-19 17:28:47 -05:00
62d39c97c0 #9 feat(Sale): add external_id field. 2025-07-19 10:57:28 -05:00
f8ff6b7905 Merge pull request 'Se arregla fecha en formato csv para tryton para que se pueda exportar #7' (#8) from fix_csv_to_tryton_#7 into main
Reviewed-on: #8
2025-04-12 12:14:07 -05:00
c7e1af9c81 fix(CSV): report tryton csv. 2025-04-12 12:06:07 -05:00
32149b10e2 Merge pull request 'Permitiendo números decimales para la cantidad de productos en las compras #5' (#6) from #5_sale_with_decima_throught_api into main
Reviewed-on: #6
2025-04-06 16:11:00 -05:00
8791c5fa2d fix(Sale): allow decimal on quantity products. 2025-04-06 16:02:44 -05:00
3c318f2751 Merge pull request 'Exportando ventas para tryton desde api' (#4) from api_export_sales_for_tryton_#3 into main
Reviewed-on: #4
2025-03-08 11:21:24 -05:00
5ecf0f4bf5 feat(Api): export sales for tryton from api. 2025-03-02 23:33:26 -05:00
0e38255a9d refactor(View): public function. 2025-03-02 23:15:52 -05:00
2364583952 refactor(views): extract to method. 2025-03-02 23:14:55 -05:00
585d92c64c fix(ExportSales): add test and fix. 2025-03-02 23:04:19 -05:00
99e1198819 allowed settings from environment. 2025-02-08 18:54:54 -05:00
62 changed files with 3456 additions and 266 deletions

26
.env.development Normal file
View File

@@ -0,0 +1,26 @@
# Development Environment Variables
# Este archivo contiene las variables de entorno para desarrollo local
# Django Environment
DJANGO_ENV=development
# Debug mode (True for development)
DEBUG=True
# Django Secret Key (insecure key for development only)
SECRET_KEY=django-insecure-development-key-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v
# Allowed hosts (comma-separated)
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# CORS allowed origins (comma-separated)
CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7001,http://localhost:5173
# Database (SQLite by default in development)
# No additional DB configuration needed for SQLite
# Tryton ERP Configuration
TRYTON_HOST=localhost
TRYTON_DATABASE=tryton
TRYTON_USERNAME=admin
TRYTON_PASSWORD=admin

56
.env.production.example Normal file
View File

@@ -0,0 +1,56 @@
# Production Environment Variables - EXAMPLE
# ¡IMPORTANTE! Copia este archivo a .env.production y completa con valores reales
# NO commitees .env.production con valores sensibles a git
# Django Environment
DJANGO_ENV=production
# Debug mode (MUST be False in production)
DEBUG=False
# Django Secret Key
# ¡IMPORTANTE! Genera una clave única y segura para producción
# Puedes generar una con: python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
SECRET_KEY=CHANGE-ME-TO-A-SECURE-RANDOM-SECRET-KEY-IN-PRODUCTION
# Allowed hosts (comma-separated domains)
# Ejemplo: ALLOWED_HOSTS=tiendailusion.com,www.tiendailusion.com,api.tiendailusion.com
ALLOWED_HOSTS=tiendailusion.com,www.tiendailusion.com
# CORS allowed origins (comma-separated URLs)
# Ejemplo: CORS_ALLOWED_ORIGINS=https://tiendailusion.com,https://www.tiendailusion.com
CORS_ALLOWED_ORIGINS=https://tiendailusion.com,https://www.tiendailusion.com
# CSRF Trusted Origins (comma-separated URLs)
# Debe incluir el protocolo (https://)
CSRF_TRUSTED_ORIGINS=https://tiendailusion.com,https://www.tiendailusion.com
# PostgreSQL Database Configuration
DB_NAME=tienda_ilusion_prod
DB_USER=tienda_ilusion_user
DB_PASSWORD=CHANGE-ME-TO-A-SECURE-DATABASE-PASSWORD
DB_HOST=postgres
DB_PORT=5432
# Email Configuration (para notificaciones y recuperación de contraseñas)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=noreply@tiendailusion.com
EMAIL_HOST_PASSWORD=CHANGE-ME-TO-YOUR-EMAIL-PASSWORD
DEFAULT_FROM_EMAIL=noreply@tiendailusion.com
# Admin notifications
ADMIN_EMAIL=admin@tiendailusion.com
# Tryton ERP Configuration (Production)
TRYTON_HOST=tryton-production-server
TRYTON_DATABASE=tryton_production
TRYTON_USERNAME=tienda_ilusion_integration
TRYTON_PASSWORD=CHANGE-ME-TO-TRYTON-PASSWORD
# Optional: Redis URL for caching (if using Redis)
# REDIS_URL=redis://redis:6379/1
# Optional: Sentry DSN for error tracking
# SENTRY_DSN=https://your-sentry-dsn@sentry.io/project-id

51
.env.staging.example Normal file
View File

@@ -0,0 +1,51 @@
# Staging Environment Variables
# Testing de producción en localhost sin SSL
# Este ambiente simula producción pero permite HTTP en localhost
# Django Environment
DJANGO_ENV=staging
# Debug mode (False para simular producción)
DEBUG=False
# Django Secret Key
# Para staging local, puedes usar una key fija (NO usar en producción real)
SECRET_KEY=staging-local-key-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v
# Allowed hosts (localhost para testing)
ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0
# CORS allowed origins (permite acceso desde navegador local)
# Incluye común puertos de desarrollo de frontend y acceso directo
CORS_ALLOWED_ORIGINS=http://localhost:8000,http://localhost:3000,http://localhost:5173,http://localhost:7001,http://127.0.0.1:8000
# CSRF Trusted Origins (localhost HTTP para staging)
CSRF_TRUSTED_ORIGINS=http://localhost:8000,http://127.0.0.1:8000
# PostgreSQL Database Configuration (staging local)
DB_NAME=tienda_ilusion_staging
DB_USER=tienda_ilusion_user
DB_PASSWORD=staging_local_password
DB_HOST=postgres
DB_PORT=5432
# Email Configuration (console backend para staging)
EMAIL_HOST=smtp.gmail.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=staging@tiendailusion.local
EMAIL_HOST_PASSWORD=staging_password
DEFAULT_FROM_EMAIL=staging@tiendailusion.local
# Admin notifications
ADMIN_EMAIL=admin@tiendailusion.local
# Tryton ERP Configuration (Staging)
# Ajusta estos valores según tu entorno de staging de Tryton
TRYTON_HOST=localhost
TRYTON_DATABASE=tryton_staging
TRYTON_USERNAME=admin
TRYTON_PASSWORD=admin
# Staging specific: Disable SSL redirect for localhost testing
STAGING_DISABLE_SSL=True

31
.gitignore vendored
View File

@@ -327,9 +327,40 @@ pip-selfcheck.json
# End of https://www.toptal.com/developers/gitignore/api/emacs,python,django,venv
# Project-specific ignores
/tienda_ilusion/don_confiao/static/frontend/
/tienda_ilusion/don_confiao/frontend/don-confiao/.vite/
/tienda_ilusion/don_confiao/frontend/don-confiao/.eslintrc.js
/tienda_ilusion/don_confiao/frontend/don-confiao/.eslintrc-auto-import.json
/tienda_ilusion/don_confiao/frontend/don-confiao/.editorconfig
/tienda_ilusion/don_confiao/frontend/don-confiao/.browserslistrc
# Environment files with sensitive data
.env.production
.env.local
.env.*.local
# Static files collected by Django
/tienda_ilusion/staticfiles/
staticfiles/
# Media files uploaded by users
/tienda_ilusion/media/
# Application logs
/tienda_ilusion/logs/
*.log
# Database backups
backups/
*.sql
*.dump
# Docker volumes and data
postgres_data/
pgdata/
# IDE-specific
.vscode/
.idea/
.env.staging

433
AGENTS.md Normal file
View File

@@ -0,0 +1,433 @@
# Don Confiao Backend - Contexto del Proyecto
## Tipo de Proyecto
Backend Django con Django REST Framework
## Estructura del Proyecto
```
don_confiao_backend/
├── requirements.txt # Dependencias Python
├── docker-compose.dev.yml # Docker Compose para desarrollo
├── docker-compose.staging.yml # Docker Compose para staging (testing producción local)
├── docker-compose.prod.yml # Docker Compose para producción
├── django.Dockerfile # Dockerfile Django
├── .env.development # Variables de entorno desarrollo
├── .env.staging # Variables de entorno staging
├── .env.production.example # Ejemplo variables de entorno producción
├── .env_example # Ejemplo de variables de entorno
├── README.rst # Documentación básica
├── Rakefile # Tareas rake
├── doc/ # Documentación adicional
│ └── requests.org
└── tienda_ilusion/ # Proyecto Django
├── manage.py
├── db.sqlite3 # Base de datos SQLite (desarrollo)
├── don_confiao/ # App principal
│ ├── models.py # Modelos: Customer, Product, Sale, SaleLine, Payment, ReconciliationJar, AdminCode
│ ├── views.py
│ ├── api_views.py
│ ├── serializers.py
│ ├── forms.py
│ ├── admin.py
│ ├── urls.py
│ ├── export_csv.py
│ ├── tests/ # Tests
│ └── migrations/
├── users/ # App de usuarios
│ ├── models.py
│ ├── views.py
│ ├── serializers.py
│ ├── urls.py
│ └── tests/
└── config/ # Configuración Django
├── settings/ # Settings por ambiente
│ ├── __init__.py # Detección automática de ambiente
│ ├── base.py # Configuración compartida
│ ├── development.py # Configuración desarrollo
│ ├── staging.py # Configuración staging
│ └── production.py # Configuración producción
├── urls.py
├── wsgi.py
└── asgi.py
```
## Dependencias Principales
- Django==5.0.6
- djangorestframework
- django-cors-headers
- djangorestframework-simplejwt
- sabatron-tryton-rpc-client==7.4.0 (integración con Tryton ERP)
- psycopg2-binary (driver PostgreSQL para producción)
- gunicorn (servidor WSGI para producción)
- python-decouple (gestión de variables de entorno)
## Modelos Principales (don_confiao/models.py)
- **Customer**: Clientes (name, address, email, phone, external_id)
- **Product**: Productos (name, price, measuring_unit, categories)
- **ProductCategory**: Categorías de productos
- **Sale**: Ventas (customer, date, phone, description, payment_method, reconciliation)
- **SaleLine**: Líneas de venta (sale, product, quantity, unit_price, description)
- **Payment**: Pagos (date_time, type_payment, amount, reconciliation_jar)
- **PaymentSale**: Relación muchos a muchos entre Payment y Sale
- **ReconciliationJar**: Arqueo de caja (is_valid, date_time, reconcilier, cash_taken, cash_discrepancy)
- **AdminCode**: Códigos de administrador
## Autenticación
- JWT con djangorestframework-simplejwt
- ACCESS_TOKEN_LIFETIME: 30 minutos
- REFRESH_TOKEN_LIFETIME: 1 día
## API Endpoints
- REST API en don_confiao/api_views.py y users/
- URLs en don_confiao/urls.py y users/urls.py
## Ejecución con Docker Compose
El proyecto se ejecuta con docker-compose. Todos los comandos `manage.py` deben ejecutarse dentro del contenedor:
### Desarrollo (Development)
```bash
# Ejecutar tests
docker compose -f docker-compose.dev.yml run --rm django python manage.py test
# Migraciones
docker compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations
docker compose -f docker-compose.dev.yml run --rm django python manage.py migrate
# Servidor desarrollo
docker compose -f docker-compose.dev.yml up
# Shell Django
docker compose -f docker-compose.dev.yml run --rm django python manage.py shell
# Crear superuser
docker compose -f docker-compose.dev.yml run --rm django python manage.py createsuperuser
```
### Staging (Testing Producción Local)
```bash
# Iniciar servicios (PostgreSQL + Django con Gunicorn)
# Ya incluye el archivo .env.staging, listo para usar
docker compose -f docker-compose.staging.yml up -d
# Migraciones
docker compose -f docker-compose.staging.yml run --rm django python manage.py migrate
# Colectar archivos estáticos
docker compose -f docker-compose.staging.yml run --rm django python manage.py collectstatic --noinput
# Crear superuser
docker compose -f docker-compose.staging.yml run --rm django python manage.py createsuperuser
# Ver logs
docker compose -f docker-compose.staging.yml logs -f django
# Detener servicios
docker compose -f docker-compose.staging.yml down
```
### Producción (Production)
```bash
# Primero, copiar .env.production.example a .env.production y configurar variables
cp .env.production.example .env.production
# Editar .env.production con valores reales
# Iniciar servicios (PostgreSQL + Django con Gunicorn)
docker compose -f docker-compose.prod.yml up -d
# Migraciones
docker compose -f docker-compose.prod.yml run --rm django python manage.py migrate
# Colectar archivos estáticos
docker compose -f docker-compose.prod.yml run --rm django python manage.py collectstatic --noinput
# Crear superuser
docker compose -f docker-compose.prod.yml run --rm django python manage.py createsuperuser
# Ver logs
docker compose -f docker-compose.prod.yml logs -f django
# Detener servicios
docker compose -f docker-compose.prod.yml down
```
Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto es `python manage.py` (no `python tienda_ilusion/manage.py`).
## Tests
- Framework: Django unittest
- Directorio: don_confiao/tests/
- Ejecutar: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py test`
## Comandos Útiles (dentro del contenedor)
- Migraciones: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations && docker-compose -f docker-compose.dev.yml run --rm django python manage.py migrate`
- Servidor desarrollo: `docker-compose -f docker-compose.dev.yml up`
- Shell Django: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py shell`
- Superuser: `docker-compose -f docker-compose.dev.yml run --rm django python manage.py createsuperuser`
## Configuración de Ambientes
El proyecto soporta tres ambientes diferentes mediante la variable `DJANGO_ENV`:
### Development (desarrollo local)
- **Variable de ambiente**: `DJANGO_ENV=development`
- **Archivo de configuración**: `.env.development`
- **Base de datos**: SQLite (db.sqlite3)
- **DEBUG**: True
- **CORS**: Permisivo para desarrollo local
- **Docker Compose**: `docker-compose.dev.yml`
### Staging (testing de producción local)
- **Variable de ambiente**: `DJANGO_ENV=staging`
- **Archivo de configuración**: `.env.staging`
- **Base de datos**: PostgreSQL (tienda_ilusion_staging)
- **DEBUG**: False (simula producción)
- **CORS**: Permisivo para localhost (sin SSL redirect)
- **Docker Compose**: `docker-compose.staging.yml`
- **Servidor**: Gunicorn con 4 workers
- **Puerto PostgreSQL**: 5433 (para no conflictuar con producción)
### Production (producción)
- **Variable de ambiente**: `DJANGO_ENV=production`
- **Archivo de configuración**: `.env.production` (crear desde `.env.production.example`)
- **Base de datos**: PostgreSQL
- **DEBUG**: False
- **Seguridad**: HTTPS, HSTS, secure cookies, CSRF protections
- **Docker Compose**: `docker-compose.prod.yml`
- **Servidor**: Gunicorn con 4 workers
### Cambiar entre ambientes
La detección de ambiente es automática mediante la variable `DJANGO_ENV`. Docker Compose configura esta variable automáticamente según el archivo usado:
- `docker-compose.dev.yml` → usa `.env.development` → carga `settings/development.py`
- `docker-compose.staging.yml` → usa `.env.staging` → carga `settings/staging.py`
- `docker-compose.prod.yml` → usa `.env.production` → carga `settings/production.py`
### Variables de entorno requeridas
**Desarrollo** (`.env.development`):
- `DJANGO_ENV=development`
- `DEBUG=True`
- `TRYTON_HOST`, `TRYTON_DATABASE`, `TRYTON_USERNAME`, `TRYTON_PASSWORD`
**Staging** (`.env.staging`):
- `DJANGO_ENV=staging`
- `DEBUG=False`
- `SECRET_KEY` (para staging local, puede ser una key fija)
- `ALLOWED_HOSTS=localhost,127.0.0.1,0.0.0.0`
- `CORS_ALLOWED_ORIGINS` (URLs localhost separadas por comas)
- `DB_NAME=tienda_ilusion_staging`, `DB_USER`, `DB_PASSWORD`, `DB_HOST=postgres`, `DB_PORT=5432`
- `TRYTON_HOST`, `TRYTON_DATABASE`, `TRYTON_USERNAME`, `TRYTON_PASSWORD`
**Producción** (`.env.production`):
- `DJANGO_ENV=production`
- `DEBUG=False`
- `SECRET_KEY` (generar una nueva y segura)
- `ALLOWED_HOSTS` (dominios separados por comas)
- `CORS_ALLOWED_ORIGINS` (URLs separadas por comas)
- `CSRF_TRUSTED_ORIGINS` (URLs separadas por comas)
- `DB_NAME`, `DB_USER`, `DB_PASSWORD`, `DB_HOST`, `DB_PORT`
- `EMAIL_HOST`, `EMAIL_PORT`, `EMAIL_HOST_USER`, `EMAIL_HOST_PASSWORD`
- `TRYTON_HOST`, `TRYTON_DATABASE`, `TRYTON_USERNAME`, `TRYTON_PASSWORD`
Ver `.env.production.example` para todos los detalles.
## Scripts Útiles
El proyecto incluye scripts para facilitar operaciones comunes:
### Health Check
Verifica el estado de todos los servicios:
```bash
# Verificar ambiente de desarrollo
./scripts/health-check.sh dev
# Verificar ambiente de producción
./scripts/health-check.sh prod
```
### Backup de Base de Datos (Producción)
Crea un backup comprimido de la base de datos PostgreSQL:
```bash
./scripts/backup-db.sh
```
Los backups se guardan en `backups/` y se mantienen por 7 días automáticamente.
### Restore de Base de Datos (Producción)
Restaura un backup de la base de datos:
```bash
./scripts/restore-backup.sh <archivo_backup.sql.gz>
```
**ADVERTENCIA**: Esto reemplazará la base de datos actual. Se crea un backup de seguridad antes de restaurar.
## Troubleshooting
### Errores Comunes
#### 1. Error: "database 'tienda_ilusion_user' does not exist"
**Síntoma**: Aparece en los logs de PostgreSQL
```
FATAL: database "tienda_ilusion_user" does not exist
```
**Causa**: Este es un comportamiento normal de PostgreSQL. Cuando te conectas sin especificar una base de datos, PostgreSQL intenta conectarse a una base con el mismo nombre del usuario.
**Solución**: Este error NO afecta el funcionamiento de Django. Para verificar que la base de datos correcta existe:
```bash
docker exec tienda_ilusion_postgres_prod psql -U tienda_ilusion_user -d tienda_ilusion_prod -c '\l'
```
#### 2. Error: "required variable DB_PASSWORD is missing a value"
**Síntoma**: Docker Compose falla al iniciar con error de variable faltante
**Causa**: El archivo `.env.production` no existe o tiene valores placeholder
**Solución**:
1. Copiar el archivo de ejemplo: `cp .env.production.example .env.production`
2. Editar `.env.production` y reemplazar todos los valores `CHANGE-ME-...` con valores reales
3. Generar SECRET_KEY segura:
```bash
python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
```
#### 3. Error: "ModuleNotFoundError" o importación fallida
**Síntoma**: Django no puede importar módulos después de cambiar configuración
**Causa**: Dependencias no instaladas o contenedor con caché viejo
**Solución**:
```bash
# Reconstruir contenedores
docker-compose -f docker-compose.dev.yml build --no-cache
docker-compose -f docker-compose.dev.yml up
# Para producción
docker-compose -f docker-compose.prod.yml build --no-cache
docker-compose -f docker-compose.prod.yml up -d
```
#### 4. Error: "django.db.utils.OperationalError: could not connect to server"
**Síntoma**: Django no puede conectarse a PostgreSQL
**Causa**: PostgreSQL aún no está listo cuando Django intenta conectar
**Solución**: El healthcheck en `docker-compose.prod.yml` maneja esto automáticamente. Si el problema persiste:
```bash
# Verificar que PostgreSQL esté corriendo
docker ps | grep postgres
# Ver logs de PostgreSQL
docker logs tienda_ilusion_postgres_prod
# Reiniciar servicios en orden
docker-compose -f docker-compose.prod.yml restart postgres
docker-compose -f docker-compose.prod.yml restart django
```
#### 5. Error: "CSRF verification failed"
**Síntoma**: Errores CSRF en producción
**Causa**: `CSRF_TRUSTED_ORIGINS` no configurado correctamente
**Solución**: En `.env.production`, asegurar que `CSRF_TRUSTED_ORIGINS` incluya el protocolo:
```bash
CSRF_TRUSTED_ORIGINS=https://tudominio.com,https://www.tudominio.com
```
#### 6. Static files no se sirven en producción
**Síntoma**: CSS/JS no cargan, errores 404 para archivos estáticos
**Causa**: `collectstatic` no ejecutado o configuración de nginx incorrecta
**Solución**:
```bash
# Ejecutar collectstatic
docker-compose -f docker-compose.prod.yml exec django python manage.py collectstatic --noinput
# Verificar que los archivos existen
docker exec tienda_ilusion_django_prod ls -la /app/staticfiles
```
#### 7. Migraciones pendientes después de deployment
**Síntoma**: Errores de base de datos o tablas faltantes
**Causa**: Migraciones no aplicadas en producción
**Solución**:
```bash
# Ver estado de migraciones
docker-compose -f docker-compose.prod.yml exec django python manage.py showmigrations
# Aplicar migraciones pendientes
docker-compose -f docker-compose.prod.yml exec django python manage.py migrate
```
### Comandos de Diagnóstico
```bash
# Ver todos los contenedores
docker ps -a
# Ver logs en tiempo real
docker-compose -f docker-compose.prod.yml logs -f
# Ver logs solo de Django
docker logs -f tienda_ilusion_django_prod
# Ver logs solo de PostgreSQL
docker logs -f tienda_ilusion_postgres_prod
# Ejecutar shell en contenedor Django
docker-compose -f docker-compose.prod.yml exec django bash
# Ejecutar Django shell
docker-compose -f docker-compose.prod.yml exec django python manage.py shell
# Conectar a PostgreSQL
docker exec -it tienda_ilusion_postgres_prod psql -U tienda_ilusion_user -d tienda_ilusion_prod
# Verificar variables de entorno en contenedor
docker-compose -f docker-compose.prod.yml exec django env | grep DJANGO
```
### Performance y Optimización
Si experimentas problemas de rendimiento:
1. **Verificar recursos del contenedor**:
```bash
docker stats
```
2. **Ajustar workers de Gunicorn** (editar `docker-compose.prod.yml`):
```yaml
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 8 --timeout 120
```
Regla general: `workers = (2 x CPU cores) + 1`
3. **Habilitar persistent connections**: Ya configurado en `settings/production.py` con `CONN_MAX_AGE = 600`
4. **Considerar agregar Redis para caché**: Descomentar sección de Redis en `settings/production.py`
### Seguridad
Antes de ir a producción, verificar:
- [ ] `DEBUG=False` en `.env.production`
- [ ] `SECRET_KEY` único y seguro generado
- [ ] `ALLOWED_HOSTS` configurado con dominios reales
- [ ] `CORS_ALLOWED_ORIGINS` limitado a orígenes confiables
- [ ] `CSRF_TRUSTED_ORIGINS` configurado correctamente
- [ ] Contraseñas de base de datos seguras
- [ ] `.env.production` NO commiteado a git
- [ ] HTTPS habilitado (certificados SSL/TLS configurados)
- [ ] Firewall configurado (solo puertos necesarios abiertos)
- [ ] Backups automáticos configurados
## Integraciones
- **Tryton ERP**: Integración mediante sabatron-tryton-rpc-client para sincronización de clientes, productos y ventas

269
README.md Normal file
View File

@@ -0,0 +1,269 @@
# Don Confiao Backend - Tienda Ilusion
Backend Django con Django REST Framework para el sistema de punto de venta Tienda Ilusion.
## Características
- 🔐 Autenticación JWT con djangorestframework-simplejwt
- 🗄️ Soporte multi-ambiente (desarrollo/producción)
- 🐘 PostgreSQL para producción, SQLite para desarrollo
- 🔄 Integración con Tryton ERP
- 📦 Docker Compose para fácil deployment
- 🛡️ Configuración de seguridad completa para producción
- 📊 API REST completa para gestión de ventas, productos y clientes
## Requisitos Previos
- Docker & Docker Compose
- Python 3.11+ (para desarrollo local sin Docker)
- Git
## Inicio Rápido
### Desarrollo Local
1. **Clonar el repositorio**
```bash
git clone <repository-url>
cd don_confiao_backend
```
2. **Iniciar servicios de desarrollo**
```bash
docker-compose -f docker-compose.dev.yml up
```
3. **Aplicar migraciones**
```bash
docker-compose -f docker-compose.dev.yml run --rm django python manage.py migrate
```
4. **Crear superusuario**
```bash
docker-compose -f docker-compose.dev.yml run --rm django python manage.py createsuperuser
```
5. **Acceder a la aplicación**
- API: http://localhost:7000
- Admin: http://localhost:7000/admin
### Producción
1. **Configurar variables de entorno**
```bash
cp .env.production.example .env.production
# Editar .env.production con valores reales
```
2. **Generar SECRET_KEY segura**
```bash
python -c 'from django.core.management.utils import get_random_secret_key; print(get_random_secret_key())'
```
3. **Iniciar servicios**
```bash
docker-compose -f docker-compose.prod.yml up -d
```
4. **Las migraciones y collectstatic se ejecutan automáticamente**
- Si necesitas ejecutarlas manualmente:
```bash
docker-compose -f docker-compose.prod.yml exec django python manage.py migrate
docker-compose -f docker-compose.prod.yml exec django python manage.py collectstatic --noinput
```
5. **Crear superusuario**
```bash
docker-compose -f docker-compose.prod.yml exec django python manage.py createsuperuser
```
## Estructura del Proyecto
```
don_confiao_backend/
├── tienda_ilusion/ # Proyecto Django
│ ├── config/ # Configuración principal
│ │ └── settings/ # Settings por ambiente
│ │ ├── base.py # Configuración compartida
│ │ ├── development.py # Desarrollo
│ │ └── production.py # Producción
│ ├── don_confiao/ # App principal
│ └── users/ # App de usuarios
├── scripts/ # Scripts de utilidad
│ ├── health-check.sh # Verificación de salud
│ ├── backup-db.sh # Backup de base de datos
│ └── restore-backup.sh # Restore de backup
├── docker-compose.dev.yml # Docker Compose desarrollo
├── docker-compose.prod.yml # Docker Compose producción
└── requirements.txt # Dependencias Python
```
## Ambientes
### Development
- Base de datos: SQLite
- Debug: Habilitado
- CORS: Permisivo
- Server: Django development server
- Puerto: 7000
### Production
- Base de datos: PostgreSQL
- Debug: Deshabilitado
- CORS: Configurado por dominio
- Server: Gunicorn (4 workers)
- Puerto: 8000
- Seguridad: HTTPS, HSTS, secure cookies
## Comandos Útiles
### Desarrollo
```bash
# Ejecutar tests
docker-compose -f docker-compose.dev.yml run --rm django python manage.py test
# Shell de Django
docker-compose -f docker-compose.dev.yml run --rm django python manage.py shell
# Crear migraciones
docker-compose -f docker-compose.dev.yml run --rm django python manage.py makemigrations
# Ver logs
docker-compose -f docker-compose.dev.yml logs -f
```
### Producción
```bash
# Ver logs
docker-compose -f docker-compose.prod.yml logs -f django
# Backup de base de datos
./scripts/backup-db.sh
# Restore de backup
./scripts/restore-backup.sh backups/tienda_ilusion_backup_YYYYMMDD_HHMMSS.sql.gz
# Health check
./scripts/health-check.sh prod
# Reiniciar servicios
docker-compose -f docker-compose.prod.yml restart
# Detener servicios
docker-compose -f docker-compose.prod.yml down
```
## API Endpoints
La API REST está disponible en `/api/`. Principales endpoints:
- `/api/token/` - Obtener token JWT
- `/api/token/refresh/` - Refrescar token JWT
- `/api/customers/` - Gestión de clientes
- `/api/products/` - Gestión de productos
- `/api/sales/` - Gestión de ventas
- `/admin/` - Panel de administración Django
## Integración con Tryton ERP
El sistema se integra con Tryton ERP para sincronización de:
- Clientes
- Productos
- Ventas
Configurar las variables de entorno de Tryton en `.env.development` o `.env.production`:
```bash
TRYTON_HOST=your-tryton-server
TRYTON_DATABASE=your-database
TRYTON_USERNAME=your-username
TRYTON_PASSWORD=your-password
```
## Backup y Restore
### Crear Backup
```bash
./scripts/backup-db.sh
```
Los backups se guardan en `backups/` y se mantienen por 7 días.
### Restaurar Backup
```bash
./scripts/restore-backup.sh backups/backup_file.sql.gz
```
## Troubleshooting
Ver la sección de Troubleshooting en [AGENTS.md](AGENTS.md) para soluciones a problemas comunes.
### Problemas Comunes
1. **Error de conexión a base de datos**: Verificar que PostgreSQL esté corriendo
2. **CSRF errors**: Verificar `CSRF_TRUSTED_ORIGINS` en `.env.production`
3. **Static files no cargan**: Ejecutar `collectstatic`
4. **Errores de migración**: Verificar estado con `showmigrations`
## Seguridad
### Checklist de Producción
Antes de desplegar a producción:
- [ ] `DEBUG=False` en `.env.production`
- [ ] `SECRET_KEY` única y segura generada
- [ ] `ALLOWED_HOSTS` configurado correctamente
- [ ] `CORS_ALLOWED_ORIGINS` limitado a dominios confiables
- [ ] Contraseñas de base de datos seguras
- [ ] HTTPS habilitado con certificados SSL/TLS válidos
- [ ] Firewall configurado
- [ ] Backups automáticos configurados
- [ ] Monitoreo de logs configurado
## Desarrollo
### Agregar nuevas dependencias
```bash
# Agregar a requirements.txt
echo "nueva-dependencia==version" >> requirements.txt
# Reconstruir contenedor
docker-compose -f docker-compose.dev.yml build --no-cache
```
### Tests
```bash
# Ejecutar todos los tests
docker-compose -f docker-compose.dev.yml run --rm django python manage.py test
# Ejecutar tests de una app específica
docker-compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao
# Con coverage
docker-compose -f docker-compose.dev.yml run --rm django coverage run --source='.' manage.py test
docker-compose -f docker-compose.dev.yml run --rm django coverage report
```
## Contribuir
1. Fork el proyecto
2. Crear branch de feature (`git checkout -b feature/AmazingFeature`)
3. Commit cambios (`git commit -m 'Add some AmazingFeature'`)
4. Push al branch (`git push origin feature/AmazingFeature`)
5. Abrir Pull Request
## Licencia
[Especificar licencia]
## Contacto
[Información de contacto]
## Documentación Adicional
- [AGENTS.md](AGENTS.md) - Contexto completo del proyecto y troubleshooting
- [.env.production.example](.env.production.example) - Ejemplo de variables de producción

View File

@@ -4,5 +4,3 @@ WORKDIR /app/
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
CMD ["python", "manage.py", "runserver", "0.0.0.0:9090"]

78
doc/requests.org Normal file
View File

@@ -0,0 +1,78 @@
* Requests
Ejemplo de request contra la api usando [[https://github.com/federicotdn/verb][verb]]
** Autenticación :verb:
template http://localhost:7000/api
Content-Type: application/json;
*** Solicitar token
post /token/
{
"username": "admin",
"password": "123"
}
**** respuesta
#+begin_src json
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc"
}
#+end_src
*** Perfil de usuario
get /users/me/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc
**** Respuesta
#+begin_src json
{
"id": 2,
"username": "admin",
"email": "correo@example.com",
"first_name": "",
"last_name": ""
}
#+end_src
*** Renovar token
post /token/refresh/
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k"
}
**** response
#+begin_src json
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA"
}
#+end_src
** Don confiao :verb:
template http://localhost:7000/don_confiao/api/
Content-Type: application/json;
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA
*** todas las rutas
get
**** response
#+begin_src json
{
"sales": "http://localhost:7000/don_confiao/api/sales/",
"customers": "http://localhost:7000/don_confiao/api/customers/",
"products": "http://localhost:7000/don_confiao/api/products/",
"reconciliate_jar": "http://localhost:7000/don_confiao/api/reconciliate_jar/"
}
#+end_src
*** customers
get customers/
**** response
#+begin_src json
[
{
"id": 1,
"name": "Consumidor Final",
"address": "",
"email": "",
"phone": "",
"external_id": "2753"
},
...
]
#+end_src
*** products
get products/

21
docker-compose.dev.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
django:
build:
context: ./
dockerfile: django.Dockerfile
container_name: tienda_ilusion_django_dev
env_file:
- .env.development
environment:
- DJANGO_ENV=development
volumes:
- ./tienda_ilusion:/app/
ports:
- "7000:9090"
networks:
- tienda_network_dev
command: python manage.py runserver 0.0.0.0:9090
networks:
tienda_network_dev:
driver: bridge

89
docker-compose.prod.yml Normal file
View File

@@ -0,0 +1,89 @@
services:
postgres:
image: postgres:15-alpine
container_name: tienda_ilusion_postgres_prod
environment:
- POSTGRES_USER=${DB_USER:-tienda_ilusion_user}
- POSTGRES_DB=${DB_NAME:-tienda_ilusion_prod}
- POSTGRES_PASSWORD=${DB_PASSWORD:-tienda_ilusion_pass}
env_file:
- .env.production
volumes:
- postgres_data:/var/lib/postgresql/data
ports:
- "5432:5432"
networks:
- tienda_network
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${POSTGRES_USER:-tienda_ilusion_user} -d ${POSTGRES_DB:-tienda_ilusion_prod}",
]
interval: 10s
timeout: 5s
retries: 5
django:
build:
context: ./
dockerfile: django.Dockerfile
container_name: tienda_ilusion_django_prod
env_file:
- .env.production
environment:
- DJANGO_ENV=production
- DB_HOST=postgres
- DB_USER=${DB_USER:-tienda_ilusion_user}
- DB_NAME=${DB_NAME:-tienda_ilusion_prod}
- DB_PASSWORD=${DB_PASSWORD:-tienda_ilusion_pass}
volumes:
- ./tienda_ilusion:/app/
- static_volume:/app/staticfiles
- media_volume:/app/media
- logs_volume:/app/logs
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- tienda_network
restart: unless-stopped
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120"
# Optional: Nginx reverse proxy para servir archivos estáticos
# nginx:
# image: nginx:alpine
# container_name: tienda_ilusion_nginx
# ports:
# - "80:80"
# - "443:443"
# volumes:
# - ./nginx.conf:/etc/nginx/nginx.conf:ro
# - static_volume:/var/www/static:ro
# - media_volume:/var/www/media:ro
# - ./ssl:/etc/nginx/ssl:ro
# depends_on:
# - django
# networks:
# - tienda_network
# restart: unless-stopped
volumes:
postgres_data:
driver: local
static_volume:
driver: local
media_volume:
driver: local
logs_volume:
driver: local
networks:
tienda_network:
driver: bridge

View File

@@ -0,0 +1,71 @@
services:
postgres:
image: postgres:15-alpine
container_name: tienda_ilusion_postgres_staging
environment:
- POSTGRES_USER=${DB_USER:-tienda_ilusion_user}
- POSTGRES_DB=${DB_NAME:-tienda_ilusion_staging}
- POSTGRES_PASSWORD=${DB_PASSWORD:-staging_local_password}
env_file:
- .env.staging
volumes:
- postgres_staging_data:/var/lib/postgresql/data
ports:
- "5433:5432" # Puerto diferente para no conflictuar con prod
networks:
- tienda_staging_network
restart: unless-stopped
healthcheck:
test:
[
"CMD-SHELL",
"pg_isready -U ${DB_USER:-tienda_ilusion_user} -d ${DB_PASSWORD:-tienda_ilusion_staging}",
]
interval: 10s
timeout: 5s
retries: 5
django:
build:
context: ./
dockerfile: django.Dockerfile
container_name: tienda_ilusion_django_staging
env_file:
- .env.staging
environment:
- DJANGO_ENV=staging
- DB_HOST=postgres
- DB_USER=${DB_USER:-tienda_ilusion_user}
- DB_NAME=${DB_NAME:-tienda_ilusion_staging}
- DB_PASSWORD=${DB_PASSWORD:-staging_local_password}
volumes:
- ./tienda_ilusion:/app/
- static_staging_volume:/app/staticfiles
- media_staging_volume:/app/media
- logs_staging_volume:/app/logs
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
networks:
- tienda_staging_network
restart: unless-stopped
command: >
sh -c "python manage.py migrate &&
python manage.py collectstatic --noinput &&
gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120 --reload"
volumes:
postgres_staging_data:
driver: local
static_staging_volume:
driver: local
media_staging_volume:
driver: local
logs_staging_volume:
driver: local
networks:
tienda_staging_network:
driver: bridge

View File

@@ -1,10 +0,0 @@
services:
django:
build:
context: ./
dockerfile: django.Dockerfile
volumes:
- ./tienda_ilusion:/app/
ports:
- "7000:9090"

288
poetry.lock generated Normal file
View File

@@ -0,0 +1,288 @@
# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand.
[[package]]
name = "asgiref"
version = "3.11.1"
description = "ASGI specs, helper code, and adapters"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "asgiref-3.11.1-py3-none-any.whl", hash = "sha256:e8667a091e69529631969fd45dc268fa79b99c92c5fcdda727757e52146ec133"},
{file = "asgiref-3.11.1.tar.gz", hash = "sha256:5f184dc43b7e763efe848065441eac62229c9f7b0475f41f80e207a114eda4ce"},
]
[package.extras]
tests = ["mypy (>=1.14.0)", "pytest", "pytest-asyncio"]
[[package]]
name = "colorama"
version = "0.4.6"
description = "Cross-platform colored terminal text."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
groups = ["dev"]
files = [
{file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
]
[[package]]
name = "django"
version = "6.0.5"
description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design."
optional = false
python-versions = ">=3.12"
groups = ["main"]
files = [
{file = "django-6.0.5-py3-none-any.whl", hash = "sha256:9d58a7cb49244e74c8e161d5e403a46d6209f1009ba40f5a66d6aa0d0786a8f0"},
{file = "django-6.0.5.tar.gz", hash = "sha256:bc6d6872e98a2864c836e42edd644b362db311147dd5aa8d5b82ba7a032f5269"},
]
[package.dependencies]
asgiref = ">=3.9.1"
sqlparse = ">=0.5.0"
tzdata = {version = "*", markers = "sys_platform == \"win32\""}
[package.extras]
argon2 = ["argon2-cffi (>=23.1.0)"]
bcrypt = ["bcrypt (>=4.1.1)"]
[[package]]
name = "django-cors-headers"
version = "4.9.0"
description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)."
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449"},
{file = "django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8"},
]
[package.dependencies]
asgiref = ">=3.6"
django = ">=4.2"
[[package]]
name = "djangorestframework"
version = "3.17.1"
description = "Web APIs for Django, made easy."
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "djangorestframework-3.17.1-py3-none-any.whl", hash = "sha256:c3c74dd3e83a5a3efc37b3c18d92bd6f86a6791c7b7d4dff62bb068500e76457"},
{file = "djangorestframework-3.17.1.tar.gz", hash = "sha256:a6def5f447fe78ff853bff1d47a3c59bf38f5434b031780b351b0c73a62db1a5"},
]
[package.dependencies]
django = ">=4.2"
[[package]]
name = "djangorestframework-simplejwt"
version = "5.5.1"
description = "A minimal JSON Web Token authentication plugin for Django REST Framework"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469"},
{file = "djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f"},
]
[package.dependencies]
django = ">=4.2"
djangorestframework = ">=3.14"
pyjwt = ">=1.7.1"
[package.extras]
crypto = ["cryptography (>=3.3.1)"]
dev = ["Sphinx", "cryptography", "freezegun", "ipython", "pre-commit", "pytest", "pytest-cov", "pytest-django", "pytest-watch", "pytest-xdist", "python-jose (==3.3.0)", "pyupgrade", "ruff", "sphinx_rtd_theme (>=0.1.9)", "tox", "twine", "wheel", "yesqa"]
doc = ["Sphinx", "sphinx_rtd_theme (>=0.1.9)"]
lint = ["pre-commit", "pyupgrade", "ruff", "yesqa"]
python-jose = ["python-jose (==3.3.0)"]
test = ["cryptography", "freezegun", "pytest", "pytest-cov", "pytest-django", "pytest-xdist", "tox"]
[[package]]
name = "mslex"
version = "1.3.0"
description = "shlex for windows"
optional = false
python-versions = ">=3.5"
groups = ["dev"]
markers = "sys_platform == \"win32\""
files = [
{file = "mslex-1.3.0-py3-none-any.whl", hash = "sha256:c7074b347201b3466fc077c5692fbce9b5f62a63a51f537a53fbbd02eff2eea4"},
{file = "mslex-1.3.0.tar.gz", hash = "sha256:641c887d1d3db610eee2af37a8e5abda3f70b3006cdfd2d0d29dc0d1ae28a85d"},
]
[[package]]
name = "psutil"
version = "6.1.1"
description = "Cross-platform lib for process and system monitoring in Python."
optional = false
python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,>=2.7"
groups = ["dev"]
files = [
{file = "psutil-6.1.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:9ccc4316f24409159897799b83004cb1e24f9819b0dcf9c0b68bdcb6cefee6a8"},
{file = "psutil-6.1.1-cp27-cp27m-manylinux2010_i686.whl", hash = "sha256:ca9609c77ea3b8481ab005da74ed894035936223422dc591d6772b147421f777"},
{file = "psutil-6.1.1-cp27-cp27m-manylinux2010_x86_64.whl", hash = "sha256:8df0178ba8a9e5bc84fed9cfa61d54601b371fbec5c8eebad27575f1e105c0d4"},
{file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_i686.whl", hash = "sha256:1924e659d6c19c647e763e78670a05dbb7feaf44a0e9c94bf9e14dfc6ba50468"},
{file = "psutil-6.1.1-cp27-cp27mu-manylinux2010_x86_64.whl", hash = "sha256:018aeae2af92d943fdf1da6b58665124897cfc94faa2ca92098838f83e1b1bca"},
{file = "psutil-6.1.1-cp27-none-win32.whl", hash = "sha256:6d4281f5bbca041e2292be3380ec56a9413b790579b8e593b1784499d0005dac"},
{file = "psutil-6.1.1-cp27-none-win_amd64.whl", hash = "sha256:c777eb75bb33c47377c9af68f30e9f11bc78e0f07fbf907be4a5d70b2fe5f030"},
{file = "psutil-6.1.1-cp36-abi3-macosx_10_9_x86_64.whl", hash = "sha256:fc0ed7fe2231a444fc219b9c42d0376e0a9a1a72f16c5cfa0f68d19f1a0663e8"},
{file = "psutil-6.1.1-cp36-abi3-macosx_11_0_arm64.whl", hash = "sha256:0bdd4eab935276290ad3cb718e9809412895ca6b5b334f5a9111ee6d9aff9377"},
{file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6e06c20c05fe95a3d7302d74e7097756d4ba1247975ad6905441ae1b5b66003"},
{file = "psutil-6.1.1-cp36-abi3-manylinux_2_12_x86_64.manylinux2010_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:97f7cb9921fbec4904f522d972f0c0e1f4fabbdd4e0287813b21215074a0f160"},
{file = "psutil-6.1.1-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:33431e84fee02bc84ea36d9e2c4a6d395d479c9dd9bba2376c1f6ee8f3a4e0b3"},
{file = "psutil-6.1.1-cp36-cp36m-win32.whl", hash = "sha256:384636b1a64b47814437d1173be1427a7c83681b17a450bfc309a1953e329603"},
{file = "psutil-6.1.1-cp36-cp36m-win_amd64.whl", hash = "sha256:8be07491f6ebe1a693f17d4f11e69d0dc1811fa082736500f649f79df7735303"},
{file = "psutil-6.1.1-cp37-abi3-win32.whl", hash = "sha256:eaa912e0b11848c4d9279a93d7e2783df352b082f40111e078388701fd479e53"},
{file = "psutil-6.1.1-cp37-abi3-win_amd64.whl", hash = "sha256:f35cfccb065fff93529d2afb4a2e89e363fe63ca1e4a5da22b603a85833c2649"},
{file = "psutil-6.1.1.tar.gz", hash = "sha256:cf8496728c18f2d0b45198f06895be52f36611711746b7f30c464b422b50e2f5"},
]
[package.extras]
dev = ["abi3audit", "black", "check-manifest", "coverage", "packaging", "pylint", "pyperf", "pypinfo", "pytest-cov", "requests", "rstcheck", "ruff", "sphinx", "sphinx_rtd_theme", "toml-sort", "twine", "virtualenv", "vulture", "wheel"]
test = ["pytest", "pytest-xdist", "setuptools"]
[[package]]
name = "pyjwt"
version = "2.12.1"
description = "JSON Web Token implementation in Python"
optional = false
python-versions = ">=3.9"
groups = ["main"]
files = [
{file = "pyjwt-2.12.1-py3-none-any.whl", hash = "sha256:28ca37c070cad8ba8cd9790cd940535d40274d22f80ab87f3ac6a713e6e8454c"},
{file = "pyjwt-2.12.1.tar.gz", hash = "sha256:c74a7a2adf861c04d002db713dd85f84beb242228e671280bf709d765b03672b"},
]
[package.extras]
crypto = ["cryptography (>=3.4.0)"]
dev = ["coverage[toml] (==7.10.7)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=8.4.2,<9.0.0)", "sphinx", "sphinx-rtd-theme", "zope.interface"]
docs = ["sphinx", "sphinx-rtd-theme", "zope.interface"]
tests = ["coverage[toml] (==7.10.7)", "pytest (>=8.4.2,<9.0.0)"]
[[package]]
name = "sabatron-tryton-rpc-client"
version = "7.4.0"
description = "Python RPC Client for Tryton"
optional = false
python-versions = ">=3.6"
groups = ["main"]
files = [
{file = "sabatron_tryton_rpc_client-7.4.0-py3-none-any.whl", hash = "sha256:131d8d9d6a1dc1d556d4806fa7750ca637247f021881dca5c2855d8a44e4bd45"},
{file = "sabatron_tryton_rpc_client-7.4.0.tar.gz", hash = "sha256:3d3ededd1a8488463d6338cd7d8b2a271834ba42fc220ebb8e15e983c0e5b19d"},
]
[[package]]
name = "sqlparse"
version = "0.5.5"
description = "A non-validating SQL parser."
optional = false
python-versions = ">=3.8"
groups = ["main"]
files = [
{file = "sqlparse-0.5.5-py3-none-any.whl", hash = "sha256:12a08b3bf3eec877c519589833aed092e2444e68240a3577e8e26148acc7b1ba"},
{file = "sqlparse-0.5.5.tar.gz", hash = "sha256:e20d4a9b0b8585fdf63b10d30066c7c94c5d7a7ec47c889a2d83a3caa93ff28e"},
]
[package.extras]
dev = ["build"]
doc = ["sphinx"]
[[package]]
name = "taskipy"
version = "1.14.1"
description = "tasks runner for python projects"
optional = false
python-versions = "<4.0,>=3.6"
groups = ["dev"]
files = [
{file = "taskipy-1.14.1-py3-none-any.whl", hash = "sha256:6e361520f29a0fd2159848e953599f9c75b1d0b047461e4965069caeb94908f1"},
{file = "taskipy-1.14.1.tar.gz", hash = "sha256:410fbcf89692dfd4b9f39c2b49e1750b0a7b81affd0e2d7ea8c35f9d6a4774ed"},
]
[package.dependencies]
colorama = ">=0.4.4,<0.5.0"
mslex = {version = ">=1.1.0,<2.0.0", markers = "sys_platform == \"win32\""}
psutil = ">=5.7.2,<7"
tomli = {version = ">=2.0.1,<3.0.0", markers = "python_version >= \"3.7\" and python_version < \"4.0\""}
[[package]]
name = "tomli"
version = "2.4.1"
description = "A lil' TOML parser"
optional = false
python-versions = ">=3.8"
groups = ["dev"]
files = [
{file = "tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30"},
{file = "tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076"},
{file = "tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c"},
{file = "tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc"},
{file = "tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049"},
{file = "tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e"},
{file = "tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece"},
{file = "tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a"},
{file = "tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9"},
{file = "tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585"},
{file = "tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1"},
{file = "tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917"},
{file = "tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9"},
{file = "tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257"},
{file = "tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54"},
{file = "tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897"},
{file = "tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d"},
{file = "tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5"},
{file = "tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd"},
{file = "tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36"},
{file = "tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd"},
{file = "tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf"},
{file = "tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662"},
{file = "tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15"},
{file = "tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba"},
{file = "tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6"},
{file = "tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7"},
{file = "tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4"},
{file = "tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d"},
{file = "tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c"},
{file = "tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f"},
{file = "tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8"},
{file = "tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26"},
{file = "tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396"},
{file = "tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe"},
{file = "tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f"},
]
[[package]]
name = "tzdata"
version = "2026.2"
description = "Provider of IANA time zone data"
optional = false
python-versions = ">=2"
groups = ["main"]
markers = "sys_platform == \"win32\""
files = [
{file = "tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7"},
{file = "tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10"},
]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<4.0"
content-hash = "02efc37f4afc4dbe36959374dba58e3b6e61be3f8c4298427009b9ee0d3a1910"

50
pyproject.toml Normal file
View File

@@ -0,0 +1,50 @@
[project]
name = "don-confiao-backend"
version = "0.1.0"
description = "Shop for Recreo store"
authors = [
{name = "aserrador",email = "alejandro.ayala@onecluster.org"}
]
license = {text = "GPL-3.0-or-later"}
requires-python = ">=3.14,<4.0"
dependencies = [
"django (>=6.0.5,<7.0.0)",
"djangorestframework (>=3.17.1,<4.0.0)",
"django-cors-headers (>=4.9.0,<5.0.0)",
"djangorestframework-simplejwt (>=5.5.1,<6.0.0)",
"sabatron-tryton-rpc-client (>=7.4.0,<8.0.0)"
]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"taskipy (>=1.14.1,<2.0.0)"
]
[tool.taskipy.tasks]
dev-up = "docker compose -f docker-compose.dev.yml up -d"
dev-logs = "docker compose -f docker-compose.dev.yml logs -f -n 50"
dev-stop = "docker compose -f docker-compose.dev.yml stop"
dev-restart = "docker compose -f docker-compose.dev.yml restart"
dev-down = "docker compose -f docker-compose.dev.yml down -vv --rmi all"
dev-tail = "docker compose -f docker-compose.dev.yml logs -f"
dev-sh = "docker compose -f docker-compose.dev.yml exec -it --user root django bash"
dev-migrate = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py migrate"
dev-createsuperuser = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py createsuperuser"
dev-test = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py test"
live-up = "docker compose -f docker-compose.staging.yml up -d"
live-logs = "docker compose -f docker-compose.staging.yml logs -f -n 50"
live-down = "docker compose -f docker-compose.staging.yml down -vv --rmi all"
live-sh = "docker compose -f docker-compose.staging.yml exec -it --user root django bash"
prod-up = "docker compose -f docker-compose.prod.yml up -d"
prod-logs = "docker compose -f docker-compose.prod.yml logs -f -n 50"
prod-down = "docker compose -f docker-compose.prod.yml down -vv --rmi all"
prod-sh = "docker compose -f docker-compose.prod.yml exec -it --user root django bash"

View File

@@ -1,3 +1,17 @@
Django==5.0.6
djangorestframework
django-cors-headers
djangorestframework-simplejwt
sabatron-tryton-rpc-client==7.4.0
# Database drivers
psycopg2-binary # PostgreSQL driver for production
# Production server
gunicorn # WSGI HTTP Server for production
# Environment variable management
python-decouple # Manage environment variables and settings
# Static files serving in production/staging
whitenoise==6.6.0 # Serve static files efficiently with compression

View File

@@ -11,6 +11,6 @@ import os
from django.core.asgi import get_asgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tienda_ilusion.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_asgi_application()

View File

@@ -0,0 +1,43 @@
"""
Settings module for tienda_ilusion project.
This module automatically loads the appropriate settings based on the DJANGO_ENV
environment variable. Valid values are:
- 'development' (default): Development settings with SQLite
- 'staging': Staging settings for testing production config locally (PostgreSQL, no SSL)
- 'production': Production settings with full security (PostgreSQL, SSL, etc.)
Usage:
Set DJANGO_ENV environment variable before running Django:
Development:
export DJANGO_ENV=development
python manage.py runserver
Staging (test production locally):
export DJANGO_ENV=staging
python manage.py runserver
Production:
export DJANGO_ENV=production
gunicorn config.wsgi:application
"""
import os
# Determine which environment settings to load
DJANGO_ENV = os.environ.get("DJANGO_ENV", "development")
if DJANGO_ENV == "production":
from .production import *
elif DJANGO_ENV == "staging":
from .staging import *
elif DJANGO_ENV == "development":
from .development import *
else:
# Fallback to development if unknown environment
from .development import *
print(
f"Warning: Unknown DJANGO_ENV '{DJANGO_ENV}', falling back to development settings"
)

View File

@@ -0,0 +1,176 @@
"""
Django settings for tienda_ilusion project.
Generated by 'django-admin startproject' using Django 5.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
import os
from datetime import timedelta
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
# BASE_DIR apunta a tienda_ilusion/ (3 niveles arriba desde settings/base.py)
BASE_DIR = Path(__file__).resolve().parent.parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
# This will be overridden in production settings
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"django-insecure-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v",
)
# SECURITY WARNING: don't run with debug turned on in production!
# This will be overridden in each environment
DEBUG = True
# This will be overridden in each environment
ALLOWED_HOSTS = []
# This will be overridden in each environment
CORS_ALLOWED_ORIGINS = []
# Application definition
INSTALLED_APPS = [
"don_confiao.apps.DonConfiaoConfig",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"rest_framework.authtoken",
"corsheaders",
"users",
# 'don_confiao'
]
MIDDLEWARE = [
"django.middleware.security.SecurityMiddleware",
"whitenoise.middleware.WhiteNoiseMiddleware", # Serve static files (must be after SecurityMiddleware)
"corsheaders.middleware.CorsMiddleware", # Must be before CommonMiddleware
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = "config.urls"
TEMPLATES = [
{
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "tienda_ilusion/templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = "config.wsgi.application"
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
# This will be overridden in each environment
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
},
{
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
},
{
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
},
{
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = "en-us"
TIME_ZONE = "UTC"
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
FIXTURE_DIRS = ["don_confiao/tests/Fixtures"]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
"DEFAULT_PERMISSION_CLASSES": [
"rest_framework.permissions.IsAuthenticated",
],
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"AUTH_HEADER_TYPES": ("Bearer",),
}
# Logging configuration (can be overridden in environment settings)
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"handlers": {
"console": {
"class": "logging.StreamHandler",
},
},
"root": {
"handlers": ["console"],
"level": "INFO",
},
}

View File

@@ -0,0 +1,115 @@
"""
Development settings for tienda_ilusion project.
This file contains settings specific to local development environment.
Uses SQLite database and permissive CORS settings for easier development.
"""
import os
from .base import *
# SECURITY WARNING: don't use this in production!
DEBUG = True
# SECRET_KEY for development (insecure, but convenient for development)
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"django-insecure-development-key-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v",
)
# Allow all hosts in development for easier testing
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(
","
)
# CORS settings for development
CORS_ALLOWED_ORIGINS = os.environ.get(
"CORS_ALLOWED_ORIGINS",
"http://localhost:3000,http://localhost:7001,http://localhost:5173",
).split(",")
# Allow credentials in CORS for development
CORS_ALLOW_CREDENTIALS = True
# Database - SQLite for development
DATABASES = {
"default": {
"ENGINE": "django.db.backends.sqlite3",
"NAME": BASE_DIR / "db.sqlite3",
}
}
# Email backend for development (prints to console)
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Static files configuration for development
# Note: With DEBUG=True, Django's runserver serves static files automatically
# But WhiteNoise can still be used for consistency across environments
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
# WhiteNoise configuration for development (optional, for consistency)
# In development with DEBUG=True, Django serves static files automatically
# But this ensures consistent behavior across all environments
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedStaticFilesStorage", # No manifest in dev
},
}
# WhiteNoise settings for development
WHITENOISE_AUTOREFRESH = True # Auto-reload static files
WHITENOISE_USE_FINDERS = True # Use Django's staticfiles finders (convenient for dev)
WHITENOISE_MANIFEST_STRICT = False # Permissive mode
# Enhanced logging for development
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "[{levelname}] {asctime} {module} {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
},
"root": {
"handlers": ["console"],
"level": "DEBUG",
},
"loggers": {
"django": {
"handlers": ["console"],
"level": "INFO",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "DEBUG",
"propagate": False,
},
},
}
# Development-only apps (optional)
# Uncomment to add django-debug-toolbar or other dev tools
# INSTALLED_APPS += [
# 'debug_toolbar',
# ]
# MIDDLEWARE += [
# 'debug_toolbar.middleware.DebugToolbarMiddleware',
# ]
# INTERNAL_IPS for debug toolbar
# INTERNAL_IPS = [
# '127.0.0.1',
# ]

View File

@@ -0,0 +1,192 @@
"""
Production settings for tienda_ilusion project.
This file contains settings specific to production environment.
Uses PostgreSQL database and strict security settings.
IMPORTANT: All sensitive values MUST be provided via environment variables.
"""
import os
from .base import *
# SECURITY WARNING: DEBUG must be False in production!
DEBUG = False
# SECRET_KEY must be provided via environment variable in production
SECRET_KEY = os.environ.get("SECRET_KEY")
if not SECRET_KEY:
raise ValueError("SECRET_KEY environment variable must be set in production!")
# ALLOWED_HOSTS must be provided via environment variable
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "").split(",")
if not ALLOWED_HOSTS or ALLOWED_HOSTS == [""]:
raise ValueError("ALLOWED_HOSTS environment variable must be set in production!")
# CORS settings for production
CORS_ALLOWED_ORIGINS = os.environ.get("CORS_ALLOWED_ORIGINS", "").split(",")
if not CORS_ALLOWED_ORIGINS or CORS_ALLOWED_ORIGINS == [""]:
raise ValueError(
"CORS_ALLOWED_ORIGINS environment variable must be set in production!"
)
CORS_ALLOW_CREDENTIALS = True
# Database - PostgreSQL for production
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "tienda_ilusion"),
"USER": os.environ.get("DB_USER", "postgres"),
"PASSWORD": os.environ.get("DB_PASSWORD"),
"HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 600, # Persistent connections
}
}
if not DATABASES["default"]["PASSWORD"]:
raise ValueError("DB_PASSWORD environment variable must be set in production!")
# Security settings for HTTPS
SECURE_SSL_REDIRECT = True
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# HTTP Strict Transport Security (HSTS)
SECURE_HSTS_SECONDS = 31536000 # 1 year
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_PRELOAD = True
# Proxy headers for deployment behind reverse proxy (nginx, etc.)
SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Email configuration for production
# Adjust based on your email service
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
EMAIL_HOST = os.environ.get("EMAIL_HOST", "smtp.gmail.com")
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587"))
EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "True") == "True"
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "noreply@tiendailusion.com")
# Static files configuration for production
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# WhiteNoise configuration for serving static files in production
# Django 4.2+ uses STORAGES setting instead of STATICFILES_STORAGE
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# WhiteNoise settings optimized for production
WHITENOISE_AUTOREFRESH = False # Disabled in production for better performance
WHITENOISE_USE_FINDERS = False # Use collected static files only
WHITENOISE_MANIFEST_STRICT = True # Strict mode - fail if referenced file is missing
WHITENOISE_MAX_AGE = 31536000 # 1 year cache for files with content hash
WHITENOISE_ALLOW_ALL_ORIGINS = False # Security: don't allow CORS for static files
# Production logging - log to file and console
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "[{levelname}] {asctime} {name} {message}",
"style": "{",
},
"simple": {
"format": "[{levelname}] {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "simple",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "django.log",
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
},
},
"root": {
"handlers": ["console", "file"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"django.security": {
"handlers": ["console", "file"],
"level": "WARNING",
"propagate": False,
},
},
}
# Create logs directory if it doesn't exist
import pathlib
logs_dir = BASE_DIR / "logs"
pathlib.Path(logs_dir).mkdir(parents=True, exist_ok=True)
# Admin email notifications for errors (optional)
ADMINS = [
("Admin", os.environ.get("ADMIN_EMAIL", "admin@tiendailusion.com")),
]
MANAGERS = ADMINS
# Cache configuration (optional - adjust based on your setup)
# Using local memory cache by default
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "unique-snowflake",
}
}
# For Redis cache (recommended for production):
# CACHES = {
# "default": {
# "BACKEND": "django_redis.cache.RedisCache",
# "LOCATION": os.environ.get("REDIS_URL", "redis://127.0.0.1:6379/1"),
# "OPTIONS": {
# "CLIENT_CLASS": "django_redis.client.DefaultClient",
# }
# }
# }
# Session configuration
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# CSRF configuration
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Lax"
CSRF_TRUSTED_ORIGINS = os.environ.get("CSRF_TRUSTED_ORIGINS", "").split(",")
# Performance optimizations
CONN_MAX_AGE = 600 # Database connection pooling

View File

@@ -0,0 +1,206 @@
"""
Staging settings for tienda_ilusion project.
This file contains settings for staging environment - simulates production
configuration but runs on localhost without SSL redirect for easier testing.
Use this environment to test production-like configuration locally before
deploying to real production.
"""
import os
from .base import *
# SECURITY WARNING: DEBUG is False to simulate production behavior
DEBUG = False
# SECRET_KEY for staging (use a fixed key for local staging, not for real production)
SECRET_KEY = os.environ.get(
"SECRET_KEY",
"staging-local-key-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v",
)
# Allow localhost for staging
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split(
","
)
# CORS settings for staging - permissive for localhost testing
CORS_ALLOWED_ORIGINS = os.environ.get(
"CORS_ALLOWED_ORIGINS",
"http://localhost:8000,http://localhost:3000,http://localhost:5173,http://localhost:7001,http://127.0.0.1:8000",
).split(",")
CORS_ALLOW_CREDENTIALS = True
# Additional CORS headers for better compatibility
CORS_ALLOW_HEADERS = [
"accept",
"accept-encoding",
"authorization",
"content-type",
"dnt",
"origin",
"user-agent",
"x-csrftoken",
"x-requested-with",
]
CORS_EXPOSE_HEADERS = ["Content-Type", "X-CSRFToken"]
# Database - PostgreSQL for staging (simulates production)
DATABASES = {
"default": {
"ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "tienda_ilusion_staging"),
"USER": os.environ.get("DB_USER", "tienda_ilusion_user"),
"PASSWORD": os.environ.get("DB_PASSWORD", "staging_local_password"),
"HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 600, # Persistent connections
}
}
# Security settings - DISABLED SSL redirect for localhost testing
# This is the key difference from production.py
SECURE_SSL_REDIRECT = False # Permite HTTP en localhost
SESSION_COOKIE_SECURE = False # Permite cookies sin HTTPS
CSRF_COOKIE_SECURE = False # Permite CSRF sin HTTPS
# Keep other security features enabled
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
X_FRAME_OPTIONS = "DENY"
# NO HSTS for staging (only for production with real HTTPS)
SECURE_HSTS_SECONDS = 0
SECURE_HSTS_INCLUDE_SUBDOMAINS = False
SECURE_HSTS_PRELOAD = False
# Proxy headers - disabled for staging
# SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https")
# Email configuration for staging - use console backend for easier debugging
EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
# Optional: Use SMTP for staging if you want to test real emails
# EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
# EMAIL_HOST = os.environ.get("EMAIL_HOST", "smtp.gmail.com")
# EMAIL_PORT = int(os.environ.get("EMAIL_PORT", "587"))
# EMAIL_USE_TLS = os.environ.get("EMAIL_USE_TLS", "True") == "True"
# EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", "")
# EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", "")
# DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "staging@tiendailusion.local")
# Static files configuration
STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# WhiteNoise configuration for serving static files in staging
# Django 4.2+ uses STORAGES setting instead of STATICFILES_STORAGE
STORAGES = {
"default": {
"BACKEND": "django.core.files.storage.FileSystemStorage",
},
"staticfiles": {
"BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage",
},
}
# WhiteNoise settings for staging
WHITENOISE_AUTOREFRESH = (
True # Auto-reload static files in staging (convenient for testing)
)
WHITENOISE_USE_FINDERS = False # Use collected static files only
WHITENOISE_MANIFEST_STRICT = False # More permissive in staging
WHITENOISE_MAX_AGE = 600 # 10 minutes cache for staging (allows easier testing)
# Staging logging - similar to production but more verbose
LOGGING = {
"version": 1,
"disable_existing_loggers": False,
"formatters": {
"verbose": {
"format": "[{levelname}] {asctime} {name} {message}",
"style": "{",
},
"simple": {
"format": "[{levelname}] {message}",
"style": "{",
},
},
"handlers": {
"console": {
"class": "logging.StreamHandler",
"formatter": "verbose",
},
"file": {
"class": "logging.handlers.RotatingFileHandler",
"filename": BASE_DIR / "logs" / "staging.log",
"maxBytes": 1024 * 1024 * 15, # 15MB
"backupCount": 10,
"formatter": "verbose",
},
},
"root": {
"handlers": ["console", "file"],
"level": "INFO",
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"django.security": {
"handlers": ["console", "file"],
"level": "WARNING",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "INFO", # Show SQL queries in staging for debugging
"propagate": False,
},
},
}
# Create logs directory if it doesn't exist
import pathlib
logs_dir = BASE_DIR / "logs"
pathlib.Path(logs_dir).mkdir(parents=True, exist_ok=True)
# Admin email notifications
ADMINS = [
("Staging Admin", os.environ.get("ADMIN_EMAIL", "admin@tiendailusion.local")),
]
MANAGERS = ADMINS
# Cache configuration - local memory cache for staging
CACHES = {
"default": {
"BACKEND": "django.core.cache.backends.locmem.LocMemCache",
"LOCATION": "staging-cache",
}
}
# Session configuration
SESSION_ENGINE = "django.contrib.sessions.backends.db"
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "Lax"
# CSRF configuration
CSRF_COOKIE_HTTPONLY = True
CSRF_COOKIE_SAMESITE = "Lax"
CSRF_TRUSTED_ORIGINS = os.environ.get(
"CSRF_TRUSTED_ORIGINS", "http://localhost:8000,http://127.0.0.1:8000"
).split(",")
# Performance optimizations
CONN_MAX_AGE = 600 # Database connection pooling

View File

@@ -16,11 +16,19 @@ Including another URLconf
"""
from django.contrib import admin
from django.urls import include, path
from rest_framework_simplejwt.views import (
TokenObtainPairView,
TokenRefreshView,
)
app_name = "don_confiao"
urlpatterns = [
path("don_confiao/", include("don_confiao.urls")),
path('admin/', admin.site.urls),
path('api/token/', TokenObtainPairView.as_view(),
name='token_obtain_pair'),
path('api/token/refresh/', TokenRefreshView.as_view(),
name='token_refresh'),
path('api/users/', include('users.urls')),
]

View File

@@ -11,6 +11,6 @@ import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tienda_ilusion.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
application = get_wsgi_application()

View File

@@ -3,12 +3,26 @@ from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.views import APIView
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import IsAuthenticated
from .models import Sale, SaleLine, Customer, Product, ReconciliationJar, PaymentMethods, AdminCode
from .serializers import SaleSerializer, ProductSerializer, CustomerSerializer, ReconciliationJarSerializer, PaymentMethodSerializer, SaleForRenconciliationSerializer, SaleSummarySerializer
from .views import sales_to_tryton_csv
from .permissions import IsAdministrator
from decimal import Decimal
import json
from sabatron_tryton_rpc_client.client import Client
import io
import csv
import os
TRYTON_HOST = os.environ.get('TRYTON_HOST', 'localhost')
TRYTON_DATABASE = os.environ.get('TRYTON_DATABASE', 'tryton')
TRYTON_USERNAME = os.environ.get('TRYTON_USERNAME', 'admin')
TRYTON_PASSWORD = os.environ.get('TRYTON_PASSWORD', 'admin')
TRYTON_COP_CURRENCY = 31
TRYTON_COMPANY_ID = 1
TRYTON_SHOPS = [1]
class Pagination(PageNumberPagination):
@@ -26,10 +40,12 @@ class SaleView(viewsets.ModelViewSet):
date = data['date']
lines = data['saleline_set']
payment_method = data['payment_method']
description = data.get('notes', '')
sale = Sale.objects.create(
customer=customer,
date=date,
payment_method=payment_method
payment_method=payment_method,
description=description
)
for line in lines:
@@ -60,6 +76,8 @@ class CustomerView(viewsets.ModelViewSet):
class ReconciliateJarView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
data = request.data
cash_purchases_id = data.get('cash_purchases')
@@ -85,7 +103,8 @@ class ReconciliateJarView(APIView):
def _is_valid_total(self, purchases, total):
calculated_total = sum(p.get_total() for p in purchases)
return calculated_total == Decimal(total)
return Decimal(calculated_total).quantize(Decimal('.0001')) == (
Decimal(total).quantize(Decimal('.0001')))
def _get_other_purchases(self, other_totals):
if not other_totals:
@@ -116,6 +135,8 @@ class PaymentMethodView(APIView):
class SalesForReconciliationView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.filter(reconciliation=None)
grouped_sales = {}
@@ -128,13 +149,17 @@ class SalesForReconciliationView(APIView):
return Response(grouped_sales)
class SaleSummary(APIView):
def get(self, request, id):
sale = Sale.objects.get(pk=id)
serializer = SaleSummarySerializer(sale)
return Response(serializer.data)
class AdminCodeValidateView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request, code):
codes = AdminCode.objects.filter(value=code)
return Response({'validCode': bool(codes)})
@@ -144,3 +169,289 @@ class ReconciliateJarModelView(viewsets.ModelViewSet):
queryset = ReconciliationJar.objects.all().order_by('-date_time')
pagination_class = Pagination
serializer_class = ReconciliationJarSerializer
permission_classes = [IsAuthenticated, IsAdministrator]
class SalesForTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.all()
csv = self._generate_sales_CSV(sales)
return Response({'csv': csv})
def _generate_sales_CSV(self, sales):
output = io.StringIO()
writer = csv.writer(output)
csv_data = sales_to_tryton_csv(sales)
for row in csv_data:
writer.writerow(row)
return output.getvalue()
class SalesToTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
database=TRYTON_DATABASE,
username=TRYTON_USERNAME,
password=TRYTON_PASSWORD
)
tryton_client.connect()
method = 'model.sale.sale.create'
tryton_context = {'company': TRYTON_COMPANY_ID,
'shops': TRYTON_SHOPS}
successful = []
failed = []
sales = Sale.objects.filter(external_id=None)
for sale in sales:
try:
lines = SaleLine.objects.filter(sale=sale.id)
tryton_params = self.__to_tryton_params(sale, lines, tryton_context)
external_ids = tryton_client.call(method, tryton_params)
sale.external_id = external_ids[0]
sale.save()
successful.append(sale.id)
except Exception as e:
print(f"Error al enviar la venta: {e}"
f"venta_id: {sale.id}")
failed.append(sale.id)
continue
return Response(
{'successful': successful, 'failed': failed},
status=200
)
def __to_tryton_params(self, sale, lines, tryton_context):
sale_tryton = TrytonSale(sale, lines)
return [[sale_tryton.to_tryton()], tryton_context]
class TrytonSale:
def __init__(self, sale, lines):
self.sale = sale
self.lines = lines
def _format_date(self, _date):
return {"__class__": "date", "year": _date.year, "month": _date.month,
"day": _date.day}
def to_tryton(self):
return {
"company": TRYTON_COMPANY_ID,
"shipment_address": self.sale.customer.address_external_id,
"invoice_address": self.sale.customer.address_external_id,
"currency": TRYTON_COP_CURRENCY,
"comment": self.sale.description or '',
"description": "Metodo pago: " + str(
self.sale.payment_method or ''
),
"party": self.sale.customer.external_id,
"reference": "don_confiao " + str(self.sale.id),
"sale_date": self._format_date(self.sale.date),
"lines": [[
"create",
[TrytonLineSale(line).to_tryton() for line in self.lines]
]],
"self_pick_up": True,
}
class TrytonLineSale:
def __init__(self, sale_line):
self.sale_line = sale_line
def _format_decimal(self, number):
return {"__class__": "Decimal", "decimal": str(number)}
def to_tryton(self):
return {
"product": self.sale_line.product.external_id,
"quantity": self._format_decimal(self.sale_line.quantity),
"type": "line",
"unit": self.sale_line.product.unit_external_id,
"unit_price": self._format_decimal(self.sale_line.unit_price)
}
class ProductsFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
database=TRYTON_DATABASE,
username=TRYTON_USERNAME,
password=TRYTON_PASSWORD
)
tryton_client.connect()
method = 'model.product.product.search'
context = {'company': 1}
params = [[["salable", "=", True]], 0, 1000, [["rec_name", "ASC"], ["id", None]], context]
product_ids = tryton_client.call(method, params)
tryton_products = self.__get_product_datails_from_tryton(
product_ids, tryton_client, context
)
checked_tryton_products = product_ids
failed_products = []
updated_products = []
created_products = []
untouched_products = []
for tryton_product in tryton_products:
try:
product = Product.objects.get(
external_id=tryton_product.get('id')
)
except Product.DoesNotExist:
try:
product = self.__create_product(tryton_product)
created_products.append(product.id)
continue
except Exception as e:
print(f"Error al importar productos: {e}"
f"El producto: {tryton_product}")
failed_products.append(tryton_product.get('id'))
continue
if self.__need_update(product, tryton_product):
self.__update_product(product, tryton_product)
updated_products.append(product.id)
else:
untouched_products.append(product.id)
return Response(
{
'checked_tryton_products': checked_tryton_products,
'failed_products': failed_products,
'updated_products': updated_products,
'created_products': created_products,
'untouched_products': untouched_products,
},
status=200
)
def __get_product_datails_from_tryton(self, product_ids, tryton_client, context):
tryton_fields = ['id', 'name', 'default_uom.id',
'default_uom.rec_name', 'list_price']
method = 'model.product.product.read'
params = (product_ids, tryton_fields, context)
response = tryton_client.call(method, params)
return response
def __need_update(self, product, tryton_product):
if not product.name == tryton_product.get('name'):
return True
if not product.price == tryton_product.get('list_price'):
return True
unit = tryton_product.get('default_uom.')
if not product.measuring_unit == unit.get('rec_name'):
return True
def __create_product(self, tryton_product):
product = Product()
product.name = tryton_product.get('name')
product.price = tryton_product.get('list_price')
product.external_id = tryton_product.get('id')
unit = tryton_product.get('default_uom.')
product.measuring_unit = unit.get('rec_name')
product.unit_external_id = unit.get('id')
product.save()
return product
def __update_product(self, product, tryton_product):
product.name = tryton_product.get('name')
product.price = tryton_product.get('list_price')
product.external_id = tryton_product.get('id')
unit = tryton_product.get('default_uom.')
product.measuring_unit = unit.get('rec_name')
product.unit_external_id = unit.get('id')
product.save()
class CustomersFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
database=TRYTON_DATABASE,
username=TRYTON_USERNAME,
password=TRYTON_PASSWORD
)
tryton_client.connect()
method = 'model.party.party.search'
context = {'company': 1}
params = [[], 0, 1000, [["name", "ASC"], ["id", None]], context]
party_ids = tryton_client.call(method, params)
tryton_parties = self.__get_party_datails(
party_ids, tryton_client, context
)
checked_tryton_parties = party_ids
failed_parties = []
updated_customers = []
created_customers = []
untouched_customers = []
for tryton_party in tryton_parties:
try:
customer = Customer.objects.get(
external_id=tryton_party.get('id')
)
except Customer.DoesNotExist:
customer = self.__create_customer(tryton_party)
created_customers.append(customer.id)
continue
if self.__need_update(customer, tryton_party):
self.__update_customer(customer, tryton_party)
updated_customers.append(customer.id)
else:
untouched_customers.append(customer.id)
return Response(
{
'checked_tryton_parties': checked_tryton_parties,
'failed_parties': failed_parties,
'updated_customers': updated_customers,
'created_customers': created_customers,
'untouched_customers': untouched_customers,
},
status=200
)
def __get_party_datails(self, party_ids, tryton_client, context):
tryton_fields = ['id', 'name', 'addresses']
method = 'model.party.party.read'
params = (party_ids, tryton_fields, context)
response = tryton_client.call(method, params)
return response
def __need_update(self, customer, tryton_party):
if not customer.name == tryton_party.get('name'):
return True
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
if not customer.address_external_id == str(tryton_party.get('addresses')[0]):
return True
def __create_customer(self, tryton_party):
customer = Customer()
customer.name = tryton_party.get('name')
customer.external_id = tryton_party.get('id')
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
customer.address_external_id = tryton_party.get('addresses')[0]
customer.save()
return customer
def __update_customer(self, customer, tryton_party):
customer.name = tryton_party.get('name')
customer.external_id = tryton_party.get('id')
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
customer.address_external_id = tryton_party.get('addresses')[0]
customer.save()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-04-06 20:59
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0037_admincode'),
]
operations = [
migrations.AlterField(
model_name='saleline',
name='quantity',
field=models.DecimalField(decimal_places=2, max_digits=10, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-07-19 15:56
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0038_alter_saleline_quantity'),
]
operations = [
migrations.AddField(
model_name='sale',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-07-19 22:27
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0039_sale_external_id'),
]
operations = [
migrations.AddField(
model_name='customer',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-07-19 22:32
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0040_customer_external_id'),
]
operations = [
migrations.AddField(
model_name='product',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-07-19 23:15
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0041_product_external_id'),
]
operations = [
migrations.AddField(
model_name='product',
name='unit_external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 2025-07-20 00:04
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0042_product_unit_external_id'),
]
operations = [
migrations.AddField(
model_name='customer',
name='address_external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 5.0.6 on 2026-03-15 04:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0043_customer_address_external_id'),
]
operations = [
migrations.AlterField(
model_name='payment',
name='type_payment',
field=models.CharField(choices=[('CASH', 'Efectivo'), ('CONFIAR', 'Confiar'), ('BANCOLOMBIA', 'Bancolombia'), ('CREDIT', 'Crédito')], default='CASH', max_length=30),
),
migrations.AlterField(
model_name='sale',
name='payment_method',
field=models.CharField(choices=[('CASH', 'Efectivo'), ('CONFIAR', 'Confiar'), ('BANCOLOMBIA', 'Bancolombia'), ('CREDIT', 'Crédito')], default='CASH', max_length=30),
),
]

View File

@@ -7,23 +7,30 @@ from datetime import datetime
class PaymentMethods(models.TextChoices):
CASH = 'CASH', _('Efectivo')
CONFIAR = 'CONFIAR', _('Confiar')
BANCOLOMBIA = 'BANCOLOMBIA', _('Bancolombia')
CASH = "CASH", _("Efectivo")
CONFIAR = "CONFIAR", _("Confiar")
BANCOLOMBIA = "BANCOLOMBIA", _("Bancolombia")
CREDIT = "CREDIT", _("Crédito")
class Customer(models.Model):
name = models.CharField(max_length=100, default=None, null=False, blank=False)
name = models.CharField(
max_length=100, default=None, null=False, blank=False
)
address = models.CharField(max_length=100, null=True, blank=True)
email = models.CharField(max_length=100, null=True, blank=True)
phone = models.CharField(max_length=100, null=True, blank=True)
external_id = models.CharField(max_length=100, null=True, blank=True)
address_external_id = models.CharField(
max_length=100, null=True, blank=True
)
def __str__(self):
return self.name
class MeasuringUnits(models.TextChoices):
UNIT = 'UNIT', _('Unit')
UNIT = "UNIT", _("Unit")
class ProductCategory(models.Model):
@@ -39,9 +46,13 @@ class Product(models.Model):
measuring_unit = models.CharField(
max_length=20,
choices=MeasuringUnits.choices,
default=MeasuringUnits.UNIT
default=MeasuringUnits.UNIT,
)
unit_external_id = models.CharField(
max_length=100, null=True, blank=True
)
categories = models.ManyToManyField(ProductCategory)
external_id = models.CharField(max_length=100, null=True, blank=True)
def __str__(self):
return self.name
@@ -56,7 +67,8 @@ class Product(models.Model):
"name": product.name,
"price_list": product.price,
"uom": product.measuring_unit,
"categories": [c.name for c in product.categories.all()]
"external_id": product.external_id,
"categories": [c.name for c in product.categories.all()],
}
products_list.append(rproduct)
return products_list
@@ -69,7 +81,9 @@ class ReconciliationJar(models.Model):
reconcilier = models.CharField(max_length=255, null=False, blank=False)
cash_taken = models.DecimalField(max_digits=9, decimal_places=2)
cash_discrepancy = models.DecimalField(max_digits=9, decimal_places=2)
total_cash_purchases = models.DecimalField(max_digits=9, decimal_places=2)
total_cash_purchases = models.DecimalField(
max_digits=9, decimal_places=2
)
def clean(self):
self._validate_taken_ammount()
@@ -97,14 +111,15 @@ class Sale(models.Model):
choices=PaymentMethods.choices,
default=PaymentMethods.CASH,
blank=False,
null=False
null=False,
)
reconciliation = models.ForeignKey(
ReconciliationJar,
on_delete=models.RESTRICT,
related_name='Sales',
null=True
related_name="Sales",
null=True,
)
external_id = models.CharField(max_length=100, null=True, blank=True)
def __str__(self):
return f"{self.date} {self.customer}"
@@ -115,7 +130,9 @@ class Sale(models.Model):
def clean(self):
if self.payment_method not in PaymentMethods.values:
raise ValidationError({'payment_method': "Invalid payment method"})
raise ValidationError(
{"payment_method": "Invalid payment method"}
)
@classmethod
def sale_header_csv(cls):
@@ -127,8 +144,12 @@ class Sale(models.Model):
class SaleLine(models.Model):
sale = models.ForeignKey(Sale, on_delete=models.CASCADE)
product = models.ForeignKey(Product, null=False, blank=False, on_delete=models.CASCADE)
quantity = models.IntegerField(null=True)
product = models.ForeignKey(
Product, null=False, blank=False, on_delete=models.CASCADE
)
quantity = models.DecimalField(
max_digits=10, decimal_places=2, null=True
)
unit_price = models.DecimalField(max_digits=9, decimal_places=2)
description = models.CharField(max_length=255, null=True, blank=True)
@@ -136,7 +157,7 @@ class SaleLine(models.Model):
return f"{self.sale} - {self.product}"
class ReconciliationJarSummary():
class ReconciliationJarSummary:
def __init__(self, payments):
self._validate_payments(payments)
self._payments = payments
@@ -158,7 +179,7 @@ class Payment(models.Model):
type_payment = models.CharField(
max_length=30,
choices=PaymentMethods.choices,
default=PaymentMethods.CASH
default=PaymentMethods.CASH,
)
amount = models.DecimalField(max_digits=9, decimal_places=2)
reconciliation_jar = models.ForeignKey(
@@ -166,7 +187,7 @@ class Payment(models.Model):
null=True,
default=None,
blank=True,
on_delete=models.RESTRICT
on_delete=models.RESTRICT,
)
description = models.CharField(max_length=255, null=True, blank=True)
@@ -174,8 +195,7 @@ class Payment(models.Model):
def get_reconciliation_jar_summary(cls):
return ReconciliationJarSummary(
cls.objects.filter(
type_payment=PaymentMethods.CASH,
reconciliation_jar=None
type_payment=PaymentMethods.CASH, reconciliation_jar=None
)
)

View File

@@ -0,0 +1,6 @@
from rest_framework.permissions import BasePermission
class IsAdministrator(BasePermission):
def has_permission(self, request, view):
return request.user and request.user.is_staff

View File

@@ -15,19 +15,19 @@ class SaleSerializer(serializers.ModelSerializer):
class Meta:
model = Sale
fields = ['id', 'customer', 'date', 'saleline_set',
'total', 'payment_method']
'total', 'payment_method', 'external_id']
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'price', 'measuring_unit', 'categories']
fields = ['id', 'name', 'price', 'measuring_unit', 'categories', 'external_id']
class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ['id', 'name', 'address', 'email', 'phone']
fields = ['id', 'name', 'address', 'email', 'phone', 'external_id']
class ReconciliationJarSerializer(serializers.ModelSerializer):

View File

@@ -0,0 +1,19 @@
from django.contrib.auth.models import User
from rest_framework_simplejwt.tokens import RefreshToken
from rest_framework.test import APIClient
class LoginMixin:
def login(self):
self.user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='adminpass'
)
refresh = RefreshToken.for_user(self.user)
self.access_token = str(refresh.access_token)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f'Bearer {self.access_token}')

View File

@@ -1,20 +1,21 @@
from django.test import TestCase, Client
from django.test import TestCase
from ..models import AdminCode
from .Mixins import LoginMixin
import json
class TestAdminCode(TestCase):
class TestAdminCode(TestCase, LoginMixin):
def setUp(self):
self.login()
self.valid_code = 'some valid code'
admin_code = AdminCode()
admin_code.value = self.valid_code
admin_code.clean()
admin_code.save()
self.client = Client()
def test_validate_code(self):
url = '/don_confiao/api/admin_code/validate/' + self.valid_code
response = self.client.get(url)

View File

@@ -1,19 +1,25 @@
import json
from django.urls import reverse
import csv
import io
from rest_framework import status
from rest_framework.test import APITestCase
from ..models import Sale, Product, Customer
from .Mixins import LoginMixin
class TestAPI(APITestCase):
class TestAPI(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
measuring_unit='UNIT'
)
self.customer = Customer.objects.create(
name='Camilo'
name='Camilo',
external_id='18'
)
def test_create_sale(self):
@@ -22,6 +28,22 @@ class TestAPI(APITestCase):
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0]
self.assertEqual(
sale.customer.name,
self.customer.name,
)
self.assertEqual(
sale.id,
content['id']
)
self.assertIsNone(sale.external_id)
def test_create_sale_with_decimal(self):
response = self._create_sale_with_decimal()
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0]
self.assertEqual(
sale.customer.name,
self.customer.name
@@ -30,6 +52,10 @@ class TestAPI(APITestCase):
sale.id,
content['id']
)
self.assertEqual(
sale.get_total(),
16500.00
)
def test_get_products(self):
url = '/don_confiao/api/products/'
@@ -44,6 +70,75 @@ class TestAPI(APITestCase):
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.name, json_response[0]['name'])
self.assertEqual(
self.customer.external_id,
json_response[0]['external_id']
)
def test_get_sales(self):
url = '/don_confiao/api/sales/'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.id, json_response[0]['customer'])
self.assertEqual(
None,
json_response[0]['external_id']
)
def test_get_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('csv', json_response)
self.assertGreater(len(json_response['csv']), 0)
def test_csv_structure_in_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, status.HTTP_200_OK)
csv_reader = csv.reader(io.StringIO(json_response['csv']))
expected_header = [
"Tercero",
"Dirección de facturación",
"Dirección de envío",
"Descripción",
"Referencia",
"Fecha venta",
"Plazo de pago",
"Almacén",
"Moneda",
"Líneas/Producto",
"Líneas/Cantidad",
"Líneas/Precio unitario",
"Líneas/Unidad",
"Empresa",
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario"
]
self.assertEqual(next(csv_reader), expected_header)
expected_rows = [
[self.customer.name, self.customer.name, self.customer.name, "",
"", "2024-09-02", "Contado", "Almacén",
"Peso colombiano", self.product.name, "2.00", "3000.00", "Unidad",
"TIENDA LA ILUSIÓN", "Tienda La Ilusion", "La Ilusion", "True", ""
],
["", "", "", "", "", "", "", "", "", self.product.name, "3.00",
"5000.00", "Unidad", "", "", "", "", ""
],
]
rows = list(csv_reader)
self.assertEqual(rows, expected_rows)
def _create_sale(self):
url = '/don_confiao/api/sales/'
@@ -57,3 +152,24 @@ class TestAPI(APITestCase):
],
}
return self.client.post(url, data, format='json')
def _create_sale_with_decimal(self):
url = '/don_confiao/api/sales/'
data = {
'customer': self.customer.id,
'date': '2024-09-02',
'payment_method': 'CASH',
'saleline_set': [
{
'product': self.product.id,
'quantity': 0.5,
'unit_price': 3000
},
{
'product': self.product.id,
'quantity': 3,
'unit_price': 5000
}
],
}
return self.client.post(url, data, format='json')

View File

@@ -0,0 +1,71 @@
import json
from unittest.mock import patch
from django.test import TestCase
from ..models import Customer
from .Mixins import LoginMixin
class TestCustomersFromTryton(TestCase, LoginMixin):
def setUp(self):
self.login()
self.customer = Customer.objects.create(
name='Calos',
external_id=5
)
self.customer.save()
self.customer2 = Customer.objects.create(
name='Cristian',
external_id=6
)
self.customer2.save()
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
def test_create_import_customer(self, mock_connect, mock_call):
def fake_call(*args, **kwargs):
party_search = 'model.party.party.search'
search_args = [[], 0, 1000, [['name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (party_search, search_args)):
return [5, 6, 7, 8]
party_read = 'model.party.party.read'
read_args = ([5, 6, 7, 8], ['id', 'name', 'addresses'], {'company': 1})
if (args == (party_read, read_args)):
return [
{'id': 5, 'name': 'Carlos', 'addresses': [303]},
{'id': 6, 'name': 'Cristian', 'addresses': []},
{'id': 7, 'name': 'Ana', 'addresses': [302]},
{'id': 8, 'name': 'José', 'addresses': []},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_clientes_de_tryton'
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
expected_response = {
'checked_tryton_parties': [5, 6, 7, 8],
'created_customers': [3, 4],
'untouched_customers': [2],
'failed_parties': [],
'updated_customers': [1]
}
self.assertEqual(content, expected_response)
created_customer = Customer.objects.get(id=3)
self.assertEqual(created_customer.external_id, str(7))
self.assertEqual(created_customer.name, 'Ana')
self.assertEqual(created_customer.address_external_id, str(302))
updated_customer = Customer.objects.get(id=1)
self.assertEqual(updated_customer.external_id, str(5))
self.assertEqual(updated_customer.name, 'Carlos')
self.assertIn(updated_customer.address_external_id, str(303))

View File

@@ -0,0 +1,105 @@
import csv
import json
from unittest.mock import patch
from django.test import TestCase
from ..models import Sale, SaleLine, Product, Customer
from .Mixins import LoginMixin
class TestExportarVentasParaTryton(TestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
measuring_unit='UNIT',
unit_external_id=1,
external_id=1
)
self.customer = Customer.objects.create(
name='Camilo',
external_id=1,
address_external_id=307,
)
self.sale = Sale.objects.create(
customer=self.customer,
date='2024-09-02',
payment_method='CASH',
description='un comentario'
)
self.sale_line1 = SaleLine.objects.create(
product=self.product,
quantity=2,
unit_price=3000,
sale=self.sale
)
self.sale_line2 = SaleLine.objects.create(
product=self.product,
quantity=3,
unit_price=5000,
sale=self.sale
)
def test_exportar_ventas_para_tryton(self):
url = '/don_confiao/exportar_ventas_para_tryton'
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response['Content-Type'], 'text/csv')
csv_content = response.content.decode('utf-8')
expected_header = [
"Tercero",
"Dirección de facturación",
"Dirección de envío",
"Descripción",
"Referencia",
"Fecha venta",
"Plazo de pago",
"Almacén",
"Moneda",
"Líneas/Producto",
"Líneas/Cantidad",
"Líneas/Precio unitario",
"Líneas/Unidad",
"Empresa",
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario"
]
csv_reader = csv.reader(csv_content.splitlines())
self.assertEqual(next(csv_reader), expected_header)
expected_rows = [
["Camilo", "Camilo", "Camilo", "un comentario", "un comentario", "2024-09-02", "Contado", "Almacén", "Peso colombiano", "Panela", "2.00", "3000.00", "Unidad", "TIENDA LA ILUSIÓN", "Tienda La Ilusion", "La Ilusion", "True", "un comentario"],
["", "", "", "", "", "", "", "", "", "Panela", "3.00", "5000.00", "Unidad", "", "", "", "", ""],
]
csv_rows = list(csv_reader)
self.assertEqual(csv_rows[0], expected_rows[0])
self.assertEqual(csv_rows[1], expected_rows[1])
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
def test_send_sales_to_tryton(self, mock_connect, mock_call):
external_id = '23423'
url = '/don_confiao/api/enviar_ventas_a_tryton'
mock_connect.return_value = None
mock_call.return_value = [external_id]
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
expected_response = {
'successful': [self.sale.id],
'failed': [],
}
self.assertEqual(content, expected_response)
updated_sale = Sale.objects.get(id=self.sale.id)
self.assertEqual(updated_sale.external_id, external_id)
mock_connect.assert_called_once()
mock_call.assert_called_once()
mock_call.assert_called_with('model.sale.sale.create', [[{'company': 1, 'shipment_address': '307', 'invoice_address': '307', 'currency': 31, 'comment': 'un comentario', 'description': 'Metodo pago: CASH', 'party': '1', 'reference': 'don_confiao 1', 'sale_date': {'__class__': 'date', 'year': 2024, 'month': 9, 'day': 2}, 'lines': [['create', [{'product': '1', 'quantity': {'__class__': 'Decimal', 'decimal': '2.00'}, 'type': 'line', 'unit': '1', 'unit_price': {'__class__': 'Decimal', 'decimal': '3000.00'}}, {'product': '1', 'quantity': {'__class__': 'Decimal', 'decimal': '3.00'}, 'type': 'line', 'unit': '1', 'unit_price': {'__class__': 'Decimal', 'decimal': '5000.00'}}]]], 'self_pick_up': True}], {'company': 1, 'shops': [1]}])

View File

@@ -1,18 +1,19 @@
from django.test import TestCase, Client
from django.test import TestCase
from django.core.exceptions import ValidationError
from ..models import Sale, Product, SaleLine, Customer, ReconciliationJar
from .Mixins import LoginMixin
import json
class TestJarReconcliation(TestCase):
class TestJarReconcliation(TestCase, LoginMixin):
def setUp(self):
self.login()
customer = Customer()
customer.name = 'Alejo Mono'
customer.save()
self.client = Client()
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"
@@ -235,6 +236,47 @@ class TestJarReconcliation(TestCase):
[sale['payment_method'] for sale in content['Sales']]
)
def test_create_reconciliation_with_decimal_on_sale_lines(self):
customer = Customer()
customer.name = 'Consumidor final'
customer.save()
product = Product()
product.name = "Mantequilla natural gramos"
product.price = "57.50"
product.save()
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"
purchase.payment_method = 'CASH'
purchase.clean()
purchase.save()
line = SaleLine()
line.sale = purchase
line.product = product
line.quantity = "0.24"
line.unit_price = "57.50"
line.save()
url = '/don_confiao/reconciliate_jar'
total_purchases = 13.80
data = {
'date_time': '2024-12-02T21:07',
'reconcilier': 'carlos',
'total_cash_purchases': total_purchases,
'cash_taken': total_purchases,
'cash_discrepancy': 0,
'cash_purchases': [purchase.id],
}
response = self.client.post(
url, data=json.dumps(data).encode('utf-8'),
content_type='application/json'
)
self.assertEqual(response.status_code, 200)
def _create_simple_reconciliation(self):
reconciliation = ReconciliationJar()
reconciliation.date_time = "2024-07-30"

View File

@@ -14,6 +14,8 @@ class TestCustomer(TestCase):
customer.save()
self.assertIsInstance(customer, Customer)
self.assertIsNone(customer.external_id)
self.assertIsNone(customer.address_external_id)
def test_don_create_customer_without_name(self):
customer = Customer()

View File

@@ -1,10 +1,10 @@
from django.test import Client, TestCase
from django.test import TestCase
from .Mixins import LoginMixin
# from ..models import PaymentMethods
class TestPaymentMethods(TestCase):
class TestPaymentMethods(TestCase, LoginMixin):
def setUp(self):
self.client = Client()
self.login()
def test_keys_in_payment_methods_to_select(self):
response = self.client.get(
@@ -21,3 +21,4 @@ class TestPaymentMethods(TestCase):
self.assertIn('CASH', [method.get('value') for method in methods])
self.assertIn('CONFIAR', [method.get('value') for method in methods])
self.assertIn('BANCOLOMBIA', [method.get('value') for method in methods])
self.assertIn('CREDIT', [method.get('value') for method in methods])

View File

@@ -10,13 +10,20 @@ class TestProducts(TestCase):
def setUp(self):
self.client = Client()
def test_create_product(self):
product = Product()
product.name = "Un producto"
product.price = 1000
product.save()
self.assertIsInstance(product, Product)
self.assertIsNone(product.external_id)
self.assertIsNone(product.unit_external_id)
def test_import_products(self):
self._import_csv()
all_products = self._get_products()
self.assertEqual(
len(all_products),
3
)
self.assertEqual(len(all_products), 3)
def test_import_products_with_categories(self):
self._import_csv()
@@ -28,77 +35,74 @@ class TestProducts(TestCase):
categories_on_csv = ["Cafes", "Alimentos", "Aceites"]
categories = ProductCategory.objects.all()
self.assertCountEqual(
[c.name for c in categories],
categories_on_csv
[c.name for c in categories], categories_on_csv
)
def test_update_products(self):
self._import_csv()
first_products = self._get_products()
self._import_csv('example_products2.csv')
self._import_csv("example_products2.csv")
seconds_products = self._get_products()
self.assertEqual(len(first_products), len(seconds_products))
def test_preserve_id_on_import(self):
self._import_csv()
id_aceite = Product.objects.get(name='Aceite').id
self._import_csv('example_products2.csv')
id_post_updated = Product.objects.get(name='Aceite').id
id_aceite = Product.objects.get(name="Aceite").id
self._import_csv("example_products2.csv")
id_post_updated = Product.objects.get(name="Aceite").id
self.assertEqual(id_aceite, id_post_updated)
def test_update_categories_on_import(self):
self._import_csv()
first_products = self._get_products()
first_categories = {p["name"]: p["categories"] for p in first_products}
self._import_csv('example_products2.csv')
first_categories = {
p["name"]: p["categories"] for p in first_products
}
self._import_csv("example_products2.csv")
updated_products = self._get_products()
updated_categories = {
p["name"]: p["categories"] for p in updated_products}
p["name"]: p["categories"] for p in updated_products
}
self.assertIn('Cafes', first_categories['Arroz'])
self.assertNotIn('Granos', first_categories['Arroz'])
self.assertIn("Cafes", first_categories["Arroz"])
self.assertNotIn("Granos", first_categories["Arroz"])
self.assertIn('Granos', updated_categories['Arroz'])
self.assertNotIn('Cafes', updated_categories['Arroz'])
self.assertIn("Granos", updated_categories["Arroz"])
self.assertNotIn("Cafes", updated_categories["Arroz"])
def test_update_price(self):
self._import_csv()
first_products = self._get_products()
first_prices = {p["name"]: p["price_list"] for p in first_products}
expected_first_prices = {
"Aceite": '50000.00',
"Café": '14000.00',
"Arroz": '7000.00'
"Aceite": "50000.00",
"Café": "14000.00",
"Arroz": "7000.00",
}
self.assertDictEqual(
expected_first_prices,
first_prices
)
self.assertDictEqual(expected_first_prices, first_prices)
self._import_csv('example_products2.csv')
self._import_csv("example_products2.csv")
updated_products = self._get_products()
updated_prices = {p["name"]: p["price_list"] for p in updated_products}
updated_prices = {
p["name"]: p["price_list"] for p in updated_products
}
expected_updated_prices = {
"Aceite": '50000.00',
"Café": '15000.00',
"Arroz": '6000.00'
"Aceite": "50000.00",
"Café": "15000.00",
"Arroz": "6000.00",
}
self.assertDictEqual(
expected_updated_prices,
updated_prices
)
self.assertDictEqual(expected_updated_prices, updated_prices)
def _get_products(self):
products_response = self.client.get("/don_confiao/productos")
return json.loads(products_response.content.decode('utf-8'))
return json.loads(products_response.content.decode("utf-8"))
def _import_csv(self, csv_file='example_products.csv'):
app_name = "don_confiao"
def _import_csv(self, csv_file="example_products.csv"):
app_name = "don_confiao/tests/data_example"
app_dir = os.path.join(settings.BASE_DIR, app_name)
example_csv = os.path.join(app_dir, csv_file)
with open(example_csv, 'rb') as csv:
with open(example_csv, "rb") as csv:
self.client.post(
"/don_confiao/importar_productos",
{"csv_file": csv}
"/don_confiao/importar_productos", {"csv_file": csv}
)

View File

@@ -0,0 +1,131 @@
import json
from decimal import Decimal
from unittest.mock import patch
from django.test import TestCase
from ..models import Product
from .Mixins import LoginMixin
class TestProductsFromTryton(TestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
measuring_unit='UNIT',
unit_external_id=1,
external_id=191
)
self.product.save()
self.product2 = Product.objects.create(
name='Papa',
price=4500,
measuring_unit='Kilogram',
unit_external_id=2,
external_id=192
)
self.product2.save()
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
def test_create_import_products(self, mock_connect, mock_call):
mock_connect.return_value = None
def fake_call(*args, **kwargs):
product_search = 'model.product.product.search'
search_args = [[["salable", "=", True]], 0, 1000, [['rec_name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (product_search, search_args)):
return [190, 191, 192]
product_read = 'model.product.product.read'
product_args = ([190, 191, 192],
['id', 'name', 'default_uom.id',
'default_uom.rec_name', 'list_price'],
{'company': 1}
)
if (args == (product_read, product_args)):
return [
{'id': 190, 'list_price': Decimal('25000'),
'name': 'Producto 1',
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
{'id': 191, 'list_price': Decimal('6000'),
'name': 'Panela2',
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
{'id': 192, 'list_price': Decimal('4500'),
'name': 'Papa',
'default_uom.': {'id': 2, 'rec_name': 'Kilogram'}},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_productos_de_tryton'
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
expected_response = {
'checked_tryton_products': [190, 191, 192],
'created_products': [3],
'untouched_products': [2],
'failed_products': [],
'updated_products': [1]
}
self.assertEqual(content, expected_response)
created_product = Product.objects.get(id=3)
self.assertEqual(created_product.external_id, str(190))
self.assertEqual(created_product.name, 'Producto 1')
self.assertEqual(created_product.price, Decimal('25000'))
self.assertEqual(created_product.measuring_unit, 'Unit')
updated_product = Product.objects.get(id=1)
self.assertEqual(updated_product.external_id, str(191))
self.assertEqual(updated_product.name, 'Panela2')
self.assertEqual(updated_product.price, Decimal('6000'))
self.assertEqual(updated_product.measuring_unit, 'Unit')
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
def test_import_duplicated_name_products(self, mock_connect, mock_call):
mock_connect.return_value = None
def fake_call(*args, **kwargs):
product_search = 'model.product.product.search'
search_args = [[["salable", "=", True]], 0, 1000, [['rec_name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (product_search, search_args)):
return [200]
product_read = 'model.product.product.read'
product_args = ([200],
['id', 'name', 'default_uom.id',
'default_uom.rec_name', 'list_price'],
{'company': 1}
)
if (args == (product_read, product_args)):
return [
{'id': 200, 'list_price': Decimal('25000'),
'name': self.product.name,
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_productos_de_tryton'
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
expected_response = {
'checked_tryton_products': [200],
'created_products': [],
'untouched_products': [],
'failed_products': [200],
'updated_products': [],
}
self.assertEqual(content, expected_response)

View File

@@ -1,14 +1,16 @@
from django.test import TestCase, Client
from django.test import TestCase
from ..models import Sale, Product, SaleLine, Customer
from .Mixins import LoginMixin
class TestSummaryViewPurchase(TestCase):
class TestSummaryViewPurchase(TestCase, LoginMixin):
def setUp(self):
self.login()
customer = Customer()
customer.name = 'Alejo Mono'
customer.save()
self.client = Client()
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"

View File

@@ -20,15 +20,23 @@ urlpatterns = [
path("productos", views.products, name="products"),
path("lista_productos", views.ProductListView.as_view(), name='product_list'),
path("importar_productos", views.import_products, name="import_products"),
path('api/importar_productos_de_tryton',
api_views.ProductsFromTrytonView.as_view(),
name="products_from_tryton"),
path("importar_terceros", views.import_customers, name="import_customers"),
path('api/importar_clientes_de_tryton',
api_views.CustomersFromTrytonView.as_view(),
name="customers_from_tryton"),
path("exportar_ventas_para_tryton",
views.exportar_ventas_para_tryton,
name="exportar_ventas_para_tryton"),
path('api/enviar_ventas_a_tryton', api_views.SalesToTrytonView.as_view(), name="send_tryton"),
path("resumen_compra/<int:id>", views.purchase_summary, name="purchase_summary"),
path("resumen_compra_json/<int:id>", api_views.SaleSummary.as_view(), name="purchase_json_summary"),
path("payment_methods/all/select_format", api_views.PaymentMethodView.as_view(), name="payment_methods_to_select"),
path('purchases/for_reconciliation', api_views.SalesForReconciliationView.as_view(), name='sales_for_reconciliation'),
path('reconciliate_jar', api_views.ReconciliateJarView.as_view()),
path('api/admin_code/validate/<code>', api_views.AdminCodeValidateView.as_view()),
path('api/sales/for_tryton', api_views.SalesForTrytonView.as_view()),
path('api/', include(router.urls)),
]

View File

@@ -163,8 +163,7 @@ def handle_import_customers_file(csv_file):
}
)
def exportar_ventas_para_tryton(request):
def sales_to_tryton_csv(sales):
tryton_sales_header = [
"Tercero",
"Dirección de facturación",
@@ -186,14 +185,7 @@ def exportar_ventas_para_tryton(request):
"Comentario"
]
if request.method == "GET":
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = "attachment; filename=sales.csv"
writer = csv.writer(response)
writer.writerow(tryton_sales_header)
sales = Sale.objects.all()
csv_data = [tryton_sales_header]
for sale in sales:
sale_lines = SaleLine.objects.filter(sale=sale.id)
if not sale_lines:
@@ -202,14 +194,14 @@ def exportar_ventas_para_tryton(request):
first_sale_line = sale_lines[0]
customer_info = [sale.customer.name] * 3 + [sale.description] * 2
first_line = customer_info + [
sale.date,
sale.date.strftime('%Y-%m-%d'),
"Contado",
"Almacén",
"Peso colombiano",
first_sale_line.product.name,
first_sale_line.quantity,
"Unidad",
first_sale_line.unit_price,
"Unidad",
"TIENDA LA ILUSIÓN",
"Tienda La Ilusion",
"La Ilusion",
@@ -223,6 +215,19 @@ def exportar_ventas_para_tryton(request):
line.unit_price,
"Unidad"]+[""]*5)
for row in lines:
csv_data.append(row)
return csv_data
def exportar_ventas_para_tryton(request):
if request.method == "GET":
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = "attachment; filename=sales.csv"
sales = Sale.objects.all()
csv_data = sales_to_tryton_csv(sales)
writer = csv.writer(response)
for row in csv_data:
writer.writerow(row)
return response

View File

@@ -1,12 +1,13 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'tienda_ilusion.settings')
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings")
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
@@ -18,5 +19,5 @@ def main():
execute_from_command_line(sys.argv)
if __name__ == '__main__':
if __name__ == "__main__":
main()

View File

@@ -1,131 +0,0 @@
"""
Django settings for tienda_ilusion project.
Generated by 'django-admin startproject' using Django 5.0.6.
For more information on this file, see
https://docs.djangoproject.com/en/5.0/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/5.0/ref/settings/
"""
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
BASE_DIR = Path(__file__).resolve().parent.parent
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/5.0/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = 'django-insecure-zh6rinl@8y7g(cf781snisx2j%p^c#d&b2@@9cqe!v@4yv8x=v'
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = True
ALLOWED_HOSTS = ['localhost']
CORS_ALLOWED_ORIGINS = ['http://localhost:3000', 'http://localhost:7001']
# Application definition
INSTALLED_APPS = [
'don_confiao.apps.DonConfiaoConfig',
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'corsheaders',
# 'don_confiao'
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
ROOT_URLCONF = 'tienda_ilusion.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'tienda_ilusion.wsgi.application'
# Database
# https://docs.djangoproject.com/en/5.0/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
}
}
# Password validation
# https://docs.djangoproject.com/en/5.0/ref/settings/#auth-password-validators
AUTH_PASSWORD_VALIDATORS = [
{
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
},
{
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
},
]
# Internationalization
# https://docs.djangoproject.com/en/5.0/topics/i18n/
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/5.0/howto/static-files/
STATIC_URL = 'static/'
# Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
FIXTURE_DIRS = ['don_confiao/tests/Fixtures']

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,5 @@
from django.apps import AppConfig
class UsersConfig(AppConfig):
name = 'users'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,13 @@
from django.contrib.auth.models import User
from rest_framework import serializers
class UserSerializer(serializers.ModelSerializer):
role = serializers.SerializerMethodField()
class Meta:
model = User
fields = ('id', 'username', 'email', 'first_name', 'last_name', 'role')
def get_role(self, obj):
return 'administrator' if obj.is_staff else 'user'

View File

@@ -0,0 +1,98 @@
from django.test import TestCase
from django.urls import reverse
from django.contrib.auth.models import User
from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken
class MeEndpointTests(TestCase):
def setUp(self):
self.user = User.objects.create_superuser(
username='admin',
email='admin@example.com',
password='adminpass'
)
refresh = RefreshToken.for_user(self.user)
self.access_token = str(refresh.access_token)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f'Bearer {self.access_token}')
def test_me_endpoint_returns_correct_user_data(self):
"""
Verifica que GET /api/users/me/ devuelve los datos del usuario
autenticado.
"""
url = reverse('current-user')
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
expected_fields = {'id', 'username', 'email',
'first_name', 'last_name', 'role'}
self.assertTrue(expected_fields.issubset(response.json().keys()))
data = response.json()
self.assertEqual(data['username'], self.user.username)
self.assertEqual(data['email'], self.user.email)
self.assertEqual(data['first_name'], self.user.first_name)
self.assertEqual(data['last_name'], self.user.last_name)
self.assertEqual(data['role'], 'administrator')
def test_regular_user_role_is_user(self):
"""
Verifica que un usuario sin permisos de staff recibe role 'user'.
"""
regular_user = User.objects.create_user(
username='regular',
email='regular@example.com',
password='regularpass',
is_staff=False
)
refresh = RefreshToken.for_user(regular_user)
access_token = str(refresh.access_token)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
url = reverse('current-user')
response = client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['role'], 'user')
def test_staff_user_role_is_administrator(self):
"""
Verifica que un usuario con is_staff=True recibe role 'administrator'.
"""
staff_user = User.objects.create_user(
username='staff',
email='staff@example.com',
password='staffpass',
is_staff=True
)
refresh = RefreshToken.for_user(staff_user)
access_token = str(refresh.access_token)
client = APIClient()
client.credentials(HTTP_AUTHORIZATION=f'Bearer {access_token}')
url = reverse('current-user')
response = client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['role'], 'administrator')
def test_me_endpoint_requires_authentication(self):
"""
Sin token el endpoint debe devolver 401 Unauthorized.
"""
client_no_auth = APIClient()
url = reverse('current-user')
response = client_no_auth.get(url)
self.assertEqual(response.status_code, 401)

View File

@@ -0,0 +1,6 @@
from django.urls import path
from .views import CurrentUserView
urlpatterns = [
path('me/', CurrentUserView.as_view(), name='current-user'),
]

View File

@@ -0,0 +1,12 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from .serializers import UserSerializer
class CurrentUserView(APIView):
permission_classes = [IsAuthenticated]
def get(self, request):
serializer = UserSerializer(request.user)
return Response(serializer.data)