2 Commits

Author SHA1 Message Date
mono
079f4e3806 fix: copy application files directly into image root
- Copy tienda_ilusion contents to /app/ instead of subdirectory
- Remove volume mount from production docker-compose.yml
- Allows deployment without repository dependency

Refs #37
2026-03-30 20:45:45 -05:00
mono
6473a8348c feat: configure docker image deployment with Gitea Container Registry
- Replace local build with pre-built image from Gitea registry
- Add docker-compose.override.yml for local development with Dockerfile
- Add .dockerignore to exclude unnecessary files from build
- Update image reference to gitea.onecluster.org/oneteam/don_confiao_backend:latest

Refs #37
2026-03-30 20:34:32 -05:00
134 changed files with 2075 additions and 10836 deletions

17
.dockerignore Normal file
View File

@@ -0,0 +1,17 @@
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
*.db
*.sqlite3
.env
.venv
venv/
ENV/
.git/
*.egg-info/
dist/
build/
.coverage
htmlcov/

View File

@@ -1,26 +0,0 @@
# 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=recreo.onecluster.com.co
TRYTON_DATABASE=ilusion_staging
TRYTON_USERNAME=alejandro.ayala
TRYTON_PASSWORD=cl4v3alejo

View File

@@ -1,56 +0,0 @@
# 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

View File

@@ -1,51 +0,0 @@
# 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

4
.env_example Normal file
View File

@@ -0,0 +1,4 @@
TRYTON_HOST=localhost
TRYTON_DATABASE=tryton
TRYTON_USERNAME=admin
TRYTON_PASSWORD=admin

33
.gitignore vendored
View File

@@ -321,46 +321,15 @@ pyrightconfig.json
[Ll]ib
[Ll]ib64
[Ll]ocal
Scripts/
[Ss]cripts
pyvenv.cfg
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

414
AGENTS.md
View File

@@ -7,23 +7,19 @@ Backend Django con Django REST Framework
```
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
├── docker-compose.yml # Configuración Docker
├── django.Dockerfile # Dockerfile Django
├── .env # Variables de entorno
├── .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
└── 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
├── db.sqlite3 # Base de datos SQLite
├── don_confiao/ # App principal
│ ├── models.py # Modelos: Customer, Product, Sale, SaleLine, Payment, ReconciliationJar, AdminCode
│ ├── views.py
│ ├── api_views.py
│ ├── serializers.py
@@ -31,21 +27,16 @@ don_confiao_backend/
│ ├── admin.py
│ ├── urls.py
│ ├── export_csv.py
│ ├── tests/ # Tests
│ ├── tests/ # Tests
│ └── migrations/
├── users/ # App de usuarios
├── 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
└── tienda_ilusion/ # Configuración Django
├── settings.py
├── urls.py
├── wsgi.py
└── asgi.py
@@ -57,9 +48,6 @@ don_confiao_backend/
- 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)
@@ -85,73 +73,22 @@ don_confiao_backend/
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
docker-compose 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
docker-compose run --rm django python manage.py makemigrations
docker-compose run --rm django python manage.py migrate
# Servidor desarrollo
docker compose -f docker-compose.dev.yml up
docker-compose up
# Shell Django
docker compose -f docker-compose.dev.yml run --rm django python manage.py shell
docker-compose 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
docker-compose run --rm django python manage.py createsuperuser
```
Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto es `python manage.py` (no `python tienda_ilusion/manage.py`).
@@ -159,312 +96,13 @@ Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto
## Tests
- Framework: Django unittest
- Directorio: don_confiao/tests/
- Ejecutar: `docker compose -f docker-compose.dev.yml run --rm django python manage.py test`
## Workflow TDD (Semáforo Rojo/Verde)
Todo cambio o nueva funcionalidad debe seguir TDD. **Está prohibido asumir fallos de tests: todo fallo debe ser confirmado ejecutando los tests.**
### Regla de oro: confirmar antes de modificar
Antes de modificar tests (por cambios en comportamiento o por nuevas funcionalidades), **SIEMPRE ejecutar los tests primero** y confirmar que el fallo esperado realmente ocurre. Nunca asumir que un test falla: verificar el fallo con la ejecución real.
### Ciclo Rojo → Verde → Refactor
1. **ROJO (escribir el test y confirmar que falla)**:
- Escribir o modificar el test que describe el comportamiento esperado.
- Ejecutar los tests **antes de tocar el código de producción**.
- Confirmar que el test falla por el motivo esperado (assertion fallida por el comportamiento nuevo, no por error de setup o de importación).
- Copiar el output del fallo como evidencia del semáforo rojo.
2. **VERDE (implementar lo mínimo para que pase)**:
- Implementar el código de producción mínimo necesario para que el test pase.
- Volver a ejecutar los tests y confirmar que el semáforo queda en verde.
3. **REFACTOR (limpiar con los tests en verde)**:
- Refactorizar y limpiar el código sin cambiar comportamiento.
- Re-ejecutar los tests para confirmar que siguen en verde.
### Ejecutar tests
Siempre ejecutar dentro del contenedor de desarrollo:
```bash
# Test específico (durante el ciclo, preferible sobre el suite completo para ser rápido)
docker compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao.tests.<archivo>
# Suite completa (al finalizar, para confirmar que nada se rompió)
docker compose -f docker-compose.dev.yml run --rm django python manage.py test
```
### Confirmar el fallo, no asumirlo
- Si un test falla o se espera que falle, **ejecutarlo y mostrar el resultado** antes de cambiar cualquier código o test.
- Verificar que el fallo corresponde a la razón esperada (leer el traceback), no a un problema de entorno, migraciones, importaciones o fixtures.
- No modificar tests para "hacerlos pasar" si el fallo no fue previamente confirmado.
- Ejecutar: `docker-compose 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
- Migraciones: `docker-compose run --rm django python manage.py makemigrations && docker-compose run --rm django python manage.py migrate`
- Servidor desarrollo: `docker-compose up`
- Shell Django: `docker-compose run --rm django python manage.py shell`
- Superuser: `docker-compose run --rm django python manage.py createsuperuser`
## Integraciones
- **Tryton ERP**: Integración mediante sabatron-tryton-rpc-client para sincronización de clientes, productos y ventas

269
README.md
View File

@@ -1,269 +0,0 @@
# 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

@@ -1,361 +0,0 @@
# Refactorización a Domain-Driven Design - Resumen
## ✅ Refactorización Completada
**Fecha**: 29 de Mayo 2026
**Commit**: `47e87e42048e2394a6461f78d5c8e6a4386aa2f9`
**Tests**: 46 tests pasando ✓
---
## 📊 Estadísticas
- **Archivos creados**: 17
- **Archivos eliminados**: 2
- **Archivos modificados**: 1
- **Líneas agregadas**: +816
- **Líneas eliminadas**: -621
- **Balance neto**: +195 líneas (mejor organización, más documentación)
---
## 🏗️ Nueva Estructura
```
don_confiao/
├── models/ # ✅ Ya estaba organizado por dominio
│ ├── products.py
│ ├── customers.py
│ ├── sales.py
│ ├── payments.py
│ └── admin.py
├── serializers/ # 🆕 Nuevo - Organizado por dominio
│ ├── __init__.py
│ ├── products.py # ProductSerializer, ListProductSerializer
│ ├── customers.py # CustomerSerializer, ListCustomerSerializer
│ ├── sales.py # Sale, SaleLine, CatalogSale serializers
│ └── payments.py # ReconciliationJar, PaymentMethod serializers
├── api/ # 🆕 Nuevo - API Views por dominio
│ ├── __init__.py
│ ├── products.py # ProductView, ProductsFromTrytonView
│ ├── customers.py # CustomerView, CustomersFromTrytonView
│ ├── sales.py # SaleView, CatalogSaleView, Sales*TrytonView
│ ├── payments.py # ReconciliateJarView, PaymentMethodView
│ └── admin.py # AdminCodeValidateView
└── services/ # 🆕 Nuevo - Capa de servicios
└── tryton/
├── __init__.py
├── client.py # Factory + TrytonSale/LineSale
├── products.py # ProductTrytonService
├── customers.py # CustomerTrytonService
└── sales.py # SaleTrytonService
```
---
## 📦 Detalles por Dominio
### 1. Products (Productos)
**Serializers** (`serializers/products.py`):
- `ProductSerializer` - Serializer completo de producto
- `ListProductSerializer` - Versión simplificada para listados
**API Views** (`api/products.py`):
- `ProductView` - CRUD de productos con filtrado por `active` status
- `ProductsFromTrytonView` - Importación desde Tryton
**Services** (`services/tryton/products.py`):
- `ProductTrytonService` - Lógica de sincronización con Tryton
- `import_from_tryton()` - Importa productos
- `_create_product()` - Crea nuevo producto
- `_update_product()` - Actualiza producto existente
- `_need_update()` - Determina si necesita actualización
---
### 2. Customers (Clientes)
**Serializers** (`serializers/customers.py`):
- `CustomerSerializer` - Serializer completo de cliente
- `ListCustomerSerializer` - Versión simplificada para listados
**API Views** (`api/customers.py`):
- `CustomerView` - CRUD de clientes
- `CustomersFromTrytonView` - Importación desde Tryton
**Services** (`services/tryton/customers.py`):
- `CustomerTrytonService` - Lógica de sincronización con Tryton
- `import_from_tryton()` - Importa clientes
- `_create_customer()` - Crea nuevo cliente
- `_update_customer()` - Actualiza cliente existente
- `_need_update()` - Determina si necesita actualización
---
### 3. Sales (Ventas)
**Serializers** (`serializers/sales.py`):
- `SaleSerializer` - Serializer de venta
- `SaleLineSerializer` - Serializer de línea de venta
- `CatalogSaleSerializer` - Serializer de venta por catálogo
- `CatalogSaleLineSerializer` - Línea de venta por catálogo
- `SummarySaleLineSerializer` - Resumen de línea (con detalles)
- `SaleSummarySerializer` - Resumen completo de venta
- `SaleForRenconciliationSerializer` - Ventas para reconciliación
**API Views** (`api/sales.py`):
- `SaleView` - CRUD de ventas
- `CatalogSaleView` - CRUD de ventas por catálogo
- `SaleSummary` - Resumen de venta por ID
- `SalesForTrytonView` - Exportar ventas a CSV para Tryton
- `SalesToTrytonView` - Enviar ventas a Tryton
**Services** (`services/tryton/sales.py`):
- `SaleTrytonService` - Lógica de sincronización con Tryton
- `send_to_tryton()` - Envía ventas a Tryton
- `_to_tryton_params()` - Convierte venta a formato Tryton
---
### 4. Payments (Pagos y Reconciliación)
**Serializers** (`serializers/payments.py`):
- `ReconciliationJarSerializer` - Serializer de arqueo de caja
- `PaymentMethodSerializer` - Serializer de métodos de pago
**API Views** (`api/payments.py`):
- `ReconciliateJarView` - Vista de reconciliación (POST/GET)
- `ReconciliateJarModelView` - ViewSet para arqueos
- `PaymentMethodView` - Listado de métodos de pago
- `SalesForReconciliationView` - Ventas pendientes de reconciliación
- `Pagination` - Paginación personalizada
---
### 5. Admin
**API Views** (`api/admin.py`):
- `AdminCodeValidateView` - Validación de códigos de administrador
---
### 6. Services - Tryton Integration
**Client** (`services/tryton/client.py`):
- `get_tryton_client()` - Factory para crear cliente conectado
- `TrytonSale` - Representa venta para exportación
- `TrytonLineSale` - Representa línea de venta para exportación
- Constantes de configuración (HOST, DATABASE, CURRENCY, etc.)
**Características**:
- ✅ Configuración centralizada
- ✅ Factory pattern para cliente
- ✅ DTOs para transformación de datos
- ✅ Reutilizable desde cualquier vista
---
## 🔄 Migración de Código
### Archivos Eliminados
`serializers.py` (170 líneas) → ✅ `serializers/` (4 archivos)
`api_views.py` (526 líneas) → ✅ `api/` (5 archivos)
### Backwards Compatibility
Todas las importaciones existentes siguen funcionando gracias a `__init__.py`:
```python
# Antes y Después - Mismas importaciones funcionan
from don_confiao.serializers import ProductSerializer
from don_confiao.api import ProductView
```
---
## 🎯 Beneficios Obtenidos
### 1. Cohesión
- Cada módulo agrupa código relacionado a un solo dominio
- Fácil encontrar todo lo relacionado a un concepto (ej: productos)
### 2. Separación de Responsabilidades
- **Models**: Definición de datos
- **Serializers**: Transformación API ↔ Modelos
- **API Views**: Lógica de endpoints
- **Services**: Lógica de negocio (Tryton)
### 3. Mantenibilidad
- Archivos más pequeños y enfocados
- Menos de 150 líneas por archivo
- Más fácil de leer y entender
### 4. Escalabilidad
- Agregar nuevo dominio = crear nuevos archivos en cada capa
- No modifica código existente
- Open/Closed Principle
### 5. Testabilidad
- Tests pueden organizarse por dominio
- Servicios fáciles de mockear
- Cada componente testeable independientemente
### 6. Reutilización
- Servicios Tryton usables desde cualquier vista
- Serializers compartibles entre vistas
- DRY (Don't Repeat Yourself)
---
## 📝 Próximos Pasos Sugeridos
### 1. Organizar Tests por Dominio (Opcional)
```
tests/
├── __init__.py
├── products/
│ ├── test_products_api.py
│ ├── test_products_serializers.py
│ └── test_products_tryton.py
├── customers/
│ └── ...
└── sales/
└── ...
```
### 2. Documentación
Crear documentación por dominio:
```
docs/
├── products.md
├── customers.md
├── sales.md
└── tryton_integration.md
```
### 3. Mejorar Services Layer
Agregar más lógica de negocio a services:
- Validaciones complejas
- Cálculos de negocio
- Transformaciones de datos
### 4. Agregar Type Hints
Mejorar type hints en services para mejor IDE support:
```python
def import_from_tryton(self) -> dict[str, list[int]]:
"""Importa productos desde Tryton
Returns:
Dict con listas de IDs: checked, failed, updated, created, untouched
"""
```
---
## 🧪 Tests
### Estado Actual
**46 tests pasando**
### Cobertura por Dominio
- ✅ Products: 13 tests (incluye filtrado por active status)
- ✅ Sales: 8 tests
- ✅ Customers: Tests de integración Tryton
- ✅ Payments: Tests de reconciliación
- ✅ Admin: Tests de códigos
### Ejecución
```bash
# Todos los tests
docker compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao.tests
# Tests específicos
docker compose -f docker-compose.dev.yml run --rm django python manage.py test don_confiao.tests.test_products
```
---
## 📚 Ejemplos de Uso
### Importar Serializers
```python
# Forma explícita
from don_confiao.serializers.products import ProductSerializer
# Forma simplificada (recomendada)
from don_confiao.serializers import ProductSerializer
```
### Importar API Views
```python
# Forma explícita
from don_confiao.api.products import ProductView
# Forma simplificada (recomendada)
from don_confiao.api import ProductView
```
### Usar Services
```python
from don_confiao.services import get_tryton_client, ProductTrytonService
# En una vista
def my_view(request):
client = get_tryton_client()
service = ProductTrytonService(client)
result = service.import_from_tryton()
return Response(result)
```
---
## 🔍 Verificación de Calidad
### Checks Realizados
- ✅ Todos los tests pasan
- ✅ Sin imports circulares
- ✅ Todas las URLs funcionando
- ✅ Backwards compatibility mantenida
- ✅ Código limpio y documentado
### Métricas
- **Complejidad ciclomática**: Reducida (archivos más pequeños)
- **Acoplamiento**: Reducido (separación de capas)
- **Cohesión**: Incrementada (código por dominio)
---
## 🎓 Principios Aplicados
1. **Domain-Driven Design (DDD)**: Organización por dominios de negocio
2. **Single Responsibility Principle (SRP)**: Cada módulo una responsabilidad
3. **Open/Closed Principle**: Abierto a extensión, cerrado a modificación
4. **Dependency Inversion**: Dependencias hacia abstracciones (services)
5. **DRY**: Servicios reutilizables, no repetir lógica
6. **Separation of Concerns**: Capas bien definidas
---
## 📞 Soporte
Si tienes preguntas sobre la nueva estructura:
1. **Serializers**: Ver `serializers/__init__.py` para exportaciones
2. **API Views**: Ver `api/__init__.py` para exportaciones
3. **Services**: Ver `services/tryton/` para lógica Tryton
4. **URLs**: Ver `urls.py` para rutas disponibles
---
**¡Refactorización completada exitosamente! 🎉**
La estructura está lista para escalar y mantener de forma eficiente.

View File

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

View File

@@ -9,52 +9,50 @@ post /token/
{
"username": "admin",
"password": "admin"
"password": "123"
}
**** respuesta
#+begin_src json
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc4MDExNTc0NywiaWF0IjoxNzgwMDI5MzQ3LCJqdGkiOiIxNmVjZGMxZmY4Y2Y0MzA4ODM3ZjM5Y2ZiNjQwNmZiMCIsInVzZXJfaWQiOiIxIn0.wmN-wp3Izv0NrfL_ap_i8eyg29w-foHNrQCCL6HoZWg",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA"
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc"
}
#+end_src
*** Perfil de usuario
get /users/me/
Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDA2MzU4LCJpYXQiOjE3ODAwMDQ1NTgsImp0aSI6IjkwNzY4OGU2MmNlNTQ1M2JiYzU5MTA2MDhmMjY1MmY5IiwidXNlcl9pZCI6IjEifQ.aFls9WyA1VuDeMMUu8t7Pa9CbbLfyvIg9pB9xIxydpU
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc
**** Respuesta
#+begin_src json
{
"id": 1,
"id": 2,
"username": "admin",
"email": "admin@admin.org",
"email": "correo@example.com",
"first_name": "",
"last_name": "",
"role": "administrator"
"last_name": ""
}
#+end_src
*** Renovar token
post /token/refresh/
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3ODU1Njc5MywiaWF0IjoxNzc4NDcwMzkzLCJqdGkiOiJlMDU0NTVkNWExYzA0YjFkYWZhNWZkNzFkZGM5Mzc1NyIsInVzZXJfaWQiOiIxIn0.wZcbBrGoxDMPjZxI-GR1GTAuRtzU4qaT0rgGS5Oblf4"
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k"
}
**** response
#+begin_src json
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc4NDcyMjQ5LCJpYXQiOjE3Nzg0NzA0NDksImp0aSI6IjE5YTM0ZDQ5Mzk3ZDQzNGE4NDlkZTgyYzdkNWQyNjQ0IiwidXNlcl9pZCI6IjEifQ.jowmaa5SXKIWpmUGLV0dj9CydYFtuecc7s_RveJvjLA"
"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.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA
*** todas las rutas
get
**** response
#+begin_src json
{
"sales": "http://localhost:7000/don_confiao/api/sales/",
"catalog_sales": "http://localhost:7000/don_confiao/api/catalog_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/"
@@ -76,64 +74,3 @@ get customers/
...
]
#+end_src
*** products
get products/
*** Productos Inactivos
get products/?active=false
*** Productos Activos
get products/?active=true
*** Traer todos los productos
get products/?active=all
*** Inactiva productos
patch products/1
{
"active": false
}
*** Obtener Ventas por catalogo
get catalog_sales/
**** response
#+begin_src json
{
"id": 6,
"customer": 1,
"date": "2024-09-02T00:00:00Z",
"catalogsaleline_set": [
{
"id": 2,
"catalog_sale": 6,
"product": 1,
"unit_price": "3000.00",
"quantity": "2.00"
},
{
"id": 3,
"catalog_sale": 6,
"product": 1,
"unit_price": "5000.00",
"quantity": "3.00"
}
],
"total": 21000.0
}
*** Crear una venta por catalogo
post catalog_sales/
{"customer": "1", "date": "2024-09-02", "catalogsaleline_set": [{"product": "1", "quantity": "2", "unit_price": "3000"}, {"product": "1", "quantity": "3", "unit_price": "5000"}]}
*** Importar Clientes de Tryton
post importar_productos_de_tryton
{}
**** response
#+begin_src json
[]
#+end_src

View File

@@ -1,21 +0,0 @@
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

View File

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

View File

@@ -1,87 +0,0 @@
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:
- "127.0.0.1:9000: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
ports:
- "10297:80"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
- static_volume:/var/www/static
- media_volume:/var/www/media
- ./dist:/var/www/donconfiao
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

@@ -1,64 +0,0 @@
services:
postgres:
image: postgres:15-alpine
container_name: tienda_ilusion_postgres_staging
env_file:
- .env.staging
volumes:
- postgres_staging_data:/var/lib/postgresql/data
ports:
- "5433:5432"
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
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

8
docker-compose.yml Normal file
View File

@@ -0,0 +1,8 @@
services:
django:
image: gitea.onecluster.org/oneteam/don_confiao_backend:latest
env_file:
- .env
ports:
- "7000:9090"

View File

@@ -1,36 +0,0 @@
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
server {
listen 80;
server_name _;
client_max_body_size 10M;
location /media/ {
alias /var/www/media/;
expires 30d;
add_header Cache-Control "public, immutable";
}
location /static/ {
alias /var/www/static/;
expires 365d;
add_header Cache-Control "public, immutable";
}
location / {
root /var/www/donconfiao/;
try_files $uri /index.html;
expires -1;
add_header Cache-Control "no-cache";
}
}
}

412
poetry.lock generated
View File

@@ -1,412 +0,0 @@
# 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 = "pillow"
version = "12.2.0"
description = "Python Imaging Library (fork)"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f"},
{file = "pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff"},
{file = "pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136"},
{file = "pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3"},
{file = "pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa"},
{file = "pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032"},
{file = "pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5"},
{file = "pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024"},
{file = "pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab"},
{file = "pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7"},
{file = "pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705"},
{file = "pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b"},
{file = "pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909"},
{file = "pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808"},
{file = "pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60"},
{file = "pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe"},
{file = "pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5"},
{file = "pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987"},
{file = "pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005"},
{file = "pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5"},
{file = "pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5"},
{file = "pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940"},
{file = "pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5"},
{file = "pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2"},
{file = "pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c"},
{file = "pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795"},
{file = "pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed"},
{file = "pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9"},
{file = "pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795"},
{file = "pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e"},
{file = "pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b"},
{file = "pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b"},
{file = "pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea"},
{file = "pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4"},
{file = "pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea"},
{file = "pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24"},
{file = "pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98"},
{file = "pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b"},
{file = "pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295"},
{file = "pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed"},
{file = "pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601"},
{file = "pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f"},
{file = "pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50"},
{file = "pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104"},
{file = "pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7"},
{file = "pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150"},
{file = "pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463"},
{file = "pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd"},
{file = "pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06"},
{file = "pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43"},
{file = "pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354"},
{file = "pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1"},
{file = "pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1"},
{file = "pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e"},
{file = "pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5"},
]
[package.extras]
docs = ["furo", "olefile", "sphinx (>=8.2)", "sphinx-autobuild", "sphinx-copybutton", "sphinx-inline-tabs", "sphinxext-opengraph"]
fpx = ["olefile"]
mic = ["olefile"]
test-arrow = ["arro3-compute", "arro3-core", "nanoarrow", "pyarrow"]
tests = ["check-manifest", "coverage (>=7.4.2)", "defusedxml", "markdown2", "olefile", "packaging", "pyroma (>=5)", "pytest", "pytest-cov", "pytest-timeout", "pytest-xdist", "trove-classifiers (>=2024.10.12)"]
xmp = ["defusedxml"]
[[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"},
]
[[package]]
name = "whitenoise"
version = "6.12.0"
description = "Radically simplified static file serving for WSGI applications"
optional = false
python-versions = ">=3.10"
groups = ["main"]
files = [
{file = "whitenoise-6.12.0-py3-none-any.whl", hash = "sha256:fc5e8c572e33ebf24795b47b6a7da8da3c00cff2349f5b04c02f28d0cc5a3cc2"},
{file = "whitenoise-6.12.0.tar.gz", hash = "sha256:f723ebb76a112e98816ff80fcea0a6c9b8ecde835f8ddda25df7a30a3c2db6ad"},
]
[package.extras]
brotli = ["brotli"]
[metadata]
lock-version = "2.1"
python-versions = ">=3.14,<4.0"
content-hash = "ac185d7c04ec39d561c6cc78d022accbdc80e2f815f39c71aa5f38c6502f70b2"

View File

@@ -1,52 +0,0 @@
[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)",
"pillow (>=12.2.0,<13.0.0)",
"whitenoise (>=6.12.0,<7.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

@@ -3,16 +3,3 @@ 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
Pillow # Image processing for catalogue image resizing

File diff suppressed because it is too large Load Diff

View File

@@ -1,80 +0,0 @@
#!/usr/bin/env python3
import argparse
import getpass
import sys
import requests
TOKEN_URL = "/api/token/"
FILL_SALES_CODES_URL = "/don_confiao/api/sales/fill_codes/"
FILL_CATALOG_SALES_CODES_URL = "/don_confiao/api/catalog_sales/fill_codes/"
def get_credentials():
username = input("Usuario: ")
password = getpass.getpass("Contraseña: ")
return username, password
def get_token(domain, username, password):
url = domain.rstrip("/") + TOKEN_URL
response = requests.post(
url, json={"username": username, "password": password}
)
if response.status_code != 200:
print(
f"Error al obtener token: {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()["access"]
def fill_codes(domain, token, url):
headers = {"Authorization": f"Bearer {token}"}
full_url = domain.rstrip("/") + url
response = requests.post(full_url, headers=headers)
if response.status_code != 200:
print(
f"Error al llenar códigos ({url}): {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()
def main():
parser = argparse.ArgumentParser(
description="Asigna un código único a todas las ventas y pedidos que lo requieran."
)
parser.add_argument(
"domain", help="Dominio del backend (ej: http://localhost:8000)"
)
args = parser.parse_args()
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
print("Asignando códigos a ventas...")
sales_result = fill_codes(args.domain, token, FILL_SALES_CODES_URL)
print(f" [OK] Ventas actualizadas: {sales_result['sales_updated']}")
print("Asignando códigos a pedidos de catálogo...")
catalog_result = fill_codes(
args.domain, token, FILL_CATALOG_SALES_CODES_URL
)
print(
f" [OK] Pedidos de catálogo actualizados: "
f"{catalog_result['catalog_sales_updated']}"
)
print(
f"\nResumen: {sales_result['sales_updated']} ventas, "
f"{catalog_result['catalog_sales_updated']} pedidos de catálogo actualizados"
)
if __name__ == "__main__":
main()

View File

@@ -1,99 +0,0 @@
#!/usr/bin/env python3
import argparse
import getpass
import sys
import requests
TOKEN_URL = "/api/token/"
CATALOGUE_IMAGES_URL = "/don_confiao/api/catalogue_images/"
def get_credentials():
username = input("Usuario: ")
password = getpass.getpass("Contraseña: ")
return username, password
def get_token(domain, username, password):
url = domain.rstrip("/") + TOKEN_URL
response = requests.post(url, json={"username": username, "password": password})
if response.status_code != 200:
print(f"Error al obtener token: {response.status_code} {response.text}", file=sys.stderr)
sys.exit(1)
data = response.json()
return data["access"]
def get_catalogue_images(domain, token):
url = domain.rstrip("/") + CATALOGUE_IMAGES_URL
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
print(f"Error al obtener imágenes de catálogo: {response.status_code} {response.text}", file=sys.stderr)
sys.exit(1)
return response.json()
def download_image(image_url):
response = requests.get(image_url)
if response.status_code != 200:
print(f" [ERR] No se pudo descargar imagen: {response.status_code}", file=sys.stderr)
return None
return response.content
def main():
parser = argparse.ArgumentParser(
description="Vuelve a subir cada imagen de catálogo existente para forzar el redimensionado."
)
parser.add_argument("domain", help="Dominio del backend (ej: http://localhost:8000)")
args = parser.parse_args()
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
images = get_catalogue_images(args.domain, token)
if not images:
print("No hay imágenes de catálogo para procesar.")
return
headers = {"Authorization": f"Bearer {token}"}
updated = 0
errors = 0
for img in images:
ci_id = img["id"]
product_id = img["product"]
image_url = img["image"]
print(f" [{ci_id}] Descargando {image_url} ...")
content = download_image(image_url)
if content is None:
errors += 1
continue
# Determine content type from URL extension
ext = image_url.rsplit(".", 1)[-1].lower() if "." in image_url else "jpg"
mime = {"jpg": "image/jpeg", "jpeg": "image/jpeg", "png": "image/png"}.get(ext, "application/octet-stream")
filename = f"image.{ext}"
update_url = args.domain.rstrip("/") + CATALOGUE_IMAGES_URL.rstrip("/") + f"/{ci_id}/"
files = {"image": (filename, content, mime)}
data = {"product": product_id}
response = requests.put(update_url, headers=headers, files=files, data=data)
if response.status_code == 200:
print(f" [OK] Imagen {ci_id} (producto {product_id}) actualizada.")
updated += 1
else:
print(f" [ERR] Imagen {ci_id}: {response.status_code} {response.text}")
errors += 1
print(f"\nResumen: {updated} actualizadas, {errors} errores")
if __name__ == "__main__":
main()

View File

@@ -1,188 +0,0 @@
#!/usr/bin/env python3
"""Seed geografía de Colombia (países, departamentos y municipios).
Requisitos (instalar ad-hoc, NO son dependencia del proyecto):
pip install pycountry requests
Uso:
python scripts/seed_geography.py http://localhost:7000
El script obtiene el país y los departamentos de Colombia desde
`pycountry` (ISO 3166-1/3166-2) y los municipios desde el dataset DANE
incluido en `scripts/data/colombia_municipios.csv`. No consulta internet
en tiempo de ejecución.
"""
import argparse
import csv
import getpass
import os
import sys
import unicodedata
import requests
try:
import pycountry
except ModuleNotFoundError:
print(
"pycountry no está instalado. Ejecuta: pip install pycountry",
file=sys.stderr,
)
sys.exit(1)
TOKEN_URL = "/api/token/"
SEED_GEOGRAPHY_URL = "/don_confiao/api/seed_geography"
MUNICIPALITIES_CSV = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"data",
"colombia_municipios.csv",
)
# Nombres DANE que difieren de los nombres oficiales de pycountry.
DEPARTMENT_ALIASES = {
"BOGOTA": "Distrito Capital de Bogotá",
"SAN ANDRES": "San Andrés, Providencia y Santa Catalina",
}
def get_credentials():
username = input("Usuario: ")
password = getpass.getpass("Contraseña: ")
return username, password
def get_token(domain, username, password):
url = domain.rstrip("/") + TOKEN_URL
response = requests.post(
url, json={"username": username, "password": password}
)
if response.status_code != 200:
print(
f"Error al obtener token: {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()["access"]
def _normalize(name):
return "".join(
char
for char in unicodedata.normalize("NFD", name)
if unicodedata.category(char) != "Mn"
).upper()
def get_department_mapping(subdivisions):
by_normalized = {
_normalize(subdivision.name): subdivision.name
for subdivision in subdivisions
}
csv_department_names = set()
with open(MUNICIPALITIES_CSV, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
csv_department_names.add(row["department"])
mapping = dict(DEPARTMENT_ALIASES)
for name in csv_department_names:
if name in mapping:
continue
mapped = by_normalized.get(_normalize(name))
if mapped is None:
print(
f"Departamento '{name}' no encontrado en pycountry",
file=sys.stderr,
)
sys.exit(1)
mapping[name] = mapped
return mapping
def read_municipalities(mapping):
municipalities = []
with open(MUNICIPALITIES_CSV, newline="", encoding="utf-8") as f:
for row in csv.DictReader(f):
municipality = {
"name": row["municipality"],
"department": mapping[row["department"]],
}
if row.get("latitude"):
municipality["latitude"] = float(row["latitude"])
if row.get("longitude"):
municipality["longitude"] = float(row["longitude"])
municipalities.append(municipality)
return municipalities
def build_payload():
country = pycountry.countries.get(alpha_2="CO")
subdivisions = pycountry.subdivisions.get(country_code="CO")
departments = [
{"name": subdivision.name, "code": subdivision.code}
for subdivision in subdivisions
]
mapping = get_department_mapping(subdivisions)
municipalities = read_municipalities(mapping)
return {
"country": {"name": country.name, "code": country.alpha_2},
"departments": departments,
"municipalities": municipalities,
}
def seed_geography(domain, token, payload):
headers = {"Authorization": f"Bearer {token}"}
full_url = domain.rstrip("/") + SEED_GEOGRAPHY_URL
response = requests.post(full_url, headers=headers, json=payload)
if response.status_code != 200:
print(
f"Error al sembrar geografía: {response.status_code} {response.text}",
file=sys.stderr,
)
sys.exit(1)
return response.json()
def main():
parser = argparse.ArgumentParser(
description=(
"Alimenta países, departamentos y municipios de Colombia "
"usando pycountry y el dataset DANE incluido en scripts/data/."
)
)
parser.add_argument(
"domain", help="Dominio del backend (ej: http://localhost:7000)"
)
args = parser.parse_args()
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
payload = build_payload()
print(
"Preparando siembra: 1 país, "
f"{len(payload['departments'])} departamentos, "
f"{len(payload['municipalities'])} municipios."
)
print("Enviando geografía...")
result = seed_geography(args.domain, token, payload)
print(
f" [OK] {result['country']}: "
f"{result['total_departments']} departamentos, "
f"{result['total_municipalities']} municipios."
)
print(
f"\nResumen: {result['departments_created']} departamentos y "
f"{result['municipalities_created']} municipios creados "
f"(re-ejecutar es idempotente)."
)
if __name__ == "__main__":
main()

View File

@@ -1,126 +0,0 @@
#!/usr/bin/env python3
import argparse
import getpass
import os
import re
import sys
from pathlib import Path
import requests
TOKEN_URL = "/api/token/"
PRODUCTS_URL = "/don_confiao/api/products/"
CATALOGUE_IMAGES_URL = "/don_confiao/api/catalogue_images/"
def get_credentials():
username = input("Usuario: ")
password = getpass.getpass("Contraseña: ")
return username, password
def get_token(domain, username, password):
url = domain.rstrip("/") + TOKEN_URL
response = requests.post(url, json={"username": username, "password": password})
if response.status_code != 200:
print(f"Error al obtener token: {response.status_code} {response.text}", file=sys.stderr)
sys.exit(1)
data = response.json()
return data["access"]
def get_products(domain, token):
url = domain.rstrip("/") + PRODUCTS_URL
headers = {"Authorization": f"Bearer {token}"}
response = requests.get(url, headers=headers)
if response.status_code != 200:
print(f"Error al obtener productos: {response.status_code} {response.text}", file=sys.stderr)
sys.exit(1)
return response.json()
MIME_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
}
def find_images(image_dir):
images = {}
pattern = re.compile(r"^(\d+)\.(jpg|jpeg|png)$", re.IGNORECASE)
for f in os.listdir(image_dir):
m = pattern.match(f)
if m:
external_id = m.group(1)
images[external_id] = os.path.join(image_dir, f)
return images
def main():
parser = argparse.ArgumentParser(
description="Sube imágenes de catálogo para productos usando el external_id como nombre de archivo."
)
parser.add_argument("image_dir", help="Directorio con imágenes nombradas como ##.jpg")
parser.add_argument("domain", help="Dominio del backend (ej: http://localhost:8000)")
args = parser.parse_args()
if not os.path.isdir(args.image_dir):
print(f"Error: el directorio '{args.image_dir}' no existe.", file=sys.stderr)
sys.exit(1)
username, password = get_credentials()
token = get_token(args.domain, username, password)
print("Token obtenido correctamente.")
products = get_products(args.domain, token)
ext_id_to_product_id = {}
for p in products:
if p.get("external_id"):
ext_id_to_product_id[p["external_id"]] = p["id"]
if not ext_id_to_product_id:
print("No se encontraron productos con external_id.", file=sys.stderr)
sys.exit(1)
images = find_images(args.image_dir)
if not images:
print(f"No se encontraron imágenes con el patrón ##.jpg en '{args.image_dir}'.", file=sys.stderr)
sys.exit(1)
headers = {"Authorization": f"Bearer {token}"}
upload_url = args.domain.rstrip("/") + CATALOGUE_IMAGES_URL
uploaded = 0
skipped = 0
errors = 0
for external_id, img_path in sorted(images.items()):
if external_id not in ext_id_to_product_id:
print(f" [SKIP] {Path(img_path).name}: no hay producto con external_id={external_id}")
skipped += 1
continue
product_id = ext_id_to_product_id[external_id]
filename = Path(img_path).name
ext = Path(img_path).suffix.lower()
mime = MIME_TYPES.get(ext, "application/octet-stream")
with open(img_path, "rb") as f:
files = {"image": (filename, f, mime)}
data = {"product": product_id}
response = requests.post(upload_url, headers=headers, files=files, data=data)
if response.status_code == 201:
print(f" [OK] {filename} -> producto {product_id} (id imagen: {response.json()['id']})")
uploaded += 1
else:
print(f" [ERR] {filename} -> producto {product_id}: {response.status_code} {response.text}")
errors += 1
print(f"\nResumen: {uploaded} subidas, {skipped} saltadas, {errors} errores")
if __name__ == "__main__":
main()

View File

@@ -1,43 +0,0 @@
"""
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

@@ -1,193 +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/
"""
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"
# Media files
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# Catalogue image settings
CATALOGUE_IMAGE_WIDTH = int(os.environ.get("CATALOGUE_IMAGE_WIDTH", "600"))
CATALOGUE_IMAGE_HEIGHT = int(os.environ.get("CATALOGUE_IMAGE_HEIGHT", "600"))
CATALOGUE_STRICT_DIMENSION = os.environ.get("CATALOGUE_STRICT_DIMENSION", "False").lower() in ("true", "1", "yes")
CATALOGUE_BACKGROUND_IMAGES_RGBA = os.environ.get(
"CATALOGUE_BACKGROUND_IMAGES_RGBA", "0,0,0,0"
)
# Parse RGBA string to tuple
_rgba_parts = CATALOGUE_BACKGROUND_IMAGES_RGBA.split(",")
CATALOGUE_BACKGROUND_IMAGES_RGBA = tuple(int(p.strip()) for p in _rgba_parts)
CATALOGUE_MAX_UPLOAD_SIZE = int(os.environ.get("CATALOGUE_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024)))
LOGO_MAX_UPLOAD_SIZE = int(os.environ.get("LOGO_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024)))
# 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.AllowAny",
],
}
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

@@ -1,119 +0,0 @@
"""
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/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# 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

@@ -1,192 +0,0 @@
"""
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

@@ -1,213 +0,0 @@
"""
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 = True
# 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("POSTGRES_DB", "tienda_ilusion_staging"),
"USER": os.environ.get("POSTGRES_USER", "tienda_ilusion_user"),
"PASSWORD": os.environ.get(
"POSTGRES_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": "DEBUG",
},
"loggers": {
"django": {
"handlers": ["console", "file"],
"level": "INFO",
"propagate": False,
},
"django.security": {
"handlers": ["console", "file"],
"level": "WARNING",
"propagate": False,
},
"django.db.backends": {
"handlers": ["console"],
"level": "DEBUG", # 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

@@ -1,82 +1,12 @@
from django.contrib import admin
from .models.sales import Sale, SaleLine
from .models.customers import Customer
from .models.sales import (
Sale,
SaleLine,
CatalogSale,
CatalogSaleLine,
Payment,
)
from .models.products import Product, ProductCategory
from .models.payments import ReconciliationJar
from .models.store_settings import StoreSettings
@admin.register(StoreSettings)
class StoreSettingsAdmin(admin.ModelAdmin):
list_display = ("id", "address", "latitude", "longitude", "updated_at")
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = (
"id",
"name",
"email",
"phone",
"external_id",
"address_external_id",
)
search_fields = ("name", "email", "phone")
@admin.register(Sale)
class SaleAdmin(admin.ModelAdmin):
list_display = (
"id",
"date",
"customer",
"payment_method",
"external_id",
"code",
)
search_fields = ("date", "customer__name", "payment_method", "external_id", "code")
list_filter = ("date", "customer__name", "payment_method")
@admin.register(SaleLine)
class SaleLineAdmin(admin.ModelAdmin):
list_display = ("sale", "product", "quantity", "unit_price")
search_fields = ("sale__id", "product__name")
list_filter = ("sale__date", "product__name")
@admin.register(CatalogSale)
class CatalogSaleAdmin(admin.ModelAdmin):
list_display = (
"id",
"date",
"customer_name",
"customer_phone",
"customer_address",
"pickup_method",
"external_id",
"code",
)
search_fields = ("date","customer_name", "customer_phone", "pickup_method","external_id", "code")
list_filter = ("date", "customer_name", "pickup_method")
@admin.register(CatalogSaleLine)
class CatalogSaleLineAdmin(admin.ModelAdmin):
list_display = ("catalog_sale", "product", "quantity", "unit_price")
search_fields = ("catalog_sale__id", "product__name")
list_filter = ("catalog_sale__date", "product__name")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("id", "name", "price", "measuring_unit", "external_id")
search_fields = ("id", "name",)
list_filter = ("name", "id")
from .models import (
Customer, Sale, SaleLine, Product, ProductCategory, Payment,
ReconciliationJar)
admin.site.register(Customer)
admin.site.register(Sale)
admin.site.register(SaleLine)
admin.site.register(Product)
admin.site.register(ProductCategory)
admin.site.register(Payment)
admin.site.register(ReconciliationJar)

View File

@@ -1,67 +0,0 @@
from .catalogue_images import CatalogueImageViewSet
from .products import ProductView, ProductsFromTrytonView
from .customers import CustomerView, CustomersFromTrytonView
from .sales import (
SaleView,
CatalogSaleView,
SaleSummary,
CatalogSaleSummary,
PublicSaleSummaryView,
SalesForTrytonView,
SalesToTrytonView,
CatalogSalesToTrytonView,
)
from .payments import (
ReconciliateJarView,
ReconciliateJarModelView,
PaymentMethodView,
SalesForReconciliationView,
Pagination,
)
from .admin import AdminCodeValidateView
from .store_settings import StoreSettingsView
from .provenance import (
OrganizationView,
SupplierView,
CountryView,
DepartmentView,
MunicipalityView,
SeedGeographyView,
)
__all__ = [
# Catalogue Images
"CatalogueImageViewSet",
# Products
"ProductView",
"ProductsFromTrytonView",
# Customers
"CustomerView",
"CustomersFromTrytonView",
# Sales
"SaleView",
"CatalogSaleView",
"SaleSummary",
"CatalogSaleSummary",
"PublicSaleSummaryView",
"SalesForTrytonView",
"SalesToTrytonView",
"CatalogSalesToTrytonView",
# Payments
"ReconciliateJarView",
"ReconciliateJarModelView",
"PaymentMethodView",
"SalesForReconciliationView",
"Pagination",
# Admin
"AdminCodeValidateView",
# Store Settings
"StoreSettingsView",
# Provenance
"OrganizationView",
"SupplierView",
"CountryView",
"DepartmentView",
"MunicipalityView",
"SeedGeographyView",
]

View File

@@ -1,14 +0,0 @@
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from ..models.admin import AdminCode
from ..permissions import IsAdministrator
class AdminCodeValidateView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request, code):
codes = AdminCode.objects.filter(value=code)
return Response({"validCode": bool(codes)})

View File

@@ -1,25 +0,0 @@
from rest_framework import viewsets
from rest_framework.permissions import AllowAny, IsAuthenticated
from ..image_service import resize_catalogue_image
from ..models.catalogue_images import CatalogueImage
from ..permissions import IsAdministrator
from ..serializers.catalogue_images import CatalogueImageSerializer
class CatalogueImageViewSet(viewsets.ModelViewSet):
queryset = CatalogueImage.objects.all()
serializer_class = CatalogueImageSerializer
def get_permissions(self):
if self.action in ("create", "update", "partial_update", "destroy"):
return [IsAuthenticated(), IsAdministrator()]
return [AllowAny()]
def perform_create(self, serializer):
instance = serializer.save()
resize_catalogue_image(instance.image)
def perform_update(self, serializer):
instance = serializer.save()
resize_catalogue_image(instance.image)

View File

@@ -1,26 +0,0 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from ..models.customers import Customer
from ..serializers import CustomerSerializer
from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.customers import CustomerTrytonService
from ..services.tryton.client import get_tryton_client
class CustomerView(viewsets.ModelViewSet):
queryset = Customer.objects.all()
serializer_class = CustomerSerializer
permission_classes = [IsNotPublico]
class CustomersFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = get_tryton_client()
service = CustomerTrytonService(tryton_client)
result = service.import_from_tryton()
return Response(result, status=200)

View File

@@ -1,110 +0,0 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.status import HTTP_400_BAD_REQUEST
from rest_framework.pagination import PageNumberPagination
from rest_framework.permissions import AllowAny, IsAuthenticated
from decimal import Decimal
from ..models.sales import Sale
from ..models.payments import ReconciliationJar, PaymentMethods
from ..serializers import (
ReconciliationJarSerializer,
PaymentMethodSerializer,
SaleForRenconciliationSerializer,
)
from ..permissions import IsAdministrator
class Pagination(PageNumberPagination):
page_size = 10
page_size_query_param = "page_size"
class ReconciliateJarView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
data = request.data
cash_purchases_id = data.get("cash_purchases")
serializer = ReconciliationJarSerializer(data=data)
if serializer.is_valid():
cash_purchases = Sale.objects.filter(pk__in=cash_purchases_id)
if not self._is_valid_total(
cash_purchases, data.get("total_cash_purchases")
):
return Response(
{
"error": "total_cash_purchases not equal to sum of all purchases."
},
status=HTTP_400_BAD_REQUEST,
)
reconciliation = serializer.save()
other_purchases = self._get_other_purchases(data.get("other_totals"))
self._link_purchases(reconciliation, cash_purchases, other_purchases)
return Response({"id": reconciliation.id})
return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
def get(self, request):
reconciliations = ReconciliationJar.objects.all()
serializer = ReconciliationJarSerializer(reconciliations, many=True)
return Response(serializer.data)
def _is_valid_total(self, purchases, total):
calculated_total = sum(p.get_total() for p in purchases)
return Decimal(calculated_total).quantize(Decimal(".0001")) == (
Decimal(total).quantize(Decimal(".0001"))
)
def _get_other_purchases(self, other_totals):
if not other_totals:
return []
purchases = []
for method in other_totals:
purchases.extend(other_totals[method]["purchases"])
if purchases:
return Sale.objects.filter(pk__in=purchases)
return []
def _link_purchases(self, reconciliation, cash_purchases, other_purchases):
for purchase in cash_purchases:
purchase.reconciliation = reconciliation
purchase.clean()
purchase.save()
for purchase in other_purchases:
purchase.reconciliation = reconciliation
purchase.clean()
purchase.save()
class ReconciliateJarModelView(viewsets.ModelViewSet):
queryset = ReconciliationJar.objects.all().order_by("-date_time")
pagination_class = Pagination
serializer_class = ReconciliationJarSerializer
permission_classes = [IsAuthenticated, IsAdministrator]
class PaymentMethodView(APIView):
permission_classes = [AllowAny]
def get(self, request):
serializer = PaymentMethodSerializer(PaymentMethods.choices, many=True)
return Response(serializer.data)
class SalesForReconciliationView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.filter(reconciliation=None)
grouped_sales = {}
for sale in sales:
if sale.payment_method not in grouped_sales.keys():
grouped_sales[sale.payment_method] = []
serializer = SaleForRenconciliationSerializer(sale)
grouped_sales[sale.payment_method].append(serializer.data)
return Response(grouped_sales)

View File

@@ -1,58 +0,0 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from ..models.products import Product
from ..serializers import ProductSerializer
from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.products import ProductTrytonService
from ..services.tryton.client import get_tryton_client
class ProductView(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
def get_permissions(self):
if self.action in ("list", "retrieve"):
return [AllowAny()]
return [IsNotPublico()]
def get_queryset(self):
"""
Filters products by active status for list operations.
Detail operations (retrieve, update, destroy) return all products.
Query params for list:
- active=true (default): Only active products
- active=false: Only inactive products
- active=all: All products regardless of status
"""
queryset = Product.objects.all()
# Only filter for list action, not for detail operations
if self.action != "list":
return queryset
active_param = self.request.query_params.get("active", "true")
if active_param.lower() == "all":
return queryset
elif active_param.lower() in ["true", "1", "yes"]:
return queryset.filter(active=True)
elif active_param.lower() in ["false", "0", "no"]:
return queryset.filter(active=False)
else:
# Default behavior: return only active products
return queryset.filter(active=True)
class ProductsFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = get_tryton_client()
service = ProductTrytonService(tryton_client)
result = service.import_from_tryton()
return Response(result, status=200)

View File

@@ -1,122 +0,0 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
from ..models.geography import Country, Department, Municipality
from ..models.provenance import Organization, Supplier
from ..serializers import (
CountrySerializer,
DepartmentSerializer,
MunicipalitySerializer,
OrganizationSerializer,
SupplierSerializer,
)
from ..permissions import IsAdministrator, IsNotPublico
class _AdminWriteMixin:
def get_permissions(self):
if self.action in ("list", "retrieve"):
return [IsNotPublico()]
return [IsAuthenticated(), IsAdministrator()]
class OrganizationView(_AdminWriteMixin, viewsets.ModelViewSet):
queryset = Organization.objects.all()
serializer_class = OrganizationSerializer
class SupplierView(_AdminWriteMixin, viewsets.ModelViewSet):
queryset = Supplier.objects.select_related(
"organization", "municipality"
)
serializer_class = SupplierSerializer
class CountryView(_AdminWriteMixin, viewsets.ModelViewSet):
queryset = Country.objects.all()
serializer_class = CountrySerializer
class DepartmentView(_AdminWriteMixin, viewsets.ModelViewSet):
queryset = Department.objects.select_related("country")
serializer_class = DepartmentSerializer
class MunicipalityView(_AdminWriteMixin, viewsets.ModelViewSet):
queryset = Municipality.objects.select_related(
"department", "country"
)
serializer_class = MunicipalitySerializer
class SeedGeographyView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
country_data = request.data.get("country") or {}
country_name = country_data.get("name")
if not country_name:
return Response(
{"detail": "country.name is required"}, status=400
)
country, _ = Country.objects.get_or_create(
name=country_name,
defaults={"code": country_data.get("code", "")},
)
departments_created = 0
departments = {}
for department_data in request.data.get("departments", []):
department, created = Department.objects.get_or_create(
name=department_data["name"],
defaults={"country": country},
)
departments[department.name] = department
if created:
departments_created += 1
municipalities_created = 0
for municipality_data in request.data.get(
"municipalities", []
):
department_name = municipality_data["department"]
department = departments.get(department_name)
if department is None:
department, created = Department.objects.get_or_create(
name=department_name, defaults={"country": country}
)
departments[department.name] = department
if created:
departments_created += 1
municipality, created = Municipality.objects.get_or_create(
name=municipality_data["name"],
department=department,
defaults={"country": country},
)
if created:
municipalities_created += 1
latitude = municipality_data.get("latitude")
longitude = municipality_data.get("longitude")
if latitude is not None or longitude is not None:
municipality.country = country
if latitude is not None:
municipality.latitude = latitude
if longitude is not None:
municipality.longitude = longitude
municipality.save()
return Response(
{
"country": country.name,
"departments_created": departments_created,
"municipalities_created": municipalities_created,
"total_departments": Department.objects.count(),
"total_municipalities": Municipality.objects.count(),
},
status=200,
)

View File

@@ -1,191 +0,0 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.decorators import action
import io
import csv
from ..models.sales import (
Sale,
SaleLine,
CatalogSale,
generate_order_code,
)
from ..models.customers import Customer
from ..models.products import Product
from ..serializers import (
SaleSerializer,
CatalogSaleSerializer,
SaleSummarySerializer,
CatalogSaleSummarySerializer,
)
from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.sales import SaleTrytonService
from ..services.tryton.client import get_tryton_client
from ..views import sales_to_tryton_csv
def _fill_codes(Model):
rows = Model.objects.all()
for row in rows:
row.code = generate_order_code()
Model.objects.bulk_update(rows, ["code"])
return len(rows)
class SaleView(viewsets.ModelViewSet):
queryset = Sale.objects.all()
serializer_class = SaleSerializer
permission_classes = [IsNotPublico]
@action(
detail=False,
methods=["post"],
permission_classes=[IsAuthenticated, IsAdministrator],
)
def fill_codes(self, request):
return Response({"sales_updated": _fill_codes(Sale)})
def create(self, request):
data = request.data
customer = Customer.objects.get(pk=data["customer"])
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,
description=description,
)
for line in lines:
product = Product.objects.get(pk=line["product"])
quantity = line["quantity"]
unit_price = line["unit_price"]
SaleLine.objects.create(
sale=sale,
product=product,
quantity=quantity,
unit_price=unit_price,
)
return Response(
{"id": sale.id, "message": "Venta creada con exito"},
status=201,
)
class CatalogSaleView(viewsets.ModelViewSet):
queryset = CatalogSale.objects.all()
serializer_class = CatalogSaleSerializer
@action(
detail=False,
methods=["post"],
permission_classes=[IsAuthenticated, IsAdministrator],
)
def fill_codes(self, request):
return Response({"catalog_sales_updated": _fill_codes(CatalogSale)})
def get_permissions(self):
if self.action == "fill_codes":
return [IsAuthenticated(), IsAdministrator()]
if self.action == "create":
return [IsAuthenticated()]
return [IsNotPublico()]
class SaleSummary(APIView):
permission_classes = [IsNotPublico]
def get(self, request, id):
sale = Sale.objects.get(pk=id)
serializer = SaleSummarySerializer(
sale, context={"request": request}
)
return Response(serializer.data)
class CatalogSaleSummary(APIView):
permission_classes = [IsAuthenticated]
def get(self, request, id):
catalog_sale = CatalogSale.objects.get(pk=id)
serializer = CatalogSaleSummarySerializer(
catalog_sale, context={"request": request}
)
return Response(serializer.data)
class PublicSaleSummaryView(APIView):
permission_classes = [AllowAny]
def get(self, request, code=None):
code = code or request.query_params.get("code")
if not code:
return Response(
{"detail": "code is required"}, status=400
)
sale = Sale.objects.filter(code=code).first()
if sale:
serializer = SaleSummarySerializer(
sale, context={"request": request}
)
data = serializer.data
data["type"] = "sale"
return Response(data)
catalog_sale = CatalogSale.objects.filter(code=code).first()
if catalog_sale:
serializer = CatalogSaleSummarySerializer(
catalog_sale, context={"request": request}
)
data = serializer.data
data["type"] = "catalog"
return Response(data)
return Response(
{"detail": "order not found"}, status=404
)
class SalesForTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.all()
csv_data = self._generate_sales_CSV(sales)
return Response({"csv": csv_data})
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 = get_tryton_client()
service = SaleTrytonService(tryton_client)
result = service.send_to_tryton()
return Response(result, status=200)
class CatalogSalesToTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = get_tryton_client()
service = SaleTrytonService(tryton_client)
result = service.send_catalog_sales_to_tryton()
return Response(result, status=200)

View File

@@ -1,32 +0,0 @@
from rest_framework import status
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from ..models.store_settings import StoreSettings
from ..permissions import IsAdministrator
from ..serializers.store_settings import StoreSettingsSerializer
class StoreSettingsView(APIView):
def get_permissions(self):
if self.request.method == "GET":
return [AllowAny()]
return [IsAuthenticated(), IsAdministrator()]
def get(self, request):
settings = StoreSettings.get_singleton()
return Response(
StoreSettingsSerializer(
settings, context={"request": request}
).data
)
def patch(self, request):
settings = StoreSettings.get_singleton()
serializer = StoreSettingsSerializer(
settings, data=request.data, partial=True, context={"request": request}
)
serializer.is_valid(raise_exception=True)
serializer.save()
return Response(serializer.data, status=status.HTTP_200_OK)

View File

@@ -0,0 +1,457 @@
from rest_framework import viewsets
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
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):
page_size = 10
page_size_query_param = 'page_size'
class SaleView(viewsets.ModelViewSet):
queryset = Sale.objects.all()
serializer_class = SaleSerializer
def create(self, request):
data = request.data
customer = Customer.objects.get(pk=data['customer'])
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,
description=description
)
for line in lines:
product = Product.objects.get(pk=line['product'])
quantity = line['quantity']
unit_price = line['unit_price']
SaleLine.objects.create(
sale=sale,
product=product,
quantity=quantity,
unit_price=unit_price
)
return Response(
{'id': sale.id, 'message': 'Venta creada con exito'},
status=201
)
class ProductView(viewsets.ModelViewSet):
queryset = Product.objects.all()
serializer_class = ProductSerializer
class CustomerView(viewsets.ModelViewSet):
queryset = Customer.objects.all()
serializer_class = CustomerSerializer
class ReconciliateJarView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
data = request.data
cash_purchases_id = data.get('cash_purchases')
serializer = ReconciliationJarSerializer(data=data)
if serializer.is_valid():
cash_purchases = Sale.objects.filter(pk__in=cash_purchases_id)
if not self._is_valid_total(cash_purchases, data.get('total_cash_purchases')):
return Response(
{'error': 'total_cash_purchases not equal to sum of all purchases.'},
status=HTTP_400_BAD_REQUEST
)
reconciliation = serializer.save()
other_purchases = self._get_other_purchases(data.get('other_totals'))
self._link_purchases(reconciliation, cash_purchases, other_purchases)
return Response({'id': reconciliation.id})
return Response(serializer.errors, status=HTTP_400_BAD_REQUEST)
def get(self, request):
reconciliations = ReconciliationJar.objects.all()
serializer = ReconciliationJarSerializer(reconciliations, many=True)
return Response(serializer.data)
def _is_valid_total(self, purchases, total):
calculated_total = sum(p.get_total() for p in purchases)
return Decimal(calculated_total).quantize(Decimal('.0001')) == (
Decimal(total).quantize(Decimal('.0001')))
def _get_other_purchases(self, other_totals):
if not other_totals:
return []
purchases = []
for method in other_totals:
purchases.extend(other_totals[method]['purchases'])
if purchases:
return Sale.objects.filter(pk__in=purchases)
return []
def _link_purchases(self, reconciliation, cash_purchases, other_purchases):
for purchase in cash_purchases:
purchase.reconciliation = reconciliation
purchase.clean()
purchase.save()
for purchase in other_purchases:
purchase.reconciliation = reconciliation
purchase.clean()
purchase.save()
class PaymentMethodView(APIView):
def get(self, request):
serializer = PaymentMethodSerializer(PaymentMethods.choices, many=True)
return Response(serializer.data)
class SalesForReconciliationView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.filter(reconciliation=None)
grouped_sales = {}
for sale in sales:
if sale.payment_method not in grouped_sales.keys():
grouped_sales[sale.payment_method] = []
serializer = SaleForRenconciliationSerializer(sale)
grouped_sales[sale.payment_method].append(serializer.data)
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)})
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 @@
#!/usr/bin/env python3

View File

@@ -0,0 +1,66 @@
from django import forms
from django.forms.models import inlineformset_factory
from django.forms.widgets import DateInput, DateTimeInput
from .models import Sale, SaleLine, PaymentMethods
readonly_number_widget = forms.NumberInput(attrs={'readonly': 'readonly'})
class ImportProductsForm(forms.Form):
csv_file = forms.FileField()
class ImportCustomersForm(forms.Form):
csv_file = forms.FileField()
class PurchaseForm(forms.ModelForm):
class Meta:
model = Sale
fields = [
"customer",
"date",
"phone",
"description",
]
widgets = {
'date': DateInput(attrs={'type': 'date'})
}
class PurchaseLineForm(forms.ModelForm):
class Meta:
model = SaleLine
fields = [
"product",
"quantity",
"unit_price",
"description",
]
class PurchaseSummaryForm(forms.Form):
quantity_lines = forms.IntegerField(
widget=readonly_number_widget
)
quantity_products = forms.IntegerField(
widget=readonly_number_widget
)
ammount = forms.DecimalField(
max_digits=10,
decimal_places=2,
widget=readonly_number_widget
)
payment_method = forms.ChoiceField(
choices=[(PaymentMethods.CASH, PaymentMethods.CASH)],
)
SaleLineFormSet = inlineformset_factory(
Sale,
SaleLine,
extra=1,
fields='__all__'
)

View File

@@ -1,33 +0,0 @@
from django.conf import settings
from PIL import Image
def resize_catalogue_image(image_field):
width = settings.CATALOGUE_IMAGE_WIDTH
height = settings.CATALOGUE_IMAGE_HEIGHT
strict = settings.CATALOGUE_STRICT_DIMENSION
bg_color = settings.CATALOGUE_BACKGROUND_IMAGES_RGBA
img = Image.open(image_field.path)
img = img.convert("RGBA")
if strict:
img = resize_to_fit(img, width, height)
canvas = Image.new("RGBA", (width, height), bg_color)
x = (width - img.width) // 2
y = (height - img.height) // 2
canvas.paste(img, (x, y), img)
img = canvas
else:
if img.width > width:
new_height = int(img.height * width / img.width)
img = img.resize((width, new_height), Image.LANCZOS)
img.save(image_field.path, format="PNG")
def resize_to_fit(img, max_width, max_height):
ratio = min(max_width / img.width, max_height / img.height)
new_width = int(img.width * ratio)
new_height = int(img.height * ratio)
return img.resize((new_width, new_height), Image.LANCZOS)

View File

@@ -1,41 +0,0 @@
# Generated by Django 5.0.6 on 2026-05-28 21:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0044_alter_payment_type_payment_alter_sale_payment_method'),
]
operations = [
migrations.CreateModel(
name='CatalogSale',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date', models.DateTimeField(verbose_name='Date')),
('phone', models.CharField(blank=True, max_length=13, null=True)),
('description', models.CharField(blank=True, max_length=255, null=True)),
('customer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='don_confiao.customer')),
],
options={
'abstract': False,
},
),
migrations.CreateModel(
name='CatalogSaleLine',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('quantity', models.DecimalField(decimal_places=2, max_digits=10, null=True)),
('unit_price', models.DecimalField(decimal_places=2, max_digits=9)),
('description', models.CharField(blank=True, max_length=255, null=True)),
('catalog_sale', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='don_confiao.catalogsale')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='don_confiao.product')),
],
options={
'abstract': False,
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2026-05-29 04:39
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0045_catalogsale_catalogsaleline'),
]
operations = [
migrations.AddField(
model_name='product',
name='active',
field=models.BooleanField(default=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2026-05-31 01:46
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0046_product_active'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -1,33 +0,0 @@
# Generated by Django 5.0.6 on 2026-06-05 14:02
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0047_catalogsale_external_id'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='customer_address',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='customer_name',
field=models.CharField(blank=True, max_length=255, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='customer_phone',
field=models.CharField(blank=True, max_length=13, null=True),
),
migrations.AddField(
model_name='catalogsale',
name='pickup_method',
field=models.CharField(blank=True, max_length=30, null=True),
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.0.6 on 2026-06-13 18:31
import django.db.models.deletion
import don_confiao.models.catalogue_images
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0048_catalogsale_customer_address_and_more'),
]
operations = [
migrations.CreateModel(
name='CatalogueImage',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('image', models.ImageField(upload_to=don_confiao.models.catalogue_images._catalogue_image_upload_path)),
('uploaded_at', models.DateTimeField(auto_now_add=True)),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='catalogue_images', to='don_confiao.product')),
],
),
]

View File

@@ -1,24 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-08 18:24
import don_confiao.models.sales
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0049_catalogueimage'),
]
operations = [
migrations.AddField(
model_name='catalogsale',
name='code',
field=models.CharField(default=don_confiao.models.sales.generate_order_code, editable=False, max_length=32),
),
migrations.AddField(
model_name='sale',
name='code',
field=models.CharField(default=don_confiao.models.sales.generate_order_code, editable=False, max_length=32),
),
]

View File

@@ -1,27 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-08 17:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0049_catalogueimage'),
]
operations = [
migrations.CreateModel(
name='StoreSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('address', models.TextField()),
('latitude', models.FloatField(blank=True, null=True)),
('longitude', models.FloatField(blank=True, null=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Configuración de la tienda',
'verbose_name_plural': 'Configuración de la tienda',
},
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-08 19:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0050_storesettings'),
]
operations = [
migrations.AddField(
model_name='storesettings',
name='logo',
field=models.ImageField(blank=True, null=True, upload_to='logos/'),
),
]

View File

@@ -1,14 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-10 03:51
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0050_catalogsale_code_sale_code'),
('don_confiao', '0051_storesettings_logo'),
]
operations = [
]

View File

@@ -1,101 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-15 21:17
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0052_merge_20260810_0351'),
]
operations = [
migrations.CreateModel(
name='Country',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('code', models.CharField(blank=True, max_length=3, null=True, unique=True)),
],
options={
'verbose_name': 'Country',
'verbose_name_plural': 'Countries',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Organization',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('description', models.TextField(blank=True, null=True)),
('website', models.CharField(blank=True, max_length=255, null=True)),
('contact_email', models.CharField(blank=True, max_length=255, null=True)),
('contact_phone', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': 'Organization',
'verbose_name_plural': 'Organizations',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Department',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='departments', to='don_confiao.country')),
],
options={
'verbose_name': 'Department',
'verbose_name_plural': 'Departments',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Municipality',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100)),
('country', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.country')),
('department', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='municipalities', to='don_confiao.department')),
],
options={
'verbose_name': 'Municipality',
'verbose_name_plural': 'Municipalities',
'ordering': ['name'],
},
),
migrations.CreateModel(
name='Supplier',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=100, unique=True)),
('description', models.TextField(blank=True, null=True)),
('website', models.CharField(blank=True, max_length=255, null=True)),
('contact_email', models.CharField(blank=True, max_length=255, null=True)),
('contact_phone', models.CharField(blank=True, max_length=100, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('municipality', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.municipality')),
('organization', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='suppliers', to='don_confiao.organization')),
],
options={
'verbose_name': 'Supplier',
'verbose_name_plural': 'Suppliers',
'ordering': ['name'],
},
),
migrations.AddField(
model_name='product',
name='suppliers',
field=models.ManyToManyField(blank=True, related_name='products', to='don_confiao.supplier'),
),
migrations.AddConstraint(
model_name='municipality',
constraint=models.UniqueConstraint(fields=('name', 'department'), name='unique_municipality_name_per_department'),
),
]

View File

@@ -1,23 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-16 08:29
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0053_country_organization_department_municipality_and_more'),
]
operations = [
migrations.AddField(
model_name='municipality',
name='latitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True),
),
migrations.AddField(
model_name='municipality',
name='longitude',
field=models.DecimalField(blank=True, decimal_places=7, max_digits=10, null=True),
),
]

View File

@@ -1,18 +0,0 @@
# Generated by Django 5.0.6 on 2026-08-22 20:33
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('don_confiao', '0054_municipality_latitude_municipality_longitude'),
]
operations = [
migrations.AddField(
model_name='productcategory',
name='external_id',
field=models.CharField(blank=True, max_length=100, null=True),
),
]

View File

@@ -0,0 +1,210 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from decimal import Decimal
from datetime import datetime
class PaymentMethods(models.TextChoices):
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)
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')
class ProductCategory(models.Model):
name = models.CharField(max_length=100, unique=True)
def __str__(self):
return self.name
class Product(models.Model):
name = models.CharField(max_length=100, unique=True)
price = models.DecimalField(max_digits=9, decimal_places=2)
measuring_unit = models.CharField(
max_length=20,
choices=MeasuringUnits.choices,
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
@classmethod
def to_list(cls):
products_list = []
all_products = cls.objects.all()
for product in all_products:
rproduct = {
"id": product.id,
"name": product.name,
"price_list": product.price,
"uom": product.measuring_unit,
"categories": [c.name for c in product.categories.all()]
}
products_list.append(rproduct)
return products_list
class ReconciliationJar(models.Model):
is_valid = models.BooleanField(default=False)
date_time = models.DateTimeField()
description = models.CharField(max_length=255, null=True, blank=True)
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)
def clean(self):
self._validate_taken_ammount()
def add_payments(self, payments):
for payment in payments:
self.payment_set.add(payment)
self.is_valid = True
def _validate_taken_ammount(self):
ammount_cash = self.cash_taken + self.cash_discrepancy
if not self.total_cash_purchases == ammount_cash:
raise ValidationError(
{"cash_taken": _("The taken ammount has discrepancy.")}
)
class Sale(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.PROTECT)
date = models.DateTimeField("Date")
phone = models.CharField(max_length=13, null=True, blank=True)
description = models.CharField(max_length=255, null=True, blank=True)
payment_method = models.CharField(
max_length=30,
choices=PaymentMethods.choices,
default=PaymentMethods.CASH,
blank=False,
null=False
)
reconciliation = models.ForeignKey(
ReconciliationJar,
on_delete=models.RESTRICT,
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}"
def get_total(self):
lines = self.saleline_set.all()
return sum([l.quantity * l.unit_price for l in lines])
def clean(self):
if self.payment_method not in PaymentMethods.values:
raise ValidationError({'payment_method': "Invalid payment method"})
@classmethod
def sale_header_csv(cls):
sale_header_csv = [field.name for field in cls._meta.fields]
return sale_header_csv
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.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)
def __str__(self):
return f"{self.sale} - {self.product}"
class ReconciliationJarSummary():
def __init__(self, payments):
self._validate_payments(payments)
self._payments = payments
def _validate_payments(self, payments):
pass
@property
def total(self):
return sum([p.amount for p in self.payments])
@property
def payments(self):
return self._payments
class Payment(models.Model):
date_time = models.DateTimeField()
type_payment = models.CharField(
max_length=30,
choices=PaymentMethods.choices,
default=PaymentMethods.CASH
)
amount = models.DecimalField(max_digits=9, decimal_places=2)
reconciliation_jar = models.ForeignKey(
ReconciliationJar,
null=True,
default=None,
blank=True,
on_delete=models.RESTRICT
)
description = models.CharField(max_length=255, null=True, blank=True)
@classmethod
def get_reconciliation_jar_summary(cls):
return ReconciliationJarSummary(
cls.objects.filter(
type_payment=PaymentMethods.CASH,
reconciliation_jar=None
)
)
@classmethod
def total_payment_from_sale(cls, payment_method, sale):
payment = cls()
payment.date_time = datetime.today()
payment.type_payment = payment_method
payment.amount = sale.get_total()
payment.clean()
payment.save()
payment_sale = PaymentSale()
payment_sale.payment = payment
payment_sale.sale = sale
payment_sale.clean()
payment_sale.save()
class PaymentSale(models.Model):
payment = models.ForeignKey(Payment, on_delete=models.CASCADE)
sale = models.ForeignKey(Sale, on_delete=models.CASCADE)
class AdminCode(models.Model):
value = models.CharField(max_length=255, null=False, blank=False)

View File

@@ -1,12 +0,0 @@
from .store_settings import StoreSettings
from .geography import Country, Department, Municipality
from .provenance import Organization, Supplier
__all__ = [
"StoreSettings",
"Country",
"Department",
"Municipality",
"Organization",
"Supplier",
]

View File

@@ -1,5 +0,0 @@
from django.db import models
class AdminCode(models.Model):
value = models.CharField(max_length=255, null=False, blank=False)

View File

@@ -1,20 +0,0 @@
from django.db import models
from .products import Product
def _catalogue_image_upload_path(instance, filename):
return f"catalogue_images/{instance.product_id}/{filename}"
class CatalogueImage(models.Model):
product = models.ForeignKey(
Product,
on_delete=models.CASCADE,
related_name="catalogue_images",
)
image = models.ImageField(upload_to=_catalogue_image_upload_path)
uploaded_at = models.DateTimeField(auto_now_add=True)
def __str__(self):
return f"CatalogueImage for {self.product.name} ({self.id})"

View File

@@ -1,17 +0,0 @@
from django.db import models
class Customer(models.Model):
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

View File

@@ -1,60 +0,0 @@
from django.db import models
class Country(models.Model):
name = models.CharField(max_length=100, unique=True)
code = models.CharField(
max_length=3, unique=True, null=True, blank=True
)
class Meta:
verbose_name = "Country"
verbose_name_plural = "Countries"
ordering = ["name"]
def __str__(self):
return self.name
class Department(models.Model):
name = models.CharField(max_length=100, unique=True)
country = models.ForeignKey(
Country, on_delete=models.PROTECT, related_name="departments"
)
class Meta:
verbose_name = "Department"
verbose_name_plural = "Departments"
ordering = ["name"]
def __str__(self):
return self.name
class Municipality(models.Model):
name = models.CharField(max_length=100)
department = models.ForeignKey(
Department, on_delete=models.PROTECT, related_name="municipalities"
)
country = models.ForeignKey(
Country, on_delete=models.PROTECT, related_name="municipalities"
)
latitude = models.DecimalField(
max_digits=10, decimal_places=7, null=True, blank=True
)
longitude = models.DecimalField(
max_digits=10, decimal_places=7, null=True, blank=True
)
class Meta:
verbose_name = "Municipality"
verbose_name_plural = "Municipalities"
ordering = ["name"]
constraints = [
models.UniqueConstraint(
fields=["name", "department"], name="unique_municipality_name_per_department"
)
]
def __str__(self):
return self.name

View File

@@ -1,38 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from datetime import datetime
class PaymentMethods(models.TextChoices):
CASH = "CASH", _("Efectivo")
CONFIAR = "CONFIAR", _("Confiar")
BANCOLOMBIA = "BANCOLOMBIA", _("Bancolombia")
CREDIT = "CREDIT", _("Crédito")
class ReconciliationJar(models.Model):
is_valid = models.BooleanField(default=False)
date_time = models.DateTimeField()
description = models.CharField(max_length=255, null=True, blank=True)
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
)
def clean(self):
self._validate_taken_ammount()
def add_payments(self, payments):
for payment in payments:
self.payment_set.add(payment)
self.is_valid = True
def _validate_taken_ammount(self):
ammount_cash = self.cash_taken + self.cash_discrepancy
if not self.total_cash_purchases == ammount_cash:
raise ValidationError(
{"cash_taken": _("The taken ammount has discrepancy.")}
)

View File

@@ -1,52 +0,0 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
class MeasuringUnits(models.TextChoices):
UNIT = "UNIT", _("Unit")
class ProductCategory(models.Model):
name = models.CharField(max_length=100, unique=True)
external_id = models.CharField(max_length=100, null=True, blank=True)
def __str__(self):
return self.name
class Product(models.Model):
active = models.BooleanField(default=True)
name = models.CharField(max_length=100, unique=True)
price = models.DecimalField(max_digits=9, decimal_places=2)
measuring_unit = models.CharField(
max_length=20,
choices=MeasuringUnits.choices,
default=MeasuringUnits.UNIT,
)
unit_external_id = models.CharField(
max_length=100, null=True, blank=True
)
categories = models.ManyToManyField(ProductCategory)
suppliers = models.ManyToManyField(
"Supplier", blank=True, related_name="products"
)
external_id = models.CharField(max_length=100, null=True, blank=True)
def __str__(self):
return self.name
@classmethod
def to_list(cls):
products_list = []
all_products = cls.objects.all()
for product in all_products:
rproduct = {
"id": product.id,
"name": product.name,
"price_list": product.price,
"uom": product.measuring_unit,
"external_id": product.external_id,
"categories": [c.name for c in product.categories.all()],
}
products_list.append(rproduct)
return products_list

View File

@@ -1,53 +0,0 @@
from django.db import models
from .geography import Municipality
class Organization(models.Model):
name = models.CharField(max_length=100, unique=True)
description = models.TextField(null=True, blank=True)
website = models.CharField(max_length=255, null=True, blank=True)
contact_email = models.CharField(max_length=255, null=True, blank=True)
contact_phone = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Organization"
verbose_name_plural = "Organizations"
ordering = ["name"]
def __str__(self):
return self.name
class Supplier(models.Model):
name = models.CharField(max_length=100, unique=True)
description = models.TextField(null=True, blank=True)
organization = models.ForeignKey(
Organization,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="suppliers",
)
municipality = models.ForeignKey(
Municipality,
on_delete=models.SET_NULL,
null=True,
blank=True,
related_name="suppliers",
)
website = models.CharField(max_length=255, null=True, blank=True)
contact_email = models.CharField(max_length=255, null=True, blank=True)
contact_phone = models.CharField(max_length=100, null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Supplier"
verbose_name_plural = "Suppliers"
ordering = ["name"]
def __str__(self):
return self.name

View File

@@ -1,151 +0,0 @@
from django.db import models
from .customers import Customer
from .products import Product
from .payments import PaymentMethods, ReconciliationJar
from django.core.exceptions import ValidationError
from datetime import datetime
import uuid
def generate_order_code():
return uuid.uuid4().hex
class SaleAbstractModel(models.Model):
customer = models.ForeignKey(Customer, on_delete=models.PROTECT)
date = models.DateTimeField("Date")
phone = models.CharField(max_length=13, null=True, blank=True)
description = models.CharField(max_length=255, null=True, blank=True)
code = models.CharField(
max_length=32, default=generate_order_code, editable=False
)
class Meta:
abstract = True
class SaleLineAbstractModel(models.Model):
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)
class Meta:
abstract = True
class Sale(SaleAbstractModel):
payment_method = models.CharField(
max_length=30,
choices=PaymentMethods.choices,
default=PaymentMethods.CASH,
blank=False,
null=False,
)
reconciliation = models.ForeignKey(
ReconciliationJar,
on_delete=models.RESTRICT,
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}"
def get_total(self):
lines = self.saleline_set.all()
return sum([l.quantity * l.unit_price for l in lines])
def clean(self):
if self.payment_method not in PaymentMethods.values:
raise ValidationError(
{"payment_method": "Invalid payment method"}
)
@classmethod
def sale_header_csv(cls):
sale_header_csv = [field.name for field in cls._meta.fields]
return sale_header_csv
class SaleLine(SaleLineAbstractModel):
sale = models.ForeignKey(Sale, on_delete=models.CASCADE)
def __str__(self):
return f"{self.sale} - {self.product}"
class CatalogSale(SaleAbstractModel):
external_id = models.CharField(max_length=100, null=True, blank=True)
customer_name = models.CharField(max_length=255, null=True, blank=True)
customer_phone = models.CharField(max_length=13, null=True, blank=True)
customer_address = models.CharField(
max_length=255, null=True, blank=True
)
pickup_method = models.CharField(max_length=30, null=True, blank=True)
def __str__(self):
return f"{self.date} {self.customer}"
def get_total(self):
lines = self.catalogsaleline_set.all()
return sum([l.quantity * l.unit_price for l in lines])
class CatalogSaleLine(SaleLineAbstractModel):
catalog_sale = models.ForeignKey(CatalogSale, on_delete=models.CASCADE)
def __str__(self):
return f"{self.catalog_sale} - {self.product}"
class Payment(models.Model):
date_time = models.DateTimeField()
type_payment = models.CharField(
max_length=30,
choices=PaymentMethods.choices,
default=PaymentMethods.CASH,
)
amount = models.DecimalField(max_digits=9, decimal_places=2)
reconciliation_jar = models.ForeignKey(
ReconciliationJar,
null=True,
default=None,
blank=True,
on_delete=models.RESTRICT,
)
description = models.CharField(max_length=255, null=True, blank=True)
@classmethod
def get_reconciliation_jar_summary(cls):
return ReconciliationJarSummary(
cls.objects.filter(
type_payment=PaymentMethods.CASH, reconciliation_jar=None
)
)
@classmethod
def total_payment_from_sale(cls, payment_method, sale):
payment = cls()
payment.date_time = datetime.today()
payment.type_payment = payment_method
payment.amount = sale.get_total()
payment.clean()
payment.save()
payment_sale = PaymentSale()
payment_sale.payment = payment
payment_sale.sale = sale
payment_sale.clean()
payment_sale.save()
class PaymentSale(models.Model):
payment = models.ForeignKey(Payment, on_delete=models.CASCADE)
sale = models.ForeignKey(Sale, on_delete=models.CASCADE)

View File

@@ -1,21 +0,0 @@
from django.db import models
class StoreSettings(models.Model):
address = models.TextField()
latitude = models.FloatField(null=True, blank=True)
longitude = models.FloatField(null=True, blank=True)
logo = models.ImageField(upload_to="logos/", null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
verbose_name = "Configuración de la tienda"
verbose_name_plural = "Configuración de la tienda"
def __str__(self):
return f"StoreSettings (id={self.id})"
@classmethod
def get_singleton(cls):
instance, _ = cls.objects.get_or_create(pk=1)
return instance

View File

@@ -1,26 +1,6 @@
from rest_framework.permissions import BasePermission
def _get_user_type(user):
if not user or not user.is_authenticated:
return None
profile = getattr(user, "profile", None)
if profile:
return profile.user_type
return "administrator" if user.is_staff else "user"
class IsAdministrator(BasePermission):
def has_permission(self, request, view):
return _get_user_type(request.user) == "administrator"
class IsNotPublico(BasePermission):
def has_permission(self, request, view):
user_type = _get_user_type(request.user)
return user_type is not None and user_type != "publico"
class IsPublico(BasePermission):
def has_permission(self, request, view):
return _get_user_type(request.user) == "publico"
return request.user and request.user.is_staff

View File

@@ -0,0 +1,103 @@
from rest_framework import serializers
from .models import Sale, SaleLine, Product, Customer, ReconciliationJar
class SaleLineSerializer(serializers.ModelSerializer):
class Meta:
model = SaleLine
fields = ['id', 'sale', 'product', 'unit_price', 'quantity']
class SaleSerializer(serializers.ModelSerializer):
total = serializers.ReadOnlyField(source='get_total')
class Meta:
model = Sale
fields = ['id', 'customer', 'date', 'saleline_set',
'total', 'payment_method', 'external_id']
class ProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name', 'price', 'measuring_unit', 'categories']
class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ['id', 'name', 'address', 'email', 'phone', 'external_id']
class ReconciliationJarSerializer(serializers.ModelSerializer):
Sales = SaleSerializer(many=True, read_only=True)
class Meta:
model = ReconciliationJar
fields = [
'id',
'date_time',
'reconcilier',
'cash_taken',
'cash_discrepancy',
'total_cash_purchases',
'Sales',
]
class PaymentMethodSerializer(serializers.Serializer):
text = serializers.CharField()
value = serializers.CharField()
def to_representation(self, instance):
return {
'text': instance[1],
'value': instance[0],
}
class SaleForRenconciliationSerializer(serializers.Serializer):
id = serializers.IntegerField()
date = serializers.DateTimeField()
payment_method = serializers.CharField()
customer = serializers.SerializerMethodField()
total = serializers.SerializerMethodField()
def get_customer(self, sale):
return {
'id': sale.customer.id,
'name': sale.customer.name,
}
def get_total(self, sale):
return sale.get_total()
class ListCustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ['id', 'name']
class ListProductSerializer(serializers.ModelSerializer):
class Meta:
model = Product
fields = ['id', 'name']
class SummarySaleLineSerializer(serializers.ModelSerializer):
product = ListProductSerializer()
class Meta:
model = SaleLine
fields = ['product', 'quantity', 'unit_price', 'description']
class SaleSummarySerializer(serializers.ModelSerializer):
customer = ListCustomerSerializer()
lines = SummarySaleLineSerializer(many=True, source='saleline_set')
class Meta:
model = Sale
fields = ['id', 'date', 'customer', 'payment_method', 'lines']

View File

@@ -1,54 +0,0 @@
from .catalogue_images import CatalogueImageSerializer
from .products import ProductSerializer, ListProductSerializer
from .customers import CustomerSerializer, ListCustomerSerializer
from .sales import (
SaleSerializer,
SaleLineSerializer,
CatalogSaleSerializer,
CatalogSaleLineSerializer,
SummarySaleLineSerializer,
SaleSummarySerializer,
CatalogSummarySaleLineSerializer,
CatalogSaleSummarySerializer,
SaleForRenconciliationSerializer,
)
from .payments import (
ReconciliationJarSerializer,
PaymentMethodSerializer,
)
from .store_settings import StoreSettingsSerializer
from .geography import CountrySerializer, DepartmentSerializer, MunicipalitySerializer
from .provenance import OrganizationSerializer, SupplierSerializer
__all__ = [
# Catalogue Images
"CatalogueImageSerializer",
# Products
"ProductSerializer",
"ListProductSerializer",
# Customers
"CustomerSerializer",
"ListCustomerSerializer",
# Sales
"SaleSerializer",
"SaleLineSerializer",
"CatalogSaleSerializer",
"CatalogSaleLineSerializer",
"SummarySaleLineSerializer",
"SaleSummarySerializer",
"CatalogSummarySaleLineSerializer",
"CatalogSaleSummarySerializer",
"SaleForRenconciliationSerializer",
# Payments
"ReconciliationJarSerializer",
"PaymentMethodSerializer",
# Store Settings
"StoreSettingsSerializer",
# Geography
"CountrySerializer",
"DepartmentSerializer",
"MunicipalitySerializer",
# Provenance
"OrganizationSerializer",
"SupplierSerializer",
]

View File

@@ -1,20 +0,0 @@
from django.conf import settings
from rest_framework import serializers
from ..models.catalogue_images import CatalogueImage
class CatalogueImageSerializer(serializers.ModelSerializer):
class Meta:
model = CatalogueImage
fields = ["id", "product", "image", "uploaded_at"]
read_only_fields = ["uploaded_at"]
def validate_image(self, value):
max_size = settings.CATALOGUE_MAX_UPLOAD_SIZE
if value.size > max_size:
raise serializers.ValidationError(
f"Image size exceeds the maximum allowed size of "
f"{max_size // (1024 * 1024)}MB."
)
return value

View File

@@ -1,15 +0,0 @@
from rest_framework import serializers
from ..models.customers import Customer
class CustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ["id", "name", "address", "email", "phone", "external_id"]
class ListCustomerSerializer(serializers.ModelSerializer):
class Meta:
model = Customer
fields = ["id", "name"]

View File

@@ -1,55 +0,0 @@
from rest_framework import serializers
from ..models.geography import Country, Department, Municipality
class CountrySerializer(serializers.ModelSerializer):
class Meta:
model = Country
fields = ["id", "name", "code"]
class CountryBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Country
fields = ["id", "name", "code"]
class DepartmentBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Department
fields = ["id", "name"]
class MunicipalityBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Municipality
fields = ["id", "name", "latitude", "longitude"]
class DepartmentSerializer(serializers.ModelSerializer):
country_detail = CountryBriefSerializer(source="country", read_only=True)
class Meta:
model = Department
fields = ["id", "name", "country", "country_detail"]
class MunicipalitySerializer(serializers.ModelSerializer):
department_detail = DepartmentBriefSerializer(
source="department", read_only=True
)
country_detail = CountryBriefSerializer(source="country", read_only=True)
class Meta:
model = Municipality
fields = [
"id",
"name",
"department",
"department_detail",
"country",
"country_detail",
"latitude",
"longitude",
]

View File

@@ -1,31 +0,0 @@
from rest_framework import serializers
from ..models.payments import ReconciliationJar, PaymentMethods
from .sales import SaleSerializer
class ReconciliationJarSerializer(serializers.ModelSerializer):
Sales = SaleSerializer(many=True, read_only=True)
class Meta:
model = ReconciliationJar
fields = [
"id",
"date_time",
"reconcilier",
"cash_taken",
"cash_discrepancy",
"total_cash_purchases",
"Sales",
]
class PaymentMethodSerializer(serializers.Serializer):
text = serializers.CharField()
value = serializers.CharField()
def to_representation(self, instance):
return {
"text": instance[1],
"value": instance[0],
}

View File

@@ -1,51 +0,0 @@
from rest_framework import serializers
from ..models.products import Product, ProductCategory
from ..models.provenance import Supplier
class ProductSerializer(serializers.ModelSerializer):
catalogue_images = serializers.SerializerMethodField()
suppliers = serializers.PrimaryKeyRelatedField(
many=True, queryset=Supplier.objects.all(), required=False
)
class Meta:
model = Product
fields = [
"id",
"name",
"active",
"price",
"measuring_unit",
"categories",
"suppliers",
"external_id",
"catalogue_images",
]
def get_catalogue_images(self, obj):
request = self.context.get("request")
if not request:
return [img.image.url for img in obj.catalogue_images.all()]
return [
request.build_absolute_uri(img.image.url)
for img in obj.catalogue_images.all()
]
class ListProductSerializer(serializers.ModelSerializer):
catalogue_images = serializers.SerializerMethodField()
class Meta:
model = Product
fields = ["id", "name", "catalogue_images"]
def get_catalogue_images(self, obj):
request = self.context.get("request")
if not request:
return [img.image.url for img in obj.catalogue_images.all()]
return [
request.build_absolute_uri(img.image.url)
for img in obj.catalogue_images.all()
]

View File

@@ -1,79 +0,0 @@
from rest_framework import serializers
from ..models.provenance import Organization, Supplier
from .geography import (
CountryBriefSerializer,
DepartmentBriefSerializer,
MunicipalityBriefSerializer,
)
class OrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = [
"id",
"name",
"description",
"website",
"contact_email",
"contact_phone",
"created_at",
"updated_at",
]
class OrganizationBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = [
"id",
"name",
"description",
"website",
"contact_email",
"contact_phone",
]
class SupplierSerializer(serializers.ModelSerializer):
organization_detail = OrganizationBriefSerializer(
source="organization", read_only=True
)
municipality_detail = MunicipalityBriefSerializer(
source="municipality", read_only=True
)
products = serializers.PrimaryKeyRelatedField(
many=True, read_only=True
)
class Meta:
model = Supplier
fields = [
"id",
"name",
"description",
"organization",
"organization_detail",
"municipality",
"municipality_detail",
"website",
"contact_email",
"contact_phone",
"products",
"created_at",
"updated_at",
]
class SupplierBriefSerializer(serializers.ModelSerializer):
class Meta:
model = Supplier
fields = [
"id",
"name",
"description",
"website",
"contact_email",
"contact_phone",
]

View File

@@ -1,187 +0,0 @@
from rest_framework import serializers
from django.urls import reverse
from ..models.products import Product
from ..models.sales import (
Sale,
SaleLine,
CatalogSale,
CatalogSaleLine,
Payment,
)
from .products import ListProductSerializer
from .customers import ListCustomerSerializer
from ..services.provenance import build_product_provenance
class PublicSummaryLinkMixin:
def get_link(self, obj):
path = reverse(
"don_confiao:public_purchase_summary", args=[obj.code]
)
request = self.context.get("request")
if request:
return request.build_absolute_uri(path)
return path
class ProvenanceSummaryMixin:
def get_product_provenance(self, obj):
products = Product.objects.filter(
saleline__sale=obj
).distinct()
return build_product_provenance(
products, self.context.get("request")
)
class SaleLineSerializer(serializers.ModelSerializer):
class Meta:
model = SaleLine
fields = ["id", "sale", "product", "unit_price", "quantity"]
class SaleSerializer(serializers.ModelSerializer):
total = serializers.ReadOnlyField(source="get_total")
class Meta:
model = Sale
fields = [
"id",
"code",
"customer",
"date",
"saleline_set",
"total",
"payment_method",
"external_id",
]
class CatalogSaleLineSerializer(serializers.ModelSerializer):
class Meta:
model = CatalogSaleLine
read_only_fields = ["catalog_sale"]
fields = [
"id",
"catalog_sale",
"product",
"unit_price",
"quantity",
]
class CatalogSaleSerializer(serializers.ModelSerializer):
catalogsaleline_set = CatalogSaleLineSerializer(
many=True, required=False
)
total = serializers.ReadOnlyField(source="get_total")
class Meta:
model = CatalogSale
fields = [
"id",
"code",
"customer",
"date",
"catalogsaleline_set",
"total",
"external_id",
"customer_name",
"customer_phone",
"customer_address",
"pickup_method",
]
def create(self, validated_data):
lines_data = validated_data.pop("catalogsaleline_set", [])
catalog_sale = CatalogSale.objects.create(**validated_data)
for line_data in lines_data:
CatalogSaleLine.objects.create(
catalog_sale=catalog_sale, **line_data
)
return catalog_sale
class SummarySaleLineSerializer(serializers.ModelSerializer):
product = ListProductSerializer()
class Meta:
model = SaleLine
fields = ["product", "quantity", "unit_price", "description"]
class SaleSummarySerializer(
PublicSummaryLinkMixin, ProvenanceSummaryMixin, serializers.ModelSerializer
):
customer = ListCustomerSerializer()
lines = SummarySaleLineSerializer(many=True, source="saleline_set")
link = serializers.SerializerMethodField()
product_provenance = serializers.SerializerMethodField()
class Meta:
model = Sale
fields = [
"id",
"code",
"date",
"customer",
"payment_method",
"lines",
"link",
"product_provenance",
]
class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
product = ListProductSerializer()
class Meta:
model = CatalogSaleLine
fields = ["product", "quantity", "unit_price", "description"]
class CatalogProvenanceSummaryMixin(ProvenanceSummaryMixin):
def get_product_provenance(self, obj):
products = Product.objects.filter(
catalogsaleline__catalog_sale=obj
).distinct()
return build_product_provenance(
products, self.context.get("request")
)
class CatalogSaleSummarySerializer(
PublicSummaryLinkMixin,
CatalogProvenanceSummaryMixin,
serializers.ModelSerializer,
):
customer = ListCustomerSerializer()
lines = CatalogSummarySaleLineSerializer(
many=True, source="catalogsaleline_set"
)
link = serializers.SerializerMethodField()
product_provenance = serializers.SerializerMethodField()
class Meta:
model = CatalogSale
fields = ["id", "code", "date", "customer", "lines", "link", "product_provenance"]
class SaleForRenconciliationSerializer(serializers.Serializer):
id = serializers.IntegerField()
date = serializers.DateTimeField()
payment_method = serializers.CharField()
customer = serializers.SerializerMethodField()
total = serializers.SerializerMethodField()
def get_customer(self, sale):
return {
"id": sale.customer.id,
"name": sale.customer.name,
}
def get_total(self, sale):
return sale.get_total()

View File

@@ -1,22 +0,0 @@
from django.conf import settings
from rest_framework import serializers
from ..models.store_settings import StoreSettings
class StoreSettingsSerializer(serializers.ModelSerializer):
class Meta:
model = StoreSettings
fields = ["id", "address", "latitude", "longitude", "logo", "updated_at"]
read_only_fields = ["id", "updated_at"]
def validate_logo(self, value):
if value is None:
return value
max_size = settings.LOGO_MAX_UPLOAD_SIZE
if value.size > max_size:
raise serializers.ValidationError(
f"Logo size exceeds the maximum allowed size of "
f"{max_size // (1024 * 1024)}MB."
)
return value

View File

@@ -1,13 +0,0 @@
from .tryton import (
get_tryton_client,
ProductTrytonService,
CustomerTrytonService,
SaleTrytonService,
)
__all__ = [
"get_tryton_client",
"ProductTrytonService",
"CustomerTrytonService",
"SaleTrytonService",
]

View File

@@ -1,66 +0,0 @@
from ..serializers.geography import (
CountryBriefSerializer,
DepartmentBriefSerializer,
MunicipalityBriefSerializer,
)
from ..serializers.provenance import (
OrganizationBriefSerializer,
SupplierBriefSerializer,
)
from ..serializers.products import ListProductSerializer
def build_product_provenance(products, request=None):
"""Domain data for provenance charts.
Returns a list of products, each with its suppliers. Each supplier
entry includes the linked organization and the territorial hierarchy
(municipality -> department -> country) derived from the supplier's
municipality.
"""
result = []
for product in products:
suppliers = []
supplier_qs = product.suppliers.all().select_related(
"organization", "municipality__department__country"
)
for supplier in supplier_qs:
municipality = supplier.municipality
entry = {
"supplier": SupplierBriefSerializer(supplier).data,
"organization": (
OrganizationBriefSerializer(supplier.organization).data
if supplier.organization
else None
),
"municipality": (
MunicipalityBriefSerializer(municipality).data
if municipality
else None
),
"department": (
DepartmentBriefSerializer(
municipality.department
).data
if municipality
else None
),
"country": (
CountryBriefSerializer(
municipality.department.country
).data
if municipality
else None
),
}
suppliers.append(entry)
result.append(
{
"product": ListProductSerializer(
product, context={"request": request}
).data,
"suppliers": suppliers,
}
)
return result

View File

@@ -1,13 +0,0 @@
from .client import get_tryton_client, TrytonSale, TrytonLineSale
from .products import ProductTrytonService
from .customers import CustomerTrytonService
from .sales import SaleTrytonService
__all__ = [
"get_tryton_client",
"TrytonSale",
"TrytonLineSale",
"ProductTrytonService",
"CustomerTrytonService",
"SaleTrytonService",
]

View File

@@ -1,160 +0,0 @@
import os
from sabatron_tryton_rpc_client.client import Client
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]
def get_tryton_client():
"""Factory para crear cliente Tryton conectado"""
client = Client(
hostname=TRYTON_HOST,
database=TRYTON_DATABASE,
username=TRYTON_USERNAME,
password=TRYTON_PASSWORD,
)
client.connect()
return client
class TrytonSale:
"""Representa una venta para exportación a Tryton"""
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.build_tryton_comment(),
"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,
}
def build_tryton_comment(self):
"""Construye el comentario para la venta en Tryton"""
return self.sale.description or ""
class TrytonLineSale:
"""Representa una línea de venta para exportación a Tryton"""
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 TrytonCatalogSale:
"""Representa una catalog sale para exportación a Tryton"""
def __init__(self, catalog_sale, lines):
self.catalog_sale = catalog_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.catalog_sale.customer.address_external_id,
"invoice_address": self.catalog_sale.customer.address_external_id,
"currency": TRYTON_COP_CURRENCY,
"comment": self.build_tryton_comment(),
"description": "Venta de catálogo",
"party": self.catalog_sale.customer.external_id,
"reference": "don_confiao_catalog "
+ str(self.catalog_sale.id),
"sale_date": self._format_date(self.catalog_sale.date),
"lines": [
[
"create",
[
TrytonCatalogSaleLine(line).to_tryton()
for line in self.lines
],
]
],
"self_pick_up": True,
}
def build_tryton_comment(self):
"""Construye el comentario para la venta en Tryton"""
comment = f"Cliente: {self.catalog_sale.customer_name or ''}\n"
comment += f"Teléfono: {self.catalog_sale.customer_phone or ''}\n"
comment += (
f"Dirección: {self.catalog_sale.customer_address or ''}\n"
)
comment += f"Método de recogida: {self.catalog_sale.pickup_method or ''}\n"
return comment
class TrytonCatalogSaleLine:
"""Representa una línea de catalog sale para exportación a Tryton"""
def __init__(self, catalog_sale_line):
self.catalog_sale_line = catalog_sale_line
def _format_decimal(self, number):
return {"__class__": "Decimal", "decimal": str(number)}
def to_tryton(self):
return {
"product": self.catalog_sale_line.product.external_id,
"quantity": self._format_decimal(
self.catalog_sale_line.quantity
),
"type": "line",
"unit": self.catalog_sale_line.product.unit_external_id,
"unit_price": self._format_decimal(
self.catalog_sale_line.unit_price
),
}

View File

@@ -1,139 +0,0 @@
from ...models.customers import Customer
class CustomerTrytonService:
"""Servicio para sincronización de clientes con Tryton"""
def __init__(self, tryton_client):
self.client = tryton_client
def import_from_tryton(self):
"""Importa clientes desde Tryton"""
method = "model.party.party.search"
context = {"company": 1}
params = [[], 0, 1000, [["name", "ASC"], ["id", None]], context]
party_ids = self.client.call(method, params)
tryton_parties = self._get_party_details(party_ids, context)
checked_tryton_parties = [
{"id": p.get("id"), "name": p.get("name")}
for p in tryton_parties
]
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:
try:
customer = self._create_customer(tryton_party)
created_customers.append(self._build_item(customer))
continue
except Exception as e:
print(
f"Error al importar clientes: {e}El cliente: {tryton_party}"
)
failed_parties.append(
self._build_failure(tryton_party, "crear", e)
)
continue
try:
if self._need_update(customer, tryton_party):
changes = self._describe_changes(customer, tryton_party)
self._update_customer(customer, tryton_party)
updated_customers.append(
{**self._build_item(customer), "detail": changes}
)
else:
untouched_customers.append(self._build_item(customer))
except Exception as e:
print(
f"Error al importar clientes: {e}El cliente: {tryton_party}"
)
failed_parties.append(
self._build_failure(tryton_party, "actualizar", e)
)
return {
"checked_tryton_parties": checked_tryton_parties,
"failed_parties": failed_parties,
"updated_customers": updated_customers,
"created_customers": created_customers,
"untouched_customers": untouched_customers,
}
def _build_item(self, customer):
"""Construye el objeto de un cliente sincronizado"""
return {"id": customer.id, "name": customer.name}
def _describe_changes(self, customer, tryton_party):
"""Describe los cambios detectados entre el cliente local y Tryton"""
changes = []
name = tryton_party.get("name")
if customer.name != name:
changes.append(f"Nombre: {customer.name}{name}")
if tryton_party.get("addresses") and tryton_party.get("addresses")[0]:
new_address = str(tryton_party.get("addresses")[0])
if customer.address_external_id != new_address:
changes.append(
f"Dirección: {customer.address_external_id}{new_address}"
)
return ", ".join(changes)
def _build_failure(self, tryton_party, operation, exc):
"""Construye el detalle de un cliente que falló al sincronizar"""
error = self._friendly_error(tryton_party, str(exc))
return {
"external_id": tryton_party.get("id"),
"name": tryton_party.get("name"),
"error": f"Error al {operation} cliente: {error}",
}
def _friendly_error(self, tryton_party, message):
"""Traduce causas conocidas de error a mensajes legibles"""
if "name" in message and (
"not-null constraint" in message or "NOT NULL constraint" in message
):
return "El cliente no tiene nombre en Tryton"
return message
def _get_party_details(self, party_ids, context):
"""Obtiene detalles de clientes desde Tryton"""
tryton_fields = ["id", "name", "addresses"]
method = "model.party.party.read"
params = (party_ids, tryton_fields, context)
response = self.client.call(method, params)
return response
def _need_update(self, customer, tryton_party):
"""Verifica si el cliente necesita actualización"""
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
return False
def _create_customer(self, tryton_party):
"""Crea un nuevo cliente desde datos de Tryton"""
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):
"""Actualiza un cliente existente con datos de Tryton"""
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

@@ -1,294 +0,0 @@
from ...models.products import Product, ProductCategory
class ProductTrytonService:
"""Servicio para sincronización de productos con Tryton"""
def __init__(self, tryton_client):
self.client = tryton_client
def import_from_tryton(self):
"""Importa productos desde Tryton"""
method = "model.product.product.search"
context = {"company": 1}
params = [
[["salable", "=", True]],
0,
1000,
[["rec_name", "ASC"], ["id", None]],
context,
]
product_ids = self.client.call(method, params)
tryton_products = self._get_product_details(product_ids, context)
try:
(
categories_by_tryton_id,
category_report,
) = self._import_categories(tryton_products, context)
created_categories = category_report["created"]
updated_categories = category_report["updated"]
except Exception as e:
print(f"Error al importar categorías: {e}")
categories_by_tryton_id = {}
created_categories = []
updated_categories = []
checked_tryton_products = [
{"id": p.get("id"), "name": p.get("name")}
for p in tryton_products
]
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)
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
created_products.append(self._build_item(product))
continue
except Exception as e:
print(
f"Error al importar productos: {e}El producto: {tryton_product}"
)
failed_products.append(
self._build_failure(tryton_product, "crear", e)
)
continue
try:
if self._need_update(product, tryton_product):
changes = self._describe_changes(
product, tryton_product
)
self._update_product(product, tryton_product)
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
updated_products.append(
{**self._build_item(product), "detail": changes}
)
else:
self._sync_product_categories(
product, tryton_product, categories_by_tryton_id
)
untouched_products.append(self._build_item(product))
except Exception as e:
print(
f"Error al importar productos: {e}El producto: {tryton_product}"
)
failed_products.append(
self._build_failure(tryton_product, "actualizar", e)
)
return {
"checked_tryton_products": checked_tryton_products,
"failed_products": failed_products,
"updated_products": updated_products,
"created_products": created_products,
"untouched_products": untouched_products,
"created_categories": created_categories,
"updated_categories": updated_categories,
}
def _import_categories(self, tryton_products, context):
"""Importa las categorías de Tryton usadas por los productos
sincronizados. Devuelve un mapa {id_tryton: ProductCategory} y un
reporte {"created": [...], "updated": [...]}
"""
empty_report = {"created": [], "updated": []}
category_ids = {
category_id
for tryton_product in tryton_products
for category_id in self._extract_category_ids(tryton_product)
}
if not category_ids:
return {}, empty_report
categories = {}
report = {"created": [], "updated": []}
tryton_categories = self._get_category_details(
sorted(category_ids), context
)
for tryton_category in tryton_categories:
try:
external_id = str(tryton_category.get("id"))
name = tryton_category.get("name")
category = ProductCategory.objects.filter(
external_id=external_id
).first()
if category is None and name:
category = ProductCategory.objects.filter(
name=name
).first()
is_new = category is None
if is_new:
category = ProductCategory()
old_name = category.name
old_external_id = category.external_id
category.name = name
category.external_id = external_id
category.save()
categories[tryton_category.get("id")] = category
item = {
"id": category.id,
"name": category.name,
"external_id": category.external_id,
}
if is_new:
report["created"].append(item)
continue
changes = []
if old_name != category.name:
changes.append(f"Nombre: {old_name}{category.name}")
if old_external_id != category.external_id:
changes.append(
"External ID: "
f"{old_external_id}{category.external_id}"
)
if changes:
report["updated"].append(
{**item, "detail": ", ".join(changes)}
)
except Exception as e:
print(
f"Error al importar categoría: {e}"
f"La categoría: {tryton_category}"
)
return categories, report
@staticmethod
def _extract_category_ids(tryton_product):
"""Extrae los IDs de categorías de la respuesta RPC de Tryton
(los campos con puntos llegan agrupados bajo la clave 'template.')"""
template = tryton_product.get("template.") or {}
return template.get("categories") or []
def _get_category_details(self, category_ids, context):
"""Obtiene los nombres de categorías desde Tryton"""
method = "model.product.category.read"
params = (category_ids, ["id", "name"], context)
return self.client.call(method, params)
def _sync_product_categories(self, product, tryton_product, categories):
"""Vincula al producto las categorías importadas desde Tryton"""
category_ids = self._extract_category_ids(tryton_product)
product.categories.set(
[
categories[category_id]
for category_id in category_ids
if category_id in categories
]
)
def _build_item(self, product):
"""Construye el objeto de un producto sincronizado"""
return {"id": product.id, "name": product.name}
def _describe_changes(self, product, tryton_product):
"""Describe los cambios detectados entre el producto local y Tryton"""
changes = []
name = tryton_product.get("name")
if product.name != name:
changes.append(f"Nombre: {product.name}{name}")
price = tryton_product.get("list_price")
if product.price != price:
changes.append(
f"Precio: {self._format_price(product.price)}"
f"{self._format_price(price)}"
)
unit = tryton_product.get("default_uom.")
if unit:
unit_name = unit.get("rec_name")
if product.measuring_unit != unit_name:
changes.append(
f"Unidad: {product.measuring_unit}{unit_name}"
)
return ", ".join(changes)
@staticmethod
def _format_price(value):
"""Formatea un precio sin ceros decimales innecesarios"""
return f"{value:.2f}".rstrip("0").rstrip(".")
def _build_failure(self, tryton_product, operation, exc):
"""Construye el detalle de un producto que falló al sincronizar"""
error = self._friendly_error(tryton_product, str(exc))
return {
"external_id": tryton_product.get("id"),
"name": tryton_product.get("name"),
"error": f"Error al {operation} producto: {error}",
}
def _friendly_error(self, tryton_product, message):
"""Traduce causas conocidas de error a mensajes legibles"""
if "price" in message and (
"not-null constraint" in message
or "NOT NULL constraint" in message
):
return (
"El producto no tiene precio en Tryton (list_price nulo)"
)
if "name" in message and (
"duplicate key" in message or "UNIQUE constraint" in message
):
return f"Ya existe un producto con el nombre '{tryton_product.get('name')}'"
return message
def _get_product_details(self, product_ids, context):
"""Obtiene detalles de productos desde Tryton"""
tryton_fields = [
"id",
"name",
"default_uom.id",
"default_uom.rec_name",
"list_price",
"template.categories",
]
method = "model.product.product.read"
params = (product_ids, tryton_fields, context)
response = self.client.call(method, params)
return response
def _need_update(self, product, tryton_product):
"""Verifica si el producto necesita actualización"""
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
return False
def _create_product(self, tryton_product):
"""Crea un nuevo producto desde datos de Tryton"""
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):
"""Actualiza un producto existente con datos de Tryton"""
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()

View File

@@ -1,89 +0,0 @@
from ...models.sales import Sale, SaleLine, CatalogSale, CatalogSaleLine
from .client import TrytonSale, TrytonCatalogSale, TRYTON_COMPANY_ID, TRYTON_SHOPS
class SaleTrytonService:
"""Servicio para sincronización de ventas con Tryton"""
def __init__(self, tryton_client):
self.client = tryton_client
def send_to_tryton(self):
"""Envía ventas sin external_id a Tryton"""
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 = self.client.call(method, tryton_params)
sale.external_id = external_ids[0]
sale.save()
successful.append(self._build_item(sale))
except Exception as e:
print(f"Error al enviar la venta: {e}venta_id: {sale.id}")
failed.append(self._build_failure(sale, e))
continue
return {"successful": successful, "failed": failed}
def _to_tryton_params(self, sale, lines, tryton_context):
"""Convierte venta a parámetros para Tryton"""
sale_tryton = TrytonSale(sale, lines)
return [[sale_tryton.to_tryton()], tryton_context]
def send_catalog_sales_to_tryton(self):
"""Envía catalog sales sin external_id a Tryton"""
method = "model.sale.sale.create"
tryton_context = {
"company": TRYTON_COMPANY_ID,
"shops": TRYTON_SHOPS,
}
successful = []
failed = []
catalog_sales = CatalogSale.objects.filter(external_id=None)
for catalog_sale in catalog_sales:
try:
lines = CatalogSaleLine.objects.filter(catalog_sale=catalog_sale.id)
tryton_params = self._catalog_sale_to_tryton_params(
catalog_sale, lines, tryton_context
)
external_ids = self.client.call(method, tryton_params)
catalog_sale.external_id = external_ids[0]
catalog_sale.save()
successful.append(self._build_item(catalog_sale))
except Exception as e:
print(
f"Error al enviar catalog sale: {e}, catalog_sale_id: {catalog_sale.id}"
)
failed.append(self._build_failure(catalog_sale, e))
continue
return {"successful": successful, "failed": failed}
def _build_item(self, sale):
"""Construye el objeto de una venta sincronizada"""
return {"id": sale.id, "code": sale.code}
def _build_failure(self, sale, exc):
"""Construye el detalle de una venta que falló al sincronizar"""
return {
"id": sale.id,
"code": sale.code,
"error": f"Error al enviar la venta: {exc}",
}
def _catalog_sale_to_tryton_params(self, catalog_sale, lines, tryton_context):
"""Convierte catalog sale a parámetros para Tryton"""
sale_tryton = TrytonCatalogSale(catalog_sale, lines)
return [[sale_tryton.to_tryton()], tryton_context]

View File

@@ -0,0 +1,17 @@
{% load static %}
<!DOCTYPE html>
<html lang="en" class="h-full">
<head>
<title>Don Confiao - Tienda la Ilusión</title>
</head>
<body class="flex h-full w-full">
<div id="menu" class="h-full w-2/12 border bg-green-400 max-h-screen overflow-auto">
{% include 'don_confiao/menu.html' %}
</div>
<div id="content" class="w-10/12 h-screen max-h-full overflow-auto">
{% block content %} {% endblock %}
</div>
</body>
<script src="https://cdn.tailwindcss.com/"></script>
</html>

View File

@@ -0,0 +1,13 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
{% if form.is_multipart %}
<form enctype="multipart/form-data" method="post">
{% else %}
<form method="post">
{% endif %}
{% csrf_token %}
{{ form }}
<input type="submit" value="Importar">
</form>
{% endblock %}

View File

@@ -0,0 +1,13 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
{% if form.is_multipart %}
<form enctype="multipart/form-data" method="post">
{% else %}
<form method="post">
{% endif %}
{% csrf_token %}
{{ form }}
<input type="submit" value="Importar">
</form>
{% endblock %}

View File

@@ -0,0 +1,8 @@
<h1>Tienda la Ilusión</h1>
<h2>Don Confiao</h2>
<ul>
<li><a href='./comprar'>Comprar</a></li>
<li><a href='./productos'>Productos</a></li>
<li><a href='./importar_productos'>Importar Productos</a></li>
<li><a href='./importar_terceros'>Importar Terceros</a></li>
</ul>

View File

@@ -0,0 +1,17 @@
{% load static %}
<link rel="stylesheet" type="text/css" href="{% static 'css/main_menu.css' %}">
<div class="h-full flex flex-col justify-around shadow hover:shadow-lg">
<img class="w-full px-12" src="{% static 'img/recreo_logo.png' %}" alt="Recreo">
<nav id="main_menu">
<ul class="flex flex-col m-0 p-0 justify-center shadow hover:shadow-lg gap-y-12 items-center drop-shadow-lg">
<li><a href='/don_confiao/comprar' >Comprar</a></li>
<li><a href='/don_confiao/compras'>Compras</a></li>
<li><a href='/don_confiao/lista_productos'>Productos</a></li>
<li><a href='/don_confiao/importar_productos'>Importar Productos</a></li>
<li><a href='/don_confiao/importar_terceros'>Importar Terceros</a></li>
</ul>
</nav>
<p id="page_title" class="text-center decoration-solid font-mono font-bold text-lg page_title">Don Confiao - Tienda la Ilusión</p>
</div>
<script src="https://cdn.tailwindcss.com/"></script>

View File

@@ -0,0 +1,14 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
<form action="" method="get">
<label>Filtro por nombre:</label>
<input type="text" name="name" value="{{ request.GET.name }}">
<button type="submit">Filtrar</button>
</form>
<h1>Lista de productos</h1>
<ul>
{% for obj in object_list %}
<li>{{ obj.name }} ({{ obj.id }})</li>
{% endfor %}
</ul>
{% endblock %}

View File

@@ -0,0 +1,39 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
{% load static %}
<script>
let listProducts = JSON.parse("{{ list_products|escapejs }}");
</script>
<div class="flex h-full">
<div class="h-full w-10/12 flex flex-col p-5">
<form id="complete_form_purchase" method="POST" class="h-10/12 w-full max-h-full overflow-auto">
{% csrf_token %}
{{ linea_formset.management_form }}
<div id="formset-container" class="w-full">
{% for form in linea_formset %}
<div class="form-container flex justify-center ">
<table class="w-3/4 my-5 shadow-inner" style="border: solid 1px #178E79;">
{{ form.as_table }}
</table>
</div>
{% endfor %}
</div>
<div class="h-2/12 flex justify-center">
<button id="add_line" type="button" class="bg-yellow-400 shadow hover:shadow-lg py-2 px-5 rounded-full font-bold hover:bg-violet-200 ease-in duration-150">Añadir Linea</button>
</div>
</div>
<div class="h-full w-3/12 bg-green-400 p-5 shadow hover:shadow-lg flex flex-col gap-y-3 font-semibold justify-around">
<p id="sale_resume_title" class="text-center decoration-solid font-mono font-bold text-xl page_title">Resumen de Venta</p>
{{ sale_form }}
{{ summary_form }}
<button class="font-bold my-10 py-2 px-4 rounded-full bg-yellow-400 shadow hover:shadow-lg hover:bg-violet-200 ease-in duration-150" name="form" type="submit" >Comprar</button>
</div>
</form>
</div>
<script src="https://cdn.tailwindcss.com/"></script>
<script src="{% static 'js/buy_general.js' %}"></script>
<script src="{% static 'js/add_line.js' %}"></script>
<script src="{% static 'js/sale_summary.js' %}"></script>
<script src="{% static 'js/calculate_subtotal_line.js' %}"></script>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
<h1>Resumen de compra</h1>
<dl>
<dt>Date</dt> <dd>{{ purchase.date }}</dd>
<dt>ID</dt> <dd>{{ purchase.id }}</dd>
<dt>Customer</dt> <dd>{{ purchase.customer.name }}</dd>
<dt>Total</dt> <dd>{{ purchase.get_total }}</dd>
</dl>
{% endblock %}

View File

@@ -0,0 +1,14 @@
{% extends 'don_confiao/base.html' %}
{% block content %}
{% if purchases %}
<ul>
{% for purchase in purchases %}
<li><a href="/don_confiao/resumen_compra/{{ purchase.id }}">{{ purchase.date }}, {{ purchase.customer }}</a></li>
{% endfor %}
</ul>
{% else %}
<p>No hay Compras</p>
{% endif %}
{% endblock %}

View File

@@ -1,6 +1,6 @@
from django.test import TestCase
from ..models.admin import AdminCode
from ..models import AdminCode
from .Mixins import LoginMixin
import json
@@ -10,33 +10,33 @@ class TestAdminCode(TestCase, LoginMixin):
def setUp(self):
self.login()
self.valid_code = "some valid code"
self.valid_code = 'some valid code'
admin_code = AdminCode()
admin_code.value = self.valid_code
admin_code.clean()
admin_code.save()
def test_validate_code(self):
url = "/don_confiao/api/admin_code/validate/" + self.valid_code
url = '/don_confiao/api/admin_code/validate/' + self.valid_code
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode("utf-8"))
self.assertTrue(content["validCode"])
content = json.loads(response.content.decode('utf-8'))
self.assertTrue(content['validCode'])
def test_invalid_code(self):
invalid_code = "some invalid code"
url = "/don_confiao/api/admin_code/validate/" + invalid_code
invalid_code = 'some invalid code'
url = '/don_confiao/api/admin_code/validate/' + invalid_code
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode("utf-8"))
self.assertFalse(content["validCode"])
content = json.loads(response.content.decode('utf-8'))
self.assertFalse(content['validCode'])
def test_empty_code(self):
empty_code = ""
url = "/don_confiao/api/admin_code/validate/" + empty_code
empty_code = ''
url = '/don_confiao/api/admin_code/validate/' + empty_code
response = self.client.get(url)
self.assertEqual(response.status_code, 404)

View File

@@ -4,9 +4,7 @@ import io
from rest_framework import status
from rest_framework.test import APITestCase
from ..models.sales import Sale, CatalogSale
from ..models.customers import Customer
from ..models.products import Product
from ..models import Sale, Product, Customer
from .Mixins import LoginMixin
@@ -15,13 +13,18 @@ class TestAPI(APITestCase, LoginMixin):
self.login()
self.product = Product.objects.create(
name="Panela", price=5000, measuring_unit="UNIT"
name='Panela',
price=5000,
measuring_unit='UNIT'
)
self.customer = Customer.objects.create(
name='Camilo',
external_id='18'
)
self.customer = Customer.objects.create(name="Camilo", external_id="18")
def test_create_sale(self):
response = self._create_sale()
content = json.loads(response.content.decode("utf-8"))
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]
@@ -29,132 +32,79 @@ class TestAPI(APITestCase, LoginMixin):
sale.customer.name,
self.customer.name,
)
self.assertEqual(sale.id, content["id"])
self.assertEqual(
sale.id,
content['id']
)
self.assertIsNone(sale.external_id)
def test_create_catalog_sale(self):
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(CatalogSale.objects.count(), 1)
sale = CatalogSale.objects.all()[0]
self.assertEqual(sale.customer.name, self.customer.name)
self.assertEqual(sale.id, content["id"])
self.assertEqual(sale.catalogsaleline_set.count(), 2)
def test_create_sale_with_decimal(self):
response = self._create_sale_with_decimal()
content = json.loads(response.content.decode("utf-8"))
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)
self.assertEqual(sale.id, content["id"])
self.assertEqual(sale.get_total(), 16500.00)
self.assertEqual(
sale.customer.name,
self.customer.name
)
self.assertEqual(
sale.id,
content['id']
)
self.assertEqual(
sale.get_total(),
16500.00
)
def test_get_products(self):
url = "/don_confiao/api/products/"
url = '/don_confiao/api/products/'
response = self.client.get(url)
json_response = json.loads(response.content.decode("utf-8"))
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.product.name, json_response[0]["name"])
self.assertEqual(self.product.name, json_response[0]['name'])
def test_get_customers(self):
url = "/don_confiao/api/customers/"
url = '/don_confiao/api/customers/'
response = self.client.get(url)
json_response = json.loads(response.content.decode("utf-8"))
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"])
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/"
url = '/don_confiao/api/sales/'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode("utf-8"))
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"])
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"
url = '/don_confiao/api/sales/for_tryton'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode("utf-8"))
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_catalog_sale_summary(self):
# Create a catalog sale
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
# Get the summary
url = f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale_id}"
response = self.client.get(url)
# Verify response
self.assertEqual(response.status_code, status.HTTP_200_OK)
json_response = json.loads(response.content.decode("utf-8"))
# Verify structure
self.assertIn("id", json_response)
self.assertIn("date", json_response)
self.assertIn("customer", json_response)
self.assertIn("lines", json_response)
# Verify customer details
self.assertEqual(json_response["customer"]["id"], self.customer.id)
self.assertEqual(json_response["customer"]["name"], self.customer.name)
# Verify lines
self.assertEqual(len(json_response["lines"]), 2)
# Verify first line
line1 = json_response["lines"][0]
self.assertIn("product", line1)
self.assertIn("quantity", line1)
self.assertIn("unit_price", line1)
self.assertEqual(line1["product"]["id"], self.product.id)
self.assertEqual(line1["product"]["name"], self.product.name)
self.assertEqual(line1["quantity"], "2.00")
self.assertEqual(line1["unit_price"], "3000.00")
# Verify second line
line2 = json_response["lines"][1]
self.assertEqual(line2["product"]["id"], self.product.id)
self.assertEqual(line2["quantity"], "3.00")
self.assertEqual(line2["unit_price"], "5000.00")
def test_catalog_sale_has_external_id_field(self):
"""Verifica que CatalogSale tiene el campo external_id"""
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
catalog_sale = CatalogSale.objects.get(pk=catalog_sale_id)
# Debe tener el campo external_id
self.assertIsNone(catalog_sale.external_id)
# Se puede asignar un valor
catalog_sale.external_id = "123"
catalog_sale.save()
# Verificar que se guardó
catalog_sale.refresh_from_db()
self.assertEqual(catalog_sale.external_id, "123")
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"
url = '/don_confiao/api/sales/for_tryton'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode("utf-8"))
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"]))
csv_reader = csv.reader(io.StringIO(json_response['csv']))
expected_header = [
"Tercero",
"Dirección de facturación",
@@ -173,113 +123,53 @@ class TestAPI(APITestCase, LoginMixin):
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario",
"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",
"",
"",
"",
"",
"",
],
[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/"
url = '/don_confiao/api/sales/'
data = {
"customer": self.customer.id,
"date": "2024-09-02",
"payment_method": "CASH",
"saleline_set": [
{
"product": self.product.id,
"quantity": 2,
"unit_price": 3000,
},
{
"product": self.product.id,
"quantity": 3,
"unit_price": 5000,
},
'customer': self.customer.id,
'date': '2024-09-02',
'payment_method': 'CASH',
'saleline_set': [
{'product': self.product.id, 'quantity': 2, 'unit_price': 3000},
{'product': self.product.id, 'quantity': 3, 'unit_price': 5000}
],
}
return self.client.post(url, data, format="json")
def _create_catalog_sale(self):
url = "/don_confiao/api/catalog_sales/"
data = {
"customer": self.customer.id,
"date": "2024-09-02",
"catalogsaleline_set": [
{
"product": self.product.id,
"quantity": 2,
"unit_price": 3000,
},
{
"product": self.product.id,
"quantity": 3,
"unit_price": 5000,
},
],
}
return self.client.post(url, data, format="json")
return self.client.post(url, data, format='json')
def _create_sale_with_decimal(self):
url = "/don_confiao/api/sales/"
url = '/don_confiao/api/sales/'
data = {
"customer": self.customer.id,
"date": "2024-09-02",
"payment_method": "CASH",
"saleline_set": [
'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': 0.5,
'unit_price': 3000
},
{
"product": self.product.id,
"quantity": 3,
"unit_price": 5000,
},
'product': self.product.id,
'quantity': 3,
'unit_price': 5000
}
],
}
return self.client.post(url, data, format="json")
return self.client.post(url, data, format='json')

View File

@@ -0,0 +1,22 @@
from django.test import Client, TestCase
from ..models import Product
class TestBuyForm(TestCase):
def setUp(self):
self.client = Client()
self.product = Product()
self.product.name = "Arroz"
self.product.price = 5000
self.product.save()
def test_buy_contains_products_list(self):
response = self.client.get('/don_confiao/comprar')
self.assertIn(
self.product.name,
response.context['list_products']
)
content = response.content.decode('utf-8')
self.assertIn('5000', content)
self.assertIn('Arroz', content)
self.assertIn(str(self.product.id), content)

Some files were not shown because too many files have changed in this diff Show More