22 Commits

Author SHA1 Message Date
193198918b Merge pull request 'feat: add standalone script to upload catalogue images via API' (#41) from feature/upload-catalogue-script into main
Reviewed-on: #41
2026-06-14 00:46:45 -05:00
mono
1007584d3e Merge branch 'main' into feature/upload-catalogue-script 2026-06-14 00:46:21 -05:00
mono
ac22adb558 feat: add standalone script to upload catalogue images via API 2026-06-14 00:39:33 -05:00
2415ed3564 Merge pull request 'feat: add catalogue image management (don_confiao_catalog_generator/issues/1)' (#40) from feature/1-catalogue-images into main
Reviewed-on: #40
2026-06-13 20:31:40 -05:00
mono
539629076f fix: address PR review issues for catalogue images
- Use PNG format instead of JPEG for transparency support
- Non-strict mode only resizes if image width > target width
- Replace CATALOGUE_BACKGROUND_IMAGES_COLOR with CATALOGUE_BACKGROUND_IMAGES_RGBA (tuple default (0,0,0,0))
- Move all inline imports to top of test file
- Add test_resize_non_strict_no_upscale test case
- Add comment explaining settings.DEBUG guard for media serving
2026-06-13 14:57:25 -05:00
mono
7112197ad2 feat: add catalogue image management (#1)
- Add CatalogueImage model with FK to Product
- Add image resize service (strict/non-strict modes with configurable dimensions)
- Add CRUD API endpoints with admin-only write permissions
- Add catalogue_images field to product listing/detail endpoints
- Serve media files in development via static()
- 28 TDD tests covering model, API, permissions, resize, and product listing
2026-06-13 13:39:57 -05:00
7160d64e86 chore: Add fields to list_diplay CustomerAdmin 2026-06-09 15:49:54 -05:00
bb5ef7fed8 feat: Display fields for Customer model in webadmin. 2026-06-09 12:03:22 -05:00
77761ea8cc feat: Add customer information to CatalogSale 2026-06-05 09:11:52 -05:00
ff67720cea Add external_id to CatalogSaleSerializer
- Include external_id field in CatalogSaleSerializer response
- Allows API clients to see if catalog sale has been synced to Tryton
- Maintains consistency with SaleSerializer which already includes external_id
- Backward compatible change (field is nullable)
2026-05-30 21:20:07 -05:00
5e811c802a Add Tryton synchronization for CatalogSale
- Add external_id field to CatalogSale model for tracking synced sales
- Create migration 0047 for external_id field
- Add TrytonCatalogSale and TrytonCatalogSaleLine classes for Tryton RPC format
- Add send_catalog_sales_to_tryton() method to SaleTrytonService
- Create CatalogSalesToTrytonView API endpoint (POST)
- Register endpoint at /don_confiao/api/enviar_catalog_sales_a_tryton
- Add test for external_id field functionality
- Catalog sales sync to same Tryton model as Sale (model.sale.sale.create)
- Differentiated by reference 'don_confiao_catalog X' and description 'Venta de catálogo'
- Filters only catalog sales without external_id to avoid duplicates
2026-05-30 21:01:29 -05:00
d4a61b8340 Add catalog sale purchase summary endpoint
- Add CatalogSaleSummarySerializer and CatalogSummarySaleLineSerializer
- Add CatalogSaleSummary API view for GET requests
- Register endpoint at /don_confiao/resumen_compra_catalogo_json/<id>
- Add comprehensive test for catalog sale summary
- Include nested customer and product details in response
- Endpoint returns id, date, customer, and lines with products
2026-05-30 20:32:20 -05:00
52ff61354e chore: remove unused export_csv.py file
El archivo export_csv.py solo contenía el shebang y no era utilizado
en ninguna parte del proyecto. Removido durante la refactorización.
2026-05-29 00:22:47 -05:00
8196137c4c docs: add domain-driven design refactoring summary
Agrega documentación completa sobre la refactorización realizada:
- Estructura de directorios detallada
- Detalles por dominio (Products, Customers, Sales, Payments, Admin)
- Beneficios y principios aplicados
- Ejemplos de uso
- Métricas y verificaciones de calidad
2026-05-29 00:17:26 -05:00
47e87e4204 refactor: organize code by domain-driven design
Refactoriza la estructura del proyecto siguiendo principios de Domain-Driven Design,
organizando serializers, API views y servicios por dominios de negocio.

Cambios principales:

## Serializers (serializers/)
- Dividido serializers.py en módulos por dominio:
  * products.py: ProductSerializer, ListProductSerializer
  * customers.py: CustomerSerializer, ListCustomerSerializer
  * sales.py: SaleSerializer, SaleLineSerializer, CatalogSaleSerializer, etc.
  * payments.py: ReconciliationJarSerializer, PaymentMethodSerializer
  * __init__.py: Exporta todos los serializers para mantener compatibilidad

## API Views (api/)
- Dividido api_views.py en módulos por dominio:
  * products.py: ProductView, ProductsFromTrytonView
  * customers.py: CustomerView, CustomersFromTrytonView
  * sales.py: SaleView, CatalogSaleView, SaleSummary, SalesForTrytonView, SalesToTrytonView
  * payments.py: ReconciliateJarView, ReconciliateJarModelView, PaymentMethodView, SalesForReconciliationView
  * admin.py: AdminCodeValidateView
  * __init__.py: Exporta todas las vistas para facilitar importaciones

## Services Layer (services/tryton/)
- Nueva capa de servicios para lógica de negocio Tryton:
  * client.py: get_tryton_client(), TrytonSale, TrytonLineSale, configuración
  * products.py: ProductTrytonService - sincronización de productos
  * customers.py: CustomerTrytonService - sincronización de clientes
  * sales.py: SaleTrytonService - sincronización de ventas
  * __init__.py: Exporta servicios y utilidades

## Actualización de URLs
- Actualizado urls.py para importar desde nuevos módulos
- Mantiene todas las rutas existentes sin cambios

## Eliminación de archivos antiguos
- Eliminado serializers.py (refactorizado a serializers/)
- Eliminado api_views.py (refactorizado a api/)

## Beneficios
 Cohesión: Código organizado por dominio de negocio
 Separación de responsabilidades: API, Serializers y Services separados
 Mantenibilidad: Archivos más pequeños y enfocados
 Escalabilidad: Fácil agregar nuevos dominios
 Testabilidad: Mejor organización para pruebas por dominio
 Reutilización: Servicios Tryton pueden usarse desde cualquier vista

## Estructura final:
- models/ (ya existía organizado por dominio)
- serializers/ (nuevo, organizado por dominio)
- api/ (nuevo, organizado por dominio)
- services/tryton/ (nuevo, capa de servicios)

Tests: 46 tests pasando ✓
2026-05-29 00:16:18 -05:00
f526330f9e feat: add product activation/deactivation and filtering by active status
- Add 'active' boolean field to Product model with default=True
- Implement ProductView.get_queryset() to filter products by active status
  - Default behavior: return only active products
  - Support query params: ?active=true|false|all
  - Support variations: 1/0, yes/no for true/false
  - Detail operations (GET/PATCH/DELETE by ID) work with all products
- Update ProductSerializer to include 'active' field
- Add comprehensive test suite (11 new tests):
  - Test filtering by active/inactive/all products
  - Test parameter variations (1, yes, 0, no)
  - Test PATCH to activate/deactivate products
  - Test default list behavior after status changes
- Update API documentation in doc/requests.org with examples
- All tests passing (13 product tests + 8 API tests)
2026-05-29 00:01:29 -05:00
7fe336b0ce chore: Improve style code 2026-05-28 20:28:25 -05:00
dde6f7329f fix: catalog sale lines not being created due to wrong field name and missing read_only config
- Changed test payload from 'saleline_set' to 'catalogsaleline_set'
- Removed extraneous fields 'payment_method' and 'catalog_sale' from test data
- Made 'catalog_sale' read_only in CatalogSaleLineSerializer to allow nested creation
2026-05-28 18:56:40 -05:00
e658901165 chore: Add new endpoint. 2026-05-28 16:53:55 -05:00
a33eef7556 feat: Test create catalog sale 2026-05-28 16:53:26 -05:00
47c18c760d feat: add CatalogSale model with abstract base classes for Sale/SaleLine
- Introduced SaleAbstractModel and SaleLineAbstractModel as abstract bases
- Added CatalogSale and CatalogSaleLine models inheriting from them
- Created migration 0045 for new models
- Added CatalogSaleView, CatalogSaleSerializer with nested line creation
- Registered new models in admin
- Added catalog_sales router endpoint to URLs
- Removed placeholder api/ package (now redundant)
2026-05-28 16:38:45 -05:00
f97b47081c refactor: split models into modules, remove template-based views, and clean up code style
- Split monolithic models.py into models/ package (customers, products, sales, payments, admin)
- Removed forms.py, all HTML templates, and associated template-based views
- Added api/ package with CatalogSaleView placeholder
- Updated all imports across project to use new model paths
- Removed obsolete tests (form, export, purchase, summary tests)
- Removed template-based URL patterns, kept only API endpoints
- Standardized string quotes (single to double) and reformatted code
2026-05-28 15:25:27 -05:00
72 changed files with 3351 additions and 1881 deletions

View File

@@ -20,7 +20,7 @@ CORS_ALLOWED_ORIGINS=http://localhost:3000,http://localhost:7001,http://localhos
# No additional DB configuration needed for SQLite # No additional DB configuration needed for SQLite
# Tryton ERP Configuration # Tryton ERP Configuration
TRYTON_HOST=localhost TRYTON_HOST=recreo.onecluster.com.co
TRYTON_DATABASE=tryton TRYTON_DATABASE=ilusion_staging
TRYTON_USERNAME=admin TRYTON_USERNAME=alejandro.ayala
TRYTON_PASSWORD=admin TRYTON_PASSWORD=cl4v3alejo

361
REFACTORING_SUMMARY.md Normal file
View File

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

@@ -9,50 +9,52 @@ post /token/
{ {
"username": "admin", "username": "admin",
"password": "123" "password": "admin"
} }
**** respuesta **** respuesta
#+begin_src json #+begin_src json
{ {
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k", "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc4MDExNTc0NywiaWF0IjoxNzgwMDI5MzQ3LCJqdGkiOiIxNmVjZGMxZmY4Y2Y0MzA4ODM3ZjM5Y2ZiNjQwNmZiMCIsInVzZXJfaWQiOiIxIn0.wmN-wp3Izv0NrfL_ap_i8eyg29w-foHNrQCCL6HoZWg",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc" "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA"
} }
#+end_src #+end_src
*** Perfil de usuario *** Perfil de usuario
get /users/me/ get /users/me/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDA2MzU4LCJpYXQiOjE3ODAwMDQ1NTgsImp0aSI6IjkwNzY4OGU2MmNlNTQ1M2JiYzU5MTA2MDhmMjY1MmY5IiwidXNlcl9pZCI6IjEifQ.aFls9WyA1VuDeMMUu8t7Pa9CbbLfyvIg9pB9xIxydpU
**** Respuesta **** Respuesta
#+begin_src json #+begin_src json
{ {
"id": 2, "id": 1,
"username": "admin", "username": "admin",
"email": "correo@example.com", "email": "admin@admin.org",
"first_name": "", "first_name": "",
"last_name": "" "last_name": "",
"role": "administrator"
} }
#+end_src #+end_src
*** Renovar token *** Renovar token
post /token/refresh/ post /token/refresh/
{ {
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k" "refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3ODU1Njc5MywiaWF0IjoxNzc4NDcwMzkzLCJqdGkiOiJlMDU0NTVkNWExYzA0YjFkYWZhNWZkNzFkZGM5Mzc1NyIsInVzZXJfaWQiOiIxIn0.wZcbBrGoxDMPjZxI-GR1GTAuRtzU4qaT0rgGS5Oblf4"
} }
**** response **** response
#+begin_src json #+begin_src json
{ {
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA" "access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc4NDcyMjQ5LCJpYXQiOjE3Nzg0NzA0NDksImp0aSI6IjE5YTM0ZDQ5Mzk3ZDQzNGE4NDlkZTgyYzdkNWQyNjQ0IiwidXNlcl9pZCI6IjEifQ.jowmaa5SXKIWpmUGLV0dj9CydYFtuecc7s_RveJvjLA"
} }
#+end_src #+end_src
** Don confiao :verb: ** Don confiao :verb:
template http://localhost:7000/don_confiao/api/ template http://localhost:7000/don_confiao/api/
Content-Type: application/json; Content-Type: application/json;
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA
*** todas las rutas *** todas las rutas
get get
**** response **** response
#+begin_src json #+begin_src json
{ {
"sales": "http://localhost:7000/don_confiao/api/sales/", "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/", "customers": "http://localhost:7000/don_confiao/api/customers/",
"products": "http://localhost:7000/don_confiao/api/products/", "products": "http://localhost:7000/don_confiao/api/products/",
"reconciliate_jar": "http://localhost:7000/don_confiao/api/reconciliate_jar/" "reconciliate_jar": "http://localhost:7000/don_confiao/api/reconciliate_jar/"
@@ -76,3 +78,62 @@ get customers/
#+end_src #+end_src
*** products *** products
get 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

@@ -15,3 +15,4 @@ python-decouple # Manage environment variables and settings
# Static files serving in production/staging # Static files serving in production/staging
whitenoise==6.6.0 # Serve static files efficiently with compression whitenoise==6.6.0 # Serve static files efficiently with compression
Pillow # Image processing for catalogue image resizing

View File

@@ -0,0 +1,126 @@
#!/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

@@ -138,6 +138,22 @@ USE_TZ = True
STATIC_URL = "static/" STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles" 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)))
# Default primary key field type # Default primary key field type
# https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field # https://docs.djangoproject.com/en/5.0/ref/settings/#default-auto-field

View File

@@ -48,6 +48,10 @@ EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend"
STATIC_ROOT = BASE_DIR / "staticfiles" STATIC_ROOT = BASE_DIR / "staticfiles"
STATIC_URL = "/static/" STATIC_URL = "/static/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# WhiteNoise configuration for development (optional, for consistency) # WhiteNoise configuration for development (optional, for consistency)
# In development with DEBUG=True, Django serves static files automatically # In development with DEBUG=True, Django serves static files automatically
# But this ensures consistent behavior across all environments # But this ensures consistent behavior across all environments

View File

@@ -14,6 +14,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path 1. Import the include() function: from django.urls import include, path
2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
""" """
from django.conf import settings
from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.urls import include, path from django.urls import include, path
from rest_framework_simplejwt.views import ( from rest_framework_simplejwt.views import (
@@ -32,3 +34,8 @@ urlpatterns = [
name='token_refresh'), name='token_refresh'),
path('api/users/', include('users.urls')), path('api/users/', include('users.urls')),
] ]
# Serve media files through Django only in development.
# In production/staging, media is served by the web server (nginx).
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

View File

@@ -1,11 +1,33 @@
from django.contrib import admin from django.contrib import admin
from .models import ( from .models.sales import Sale, SaleLine
Customer, Sale, SaleLine, Product, ProductCategory, Payment, from .models.customers import Customer
ReconciliationJar) from .models.sales import (
Sale,
SaleLine,
CatalogSale,
CatalogSaleLine,
Payment,
)
from .models.products import Product, ProductCategory
from .models.payments import ReconciliationJar
@admin.register(Customer)
class CustomerAdmin(admin.ModelAdmin):
list_display = (
"name",
"email",
"phone",
"external_id",
"address_external_id",
)
search_fields = ("name", "email", "phone")
admin.site.register(Customer)
admin.site.register(Sale) admin.site.register(Sale)
admin.site.register(SaleLine) admin.site.register(SaleLine)
admin.site.register(CatalogSale)
admin.site.register(CatalogSaleLine)
admin.site.register(Product) admin.site.register(Product)
admin.site.register(ProductCategory) admin.site.register(ProductCategory)
admin.site.register(Payment) admin.site.register(Payment)

View File

@@ -0,0 +1,47 @@
from .catalogue_images import CatalogueImageViewSet
from .products import ProductView, ProductsFromTrytonView
from .customers import CustomerView, CustomersFromTrytonView
from .sales import (
SaleView,
CatalogSaleView,
SaleSummary,
CatalogSaleSummary,
SalesForTrytonView,
SalesToTrytonView,
CatalogSalesToTrytonView,
)
from .payments import (
ReconciliateJarView,
ReconciliateJarModelView,
PaymentMethodView,
SalesForReconciliationView,
Pagination,
)
from .admin import AdminCodeValidateView
__all__ = [
# Catalogue Images
"CatalogueImageViewSet",
# Products
"ProductView",
"ProductsFromTrytonView",
# Customers
"CustomerView",
"CustomersFromTrytonView",
# Sales
"SaleView",
"CatalogSaleView",
"SaleSummary",
"CatalogSaleSummary",
"SalesForTrytonView",
"SalesToTrytonView",
"CatalogSalesToTrytonView",
# Payments
"ReconciliateJarView",
"ReconciliateJarModelView",
"PaymentMethodView",
"SalesForReconciliationView",
"Pagination",
# Admin
"AdminCodeValidateView",
]

View File

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

@@ -0,0 +1,25 @@
from rest_framework import viewsets
from rest_framework.permissions import 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 [IsAuthenticated()]
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

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

@@ -0,0 +1,108 @@
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 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):
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

@@ -0,0 +1,53 @@
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.products import Product
from ..serializers import ProductSerializer
from ..permissions import IsAdministrator
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_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

@@ -0,0 +1,112 @@
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.permissions import IsAuthenticated
import io
import csv
from ..models.sales import Sale, SaleLine, CatalogSale
from ..models.customers import Customer
from ..models.products import Product
from ..serializers import (
SaleSerializer,
CatalogSaleSerializer,
SaleSummarySerializer,
CatalogSaleSummarySerializer,
)
from ..permissions import IsAdministrator
from ..services.tryton.sales import SaleTrytonService
from ..services.tryton.client import get_tryton_client
from ..views import sales_to_tryton_csv
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 CatalogSaleView(viewsets.ModelViewSet):
queryset = CatalogSale.objects.all()
serializer_class = CatalogSaleSerializer
class SaleSummary(APIView):
def get(self, request, id):
sale = Sale.objects.get(pk=id)
serializer = SaleSummarySerializer(sale)
return Response(serializer.data)
class CatalogSaleSummary(APIView):
def get(self, request, id):
catalog_sale = CatalogSale.objects.get(pk=id)
serializer = CatalogSaleSummarySerializer(catalog_sale)
return Response(serializer.data)
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,457 +0,0 @@
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

@@ -1 +0,0 @@
#!/usr/bin/env python3

View File

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

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

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

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

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

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

@@ -0,0 +1,24 @@
# 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,224 +0,0 @@
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,
"external_id": product.external_id,
"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

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

View File

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

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

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

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

@@ -0,0 +1,143 @@
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
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)
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,103 +0,0 @@
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', 'external_id']
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

@@ -0,0 +1,42 @@
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,
)
__all__ = [
# Catalogue Images
"CatalogueImageSerializer",
# Products
"ProductSerializer",
"ListProductSerializer",
# Customers
"CustomerSerializer",
"ListCustomerSerializer",
# Sales
"SaleSerializer",
"SaleLineSerializer",
"CatalogSaleSerializer",
"CatalogSaleLineSerializer",
"SummarySaleLineSerializer",
"SaleSummarySerializer",
"CatalogSummarySaleLineSerializer",
"CatalogSaleSummarySerializer",
"SaleForRenconciliationSerializer",
# Payments
"ReconciliationJarSerializer",
"PaymentMethodSerializer",
]

View File

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

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

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

@@ -0,0 +1,46 @@
from rest_framework import serializers
from ..models.products import Product, ProductCategory
class ProductSerializer(serializers.ModelSerializer):
catalogue_images = serializers.SerializerMethodField()
class Meta:
model = Product
fields = [
"id",
"name",
"active",
"price",
"measuring_unit",
"categories",
"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

@@ -0,0 +1,132 @@
from rest_framework import serializers
from ..models.sales import (
Sale,
SaleLine,
CatalogSale,
CatalogSaleLine,
Payment,
)
from .products import ListProductSerializer
from .customers import ListCustomerSerializer
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 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",
"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(serializers.ModelSerializer):
customer = ListCustomerSerializer()
lines = SummarySaleLineSerializer(many=True, source="saleline_set")
class Meta:
model = Sale
fields = ["id", "date", "customer", "payment_method", "lines"]
class CatalogSummarySaleLineSerializer(serializers.ModelSerializer):
product = ListProductSerializer()
class Meta:
model = CatalogSaleLine
fields = ["product", "quantity", "unit_price", "description"]
class CatalogSaleSummarySerializer(serializers.ModelSerializer):
customer = ListCustomerSerializer()
lines = CatalogSummarySaleLineSerializer(
many=True, source="catalogsaleline_set"
)
class Meta:
model = CatalogSale
fields = ["id", "date", "customer", "lines"]
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

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

View File

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

@@ -0,0 +1,132 @@
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.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:
"""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.catalog_sale.description or "",
"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,
}
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

@@ -0,0 +1,81 @@
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 = 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 {
"checked_tryton_parties": checked_tryton_parties,
"failed_parties": failed_parties,
"updated_customers": updated_customers,
"created_customers": created_customers,
"untouched_customers": untouched_customers,
}
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

@@ -0,0 +1,104 @@
from ...models.products import Product
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)
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}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 {
"checked_tryton_products": checked_tryton_products,
"failed_products": failed_products,
"updated_products": updated_products,
"created_products": created_products,
"untouched_products": untouched_products,
}
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",
]
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

@@ -0,0 +1,77 @@
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(sale.id)
except Exception as e:
print(f"Error al enviar la venta: {e}venta_id: {sale.id}")
failed.append(sale.id)
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(catalog_sale.id)
except Exception as e:
print(
f"Error al enviar catalog sale: {e}, catalog_sale_id: {catalog_sale.id}"
)
failed.append(catalog_sale.id)
continue
return {"successful": successful, "failed": failed}
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

@@ -1,17 +0,0 @@
{% 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

@@ -1,13 +0,0 @@
{% 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

@@ -1,13 +0,0 @@
{% 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

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

@@ -1,17 +0,0 @@
{% 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

@@ -1,14 +0,0 @@
{% 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

@@ -1,39 +0,0 @@
{% 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

@@ -1,12 +0,0 @@
{% 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

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

View File

@@ -4,7 +4,9 @@ import io
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from ..models import Sale, Product, Customer from ..models.sales import Sale, CatalogSale
from ..models.customers import Customer
from ..models.products import Product
from .Mixins import LoginMixin from .Mixins import LoginMixin
@@ -13,18 +15,13 @@ class TestAPI(APITestCase, LoginMixin):
self.login() self.login()
self.product = Product.objects.create( self.product = Product.objects.create(
name='Panela', name="Panela", price=5000, measuring_unit="UNIT"
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): def test_create_sale(self):
response = self._create_sale() 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(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1) self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0] sale = Sale.objects.all()[0]
@@ -32,79 +29,132 @@ class TestAPI(APITestCase, LoginMixin):
sale.customer.name, sale.customer.name,
self.customer.name, self.customer.name,
) )
self.assertEqual( self.assertEqual(sale.id, content["id"])
sale.id,
content['id']
)
self.assertIsNone(sale.external_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): def test_create_sale_with_decimal(self):
response = self._create_sale_with_decimal() 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(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1) self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0] sale = Sale.objects.all()[0]
self.assertEqual( self.assertEqual(sale.customer.name, self.customer.name)
sale.customer.name, self.assertEqual(sale.id, content["id"])
self.customer.name self.assertEqual(sale.get_total(), 16500.00)
)
self.assertEqual(
sale.id,
content['id']
)
self.assertEqual(
sale.get_total(),
16500.00
)
def test_get_products(self): def test_get_products(self):
url = '/don_confiao/api/products/' url = "/don_confiao/api/products/"
response = self.client.get(url) 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(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): def test_get_customers(self):
url = '/don_confiao/api/customers/' url = "/don_confiao/api/customers/"
response = self.client.get(url) 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(response.status_code, 200)
self.assertEqual(self.customer.name, json_response[0]['name']) self.assertEqual(self.customer.name, json_response[0]["name"])
self.assertEqual( self.assertEqual(self.customer.external_id, json_response[0]["external_id"])
self.customer.external_id,
json_response[0]['external_id']
)
def test_get_sales(self): def test_get_sales(self):
url = '/don_confiao/api/sales/' url = "/don_confiao/api/sales/"
self._create_sale() self._create_sale()
response = self.client.get(url) 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(response.status_code, 200)
self.assertEqual(self.customer.id, json_response[0]['customer']) self.assertEqual(self.customer.id, json_response[0]["customer"])
self.assertEqual( self.assertEqual(None, json_response[0]["external_id"])
None,
json_response[0]['external_id']
)
def test_get_sales_for_tryton(self): def test_get_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton' url = "/don_confiao/api/sales/for_tryton"
self._create_sale() self._create_sale()
response = self.client.get(url) 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.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('csv', json_response) self.assertIn("csv", json_response)
self.assertGreater(len(json_response['csv']), 0) 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")
def test_csv_structure_in_sales_for_tryton(self): 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() self._create_sale()
response = self.client.get(url) 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.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 = [ expected_header = [
"Tercero", "Tercero",
"Dirección de facturación", "Dirección de facturación",
@@ -123,53 +173,113 @@ class TestAPI(APITestCase, LoginMixin):
"Tienda", "Tienda",
"Terminal de venta", "Terminal de venta",
"Autorecogida", "Autorecogida",
"Comentario" "Comentario",
] ]
self.assertEqual(next(csv_reader), expected_header) self.assertEqual(next(csv_reader), expected_header)
expected_rows = [ expected_rows = [
[self.customer.name, self.customer.name, self.customer.name, "", [
"", "2024-09-02", "Contado", "Almacén", self.customer.name,
"Peso colombiano", self.product.name, "2.00", "3000.00", "Unidad", self.customer.name,
"TIENDA LA ILUSIÓN", "Tienda La Ilusion", "La Ilusion", "True", "" self.customer.name,
], "",
["", "", "", "", "", "", "", "", "", self.product.name, "3.00", "",
"5000.00", "Unidad", "", "", "", "", "" "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) rows = list(csv_reader)
self.assertEqual(rows, expected_rows) self.assertEqual(rows, expected_rows)
def _create_sale(self): def _create_sale(self):
url = '/don_confiao/api/sales/' url = "/don_confiao/api/sales/"
data = { data = {
'customer': self.customer.id, "customer": self.customer.id,
'date': '2024-09-02', "date": "2024-09-02",
'payment_method': 'CASH', "payment_method": "CASH",
'saleline_set': [ "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_sale_with_decimal(self):
url = '/don_confiao/api/sales/'
data = {
'customer': self.customer.id,
'date': '2024-09-02',
'payment_method': 'CASH',
'saleline_set': [
{ {
'product': self.product.id, "product": self.product.id,
'quantity': 0.5, "quantity": 2,
'unit_price': 3000 "unit_price": 3000,
}, },
{ {
'product': self.product.id, "product": self.product.id,
'quantity': 3, "quantity": 3,
'unit_price': 5000 "unit_price": 5000,
} },
], ],
} }
return self.client.post(url, data, format='json') 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")
def _create_sale_with_decimal(self):
url = "/don_confiao/api/sales/"
data = {
"customer": self.customer.id,
"date": "2024-09-02",
"payment_method": "CASH",
"saleline_set": [
{
"product": self.product.id,
"quantity": 0.5,
"unit_price": 3000,
},
{
"product": self.product.id,
"quantity": 3,
"unit_price": 5000,
},
],
}
return self.client.post(url, data, format="json")

View File

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

View File

@@ -0,0 +1,408 @@
import io
from django.conf import settings
from django.contrib.auth.models import User
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings
from PIL import Image
from rest_framework import status
from rest_framework.test import APIClient, APITestCase
from rest_framework_simplejwt.tokens import RefreshToken
from ..models.catalogue_images import CatalogueImage
from ..models.products import Product
from .Mixins import LoginMixin
def _create_test_image(width=600, height=400, name="test.png"):
"""Create a SimpleUploadedFile from an in-memory image using PIL."""
img = Image.new("RGBA", (width, height), (255, 0, 0, 255))
output = io.BytesIO()
img.save(output, format="PNG")
return SimpleUploadedFile(
name=name,
content=output.getvalue(),
content_type="image/png",
)
class TestCatalogueImageModel(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Test Product", price=100.00
)
def test_create_catalogue_image(self):
image_file = _create_test_image()
catalogue_image = CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertIsInstance(catalogue_image, CatalogueImage)
self.assertEqual(catalogue_image.product, self.product)
self.assertIsNotNone(catalogue_image.image)
self.assertIsNotNone(catalogue_image.uploaded_at)
def test_catalogue_image_product_relation(self):
image_file = _create_test_image()
ci = CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertIn(ci, self.product.catalogue_images.all())
def test_catalogue_image_str(self):
image_file = _create_test_image()
ci = CatalogueImage.objects.create(
product=self.product, image=image_file
)
expected = f"CatalogueImage for {self.product.name} ({ci.id})"
self.assertEqual(str(ci), expected)
def test_cascade_delete_with_product(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
self.assertEqual(CatalogueImage.objects.count(), 1)
self.product.delete()
self.assertEqual(CatalogueImage.objects.count(), 0)
class TestCatalogueImageAPIPermissions(APITestCase, LoginMixin):
def setUp(self):
self.product = Product.objects.create(
name="Perm Test Product", price=100.00
)
def test_create_catalogue_image_unauthenticated(self):
url = "/don_confiao/api/catalogue_images/"
image_file = _create_test_image()
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED)
def test_create_catalogue_image_non_admin(self):
self.user = User.objects.create_user(
username="regularuser",
email="regular@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(self.user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
url = "/don_confiao/api/catalogue_images/"
image_file = _create_test_image()
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
def test_get_catalogue_images_authenticated_non_admin(self):
self.user = User.objects.create_user(
username="regularuser2",
email="regular2@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(self.user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_catalogue_image_non_admin(self):
admin_user = User.objects.create_superuser(
username="admin2", email="admin2@example.com", password="adminpass"
)
admin_refresh = RefreshToken.for_user(admin_user)
admin_client = APIClient()
admin_client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(admin_refresh.access_token)}"
)
image_file = _create_test_image()
create_data = {
"product": self.product.id, "image": image_file
}
create_response = admin_client.post(
"/don_confiao/api/catalogue_images/", create_data, format="multipart"
)
ci_id = create_response.json()["id"]
regular_user = User.objects.create_user(
username="regularuser3",
email="regular3@example.com",
password="regularpass",
)
refresh = RefreshToken.for_user(regular_user)
self.client = APIClient()
self.client.credentials(
HTTP_AUTHORIZATION=f"Bearer {str(refresh.access_token)}"
)
response = self.client.delete(
f"/don_confiao/api/catalogue_images/{ci_id}/"
)
self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN)
self.assertEqual(CatalogueImage.objects.count(), 1)
class TestCatalogueImageAPI(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="API Test Product", price=100.00
)
self.product2 = Product.objects.create(
name="API Test Product 2", price=200.00
)
def _create_image(self, product=None, width=600, height=400):
if product is None:
product = self.product
image_file = _create_test_image(width=width, height=height)
url = "/don_confiao/api/catalogue_images/"
data = {"product": product.id, "image": image_file}
return self.client.post(url, data, format="multipart")
def test_list_catalogue_images_empty(self):
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json(), [])
def test_create_catalogue_image(self):
response = self._create_image()
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
data = response.json()
self.assertIn("id", data)
self.assertEqual(data["product"], self.product.id)
self.assertIn("image", data)
self.assertIn("uploaded_at", data)
def test_list_catalogue_images(self):
self._create_image()
self._create_image(product=self.product2)
url = "/don_confiao/api/catalogue_images/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(len(response.json()), 2)
def test_retrieve_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["id"], ci_id)
self.assertEqual(response.json()["product"], self.product.id)
def test_update_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
new_image = _create_test_image(width=200, height=200, name="updated.png")
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.put(
url,
{"product": self.product.id, "image": new_image},
format="multipart",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
def test_delete_catalogue_image(self):
create_response = self._create_image()
ci_id = create_response.json()["id"]
url = f"/don_confiao/api/catalogue_images/{ci_id}/"
response = self.client.delete(url)
self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT)
self.assertEqual(CatalogueImage.objects.count(), 0)
def test_create_catalogue_image_invalid_product(self):
image_file = _create_test_image()
url = "/don_confiao/api/catalogue_images/"
data = {"product": 99999, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
def test_create_catalogue_image_missing_file(self):
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestCatalogueImageResize(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Resize Test Product", price=100.00
)
def _create_and_get_image_obj(self, width, height, strict=False,
bg_color=(0, 0, 0, 0)):
settings_overrides = {
"CATALOGUE_IMAGE_WIDTH": 600,
"CATALOGUE_IMAGE_HEIGHT": 600,
"CATALOGUE_STRICT_DIMENSION": strict,
"CATALOGUE_BACKGROUND_IMAGES_RGBA": bg_color,
}
with override_settings(**settings_overrides):
image_file = _create_test_image(width=width, height=height)
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
ci_id = response.json()["id"]
return CatalogueImage.objects.get(id=ci_id)
def test_resize_non_strict_mode_landscape(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
expected_height = int(600 * 600 / 800)
self.assertEqual(img.height, expected_height)
def test_resize_non_strict_mode_portrait(self):
ci = self._create_and_get_image_obj(
width=600, height=800, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
expected_height = int(800 * 600 / 600)
self.assertEqual(img.height, expected_height)
def test_resize_non_strict_mode_square(self):
ci = self._create_and_get_image_obj(
width=600, height=600, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_non_strict_no_upscale(self):
ci = self._create_and_get_image_obj(
width=400, height=300, strict=False
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 400)
self.assertEqual(img.height, 300)
def test_resize_strict_mode_landscape(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=True
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_strict_mode_portrait(self):
ci = self._create_and_get_image_obj(
width=600, height=800, strict=True
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
def test_resize_strict_mode_with_color_background(self):
ci = self._create_and_get_image_obj(
width=800, height=600, strict=True, bg_color=(255, 255, 255, 255)
)
with Image.open(ci.image.path) as img:
self.assertEqual(img.width, 600)
self.assertEqual(img.height, 600)
@override_settings(CATALOGUE_MAX_UPLOAD_SIZE=1)
def test_resize_large_file_rejected(self):
image_file = _create_test_image(width=10, height=10)
url = "/don_confiao/api/catalogue_images/"
data = {"product": self.product.id, "image": image_file}
response = self.client.post(url, data, format="multipart")
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
class TestProductListingWithImages(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Product With Images", price=100.00
)
self.product_no_images = Product.objects.create(
name="Product Without Images", price=200.00
)
def test_product_list_includes_catalogue_images_field(self):
url = "/don_confiao/api/products/"
response = self.client.get(url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for product in response.json():
self.assertIn("catalogue_images", product)
def test_product_list_catalogue_images_urls(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product.id
)
self.assertEqual(len(product_data["catalogue_images"]), 1)
self.assertTrue(
product_data["catalogue_images"][0].endswith(".png")
)
def test_product_list_catalogue_images_multiple(self):
img1 = _create_test_image(name="img1.png")
img2 = _create_test_image(name="img2.png")
CatalogueImage.objects.create(product=self.product, image=img1)
CatalogueImage.objects.create(product=self.product, image=img2)
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product.id
)
self.assertEqual(len(product_data["catalogue_images"]), 2)
def test_product_list_catalogue_images_empty(self):
url = "/don_confiao/api/products/"
response = self.client.get(url)
data = response.json()
product_data = next(
p for p in data if p["id"] == self.product_no_images.id
)
self.assertEqual(product_data["catalogue_images"], [])
def test_product_detail_includes_catalogue_images(self):
image_file = _create_test_image()
CatalogueImage.objects.create(
product=self.product, image=image_file
)
url = f"/don_confiao/api/products/{self.product.id}/"
response = self.client.get(url)
data = response.json()
self.assertIn("catalogue_images", data)
self.assertEqual(len(data["catalogue_images"]), 1)

View File

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

View File

@@ -1,50 +0,0 @@
#!/usr/bin/env python3
from django.test import Client, TestCase
from io import StringIO
import csv
class TestExportSales(TestCase):
fixtures = ['sales_fixture']
def setUp(self):
self.client = Client()
def test_export_sales(self):
sales_response = self._export_sales_csv()
filename = sales_response.headers[
'Content-Disposition'].split('; ')[1].strip('filename=').strip("'")
content = sales_response.content
content_str = content.decode('utf-8')
csv_file = StringIO(content_str)
header = next(csv.reader(csv_file))
self.assertGreater(len(content), 0)
self.assertEqual(filename, 'sales.csv')
self.assertEqual(sales_response.headers['Content-Type'], 'text/csv')
self.assertEqual(header, self._tryton_sale_header())
def _export_sales_csv(self):
return self.client.get("/don_confiao/exportar_ventas_para_tryton")
def _tryton_sale_header(self):
return [
"Tercero",
"Dirección de facturación",
"Dirección de envío",
"Descripción",
"Referencia",
"Fecha venta",
"Plazo de pago",
"Almacén",
"Moneda",
"Líneas/Producto",
"Líneas/Cantidad",
"Líneas/Precio unitario",
"Líneas/Unidad",
"Empresa",
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario"
]

View File

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

View File

@@ -1,6 +1,12 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from ..models import Sale, Product, SaleLine, Customer, ReconciliationJar from ..models.sales import (
Sale,
Product,
SaleLine,
Customer,
ReconciliationJar,
)
from .Mixins import LoginMixin from .Mixins import LoginMixin
import json import json
@@ -11,13 +17,13 @@ class TestJarReconcliation(TestCase, LoginMixin):
self.login() self.login()
customer = Customer() customer = Customer()
customer.name = 'Alejo Mono' customer.name = "Alejo Mono"
customer.save() customer.save()
purchase = Sale() purchase = Sale()
purchase.customer = customer purchase.customer = customer
purchase.date = "2024-07-30" purchase.date = "2024-07-30"
purchase.payment_method = 'CASH' purchase.payment_method = "CASH"
purchase.clean() purchase.clean()
purchase.save() purchase.save()
@@ -37,7 +43,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase2 = Sale() purchase2 = Sale()
purchase2.customer = customer purchase2.customer = customer
purchase2.date = "2024-07-30" purchase2.date = "2024-07-30"
purchase.payment_method = 'CASH' purchase.payment_method = "CASH"
purchase2.clean() purchase2.clean()
purchase2.save() purchase2.save()
@@ -52,7 +58,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase3 = Sale() purchase3 = Sale()
purchase3.customer = customer purchase3.customer = customer
purchase3.date = "2024-07-30" purchase3.date = "2024-07-30"
purchase3.payment_method = 'CASH' purchase3.payment_method = "CASH"
purchase3.clean() purchase3.clean()
purchase3.save() purchase3.save()
@@ -67,7 +73,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase4 = Sale() purchase4 = Sale()
purchase4.customer = customer purchase4.customer = customer
purchase4.date = "2024-07-30" purchase4.date = "2024-07-30"
purchase4.payment_method = 'CONFIAR' purchase4.payment_method = "CONFIAR"
purchase4.clean() purchase4.clean()
purchase4.save() purchase4.save()
@@ -90,19 +96,19 @@ class TestJarReconcliation(TestCase, LoginMixin):
self.purchase3.clean() self.purchase3.clean()
self.purchase3.save() self.purchase3.save()
url = '/don_confiao/purchases/for_reconciliation' url = "/don_confiao/purchases/for_reconciliation"
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
rawContent = response.content.decode('utf-8') rawContent = response.content.decode("utf-8")
content = json.loads(rawContent) content = json.loads(rawContent)
self.assertIn('CASH', content.keys()) self.assertIn("CASH", content.keys())
self.assertIn('CONFIAR', content.keys()) self.assertIn("CONFIAR", content.keys())
self.assertEqual(2, len(content.get('CASH'))) self.assertEqual(2, len(content.get("CASH")))
self.assertEqual(1, len(content.get('CONFIAR'))) self.assertEqual(1, len(content.get("CONFIAR")))
self.assertNotIn(str(37*72500), rawContent) self.assertNotIn(str(37 * 72500), rawContent)
self.assertIn(str(47*72500), rawContent) self.assertIn(str(47 * 72500), rawContent)
def test_don_create_reconcialiation_with_bad_numbers(self): def test_don_create_reconcialiation_with_bad_numbers(self):
reconciliation = ReconciliationJar() reconciliation = ReconciliationJar()
@@ -114,131 +120,137 @@ class TestJarReconcliation(TestCase, LoginMixin):
reconciliation.clean() reconciliation.clean()
reconciliation.save() reconciliation.save()
def test_fail_create_reconciliation_with_wrong_total_purchases_purchases(self): def test_fail_create_reconciliation_with_wrong_total_purchases_purchases(
url = '/don_confiao/reconciliate_jar' self,
):
url = "/don_confiao/reconciliate_jar"
total_purchases = (11 * 72500) + (27 * 72500) total_purchases = (11 * 72500) + (27 * 72500)
bad_total_purchases = total_purchases + 2 bad_total_purchases = total_purchases + 2
data = { data = {
'date_time': '2024-12-02T21:07', "date_time": "2024-12-02T21:07",
'reconcilier': 'carlos', "reconcilier": "carlos",
'total_cash_purchases': bad_total_purchases, "total_cash_purchases": bad_total_purchases,
'cash_taken': total_purchases, "cash_taken": total_purchases,
'cash_discrepancy': 0, "cash_discrepancy": 0,
'cash_purchases': [ "cash_purchases": [
self.purchase.id, self.purchase.id,
self.purchase2.id, self.purchase2.id,
self.purchase.id, self.purchase.id,
], ],
} }
response = self.client.post(url, data=json.dumps(data).encode('utf-8'), response = self.client.post(
content_type='application/json') url,
rawContent = response.content.decode('utf-8') data=json.dumps(data).encode("utf-8"),
content_type="application/json",
)
rawContent = response.content.decode("utf-8")
content = json.loads(rawContent) content = json.loads(rawContent)
self.assertEqual(response.status_code, 400) self.assertEqual(response.status_code, 400)
self.assertIn('error', content) self.assertIn("error", content)
self.assertIn('total_cash_purchases', content['error']) self.assertIn("total_cash_purchases", content["error"])
def test_create_reconciliation_with_purchases(self): def test_create_reconciliation_with_purchases(self):
response = self._create_reconciliation_with_purchase() response = self._create_reconciliation_with_purchase()
rawContent = response.content.decode('utf-8') rawContent = response.content.decode("utf-8")
content = json.loads(rawContent) content = json.loads(rawContent)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn('id', content) self.assertIn("id", content)
purchases = Sale.objects.filter(reconciliation_id=content['id']) purchases = Sale.objects.filter(reconciliation_id=content["id"])
self.assertEqual(len(purchases), 2) self.assertEqual(len(purchases), 2)
def test_create_reconciliation_with_purchases_and_other_totals(self): def test_create_reconciliation_with_purchases_and_other_totals(self):
url = '/don_confiao/reconciliate_jar' url = "/don_confiao/reconciliate_jar"
total_purchases = (11 * 72500) + (27 * 72500) total_purchases = (11 * 72500) + (27 * 72500)
data = { data = {
'date_time': '2024-12-02T21:07', "date_time": "2024-12-02T21:07",
'reconcilier': 'carlos', "reconcilier": "carlos",
'total_cash_purchases': total_purchases, "total_cash_purchases": total_purchases,
'cash_taken': total_purchases, "cash_taken": total_purchases,
'cash_discrepancy': 0, "cash_discrepancy": 0,
'cash_purchases': [ "cash_purchases": [
self.purchase.id, self.purchase.id,
self.purchase2.id, self.purchase2.id,
], ],
'other_totals': { "other_totals": {
'Confiar': { "Confiar": {
'total': (47 * 72500) + 1, "total": (47 * 72500) + 1,
'purchases': [self.purchase4.id], "purchases": [self.purchase4.id],
}, },
}, },
} }
response = self.client.post(url, data=json.dumps(data).encode('utf-8'), response = self.client.post(
content_type='application/json') url,
data=json.dumps(data).encode("utf-8"),
content_type="application/json",
)
rawContent = response.content.decode('utf-8') rawContent = response.content.decode("utf-8")
content = json.loads(rawContent) content = json.loads(rawContent)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertIn('id', content) self.assertIn("id", content)
purchases = Sale.objects.filter(reconciliation_id=content['id']) purchases = Sale.objects.filter(reconciliation_id=content["id"])
self.assertEqual(len(purchases), 3) self.assertEqual(len(purchases), 3)
def test_list_reconciliations(self): def test_list_reconciliations(self):
self._create_simple_reconciliation() self._create_simple_reconciliation()
self._create_simple_reconciliation() self._create_simple_reconciliation()
url = '/don_confiao/api/reconciliate_jar/' url = "/don_confiao/api/reconciliate_jar/"
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8')) content = json.loads(response.content.decode("utf-8"))
self.assertEqual(2, content['count']) self.assertEqual(2, content["count"])
self.assertEqual(2, len(content['results'])) self.assertEqual(2, len(content["results"]))
self.assertEqual('2024-07-30T00:00:00Z', self.assertEqual(
content['results'][0]['date_time']) "2024-07-30T00:00:00Z", content["results"][0]["date_time"]
)
def test_list_reconciliations_pagination(self): def test_list_reconciliations_pagination(self):
self._create_simple_reconciliation() self._create_simple_reconciliation()
self._create_simple_reconciliation() self._create_simple_reconciliation()
url = '/don_confiao/api/reconciliate_jar/?page=2&page_size=1' url = "/don_confiao/api/reconciliate_jar/?page=2&page_size=1"
response = self.client.get(url) response = self.client.get(url)
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8')) content = json.loads(response.content.decode("utf-8"))
self.assertEqual(1, len(content['results'])) self.assertEqual(1, len(content["results"]))
self.assertEqual('2024-07-30T00:00:00Z', self.assertEqual(
content['results'][0]['date_time']) "2024-07-30T00:00:00Z", content["results"][0]["date_time"]
)
def test_get_single_reconciliation(self): def test_get_single_reconciliation(self):
createResponse = self._create_reconciliation_with_purchase() createResponse = self._create_reconciliation_with_purchase()
reconciliationId = json.loads( reconciliationId = json.loads(
createResponse.content.decode('utf-8') createResponse.content.decode("utf-8")
)['id'] )["id"]
self.assertGreater(reconciliationId, 0) self.assertGreater(reconciliationId, 0)
url = f'/don_confiao/api/reconciliate_jar/{reconciliationId}/' url = f"/don_confiao/api/reconciliate_jar/{reconciliationId}/"
response = self.client.get(url, content_type='application/json') response = self.client.get(url, content_type="application/json")
content = json.loads( content = json.loads(response.content.decode("utf-8"))
response.content.decode('utf-8') self.assertEqual(reconciliationId, content["id"])
) self.assertGreater(len(content["Sales"]), 0)
self.assertEqual(reconciliationId, content['id'])
self.assertGreater(len(content['Sales']), 0)
self.assertIn( self.assertIn(
self.purchase.id, self.purchase.id, [sale["id"] for sale in content["Sales"]]
[sale['id'] for sale in content['Sales']]
) )
self.assertIn( self.assertIn(
'CASH', "CASH", [sale["payment_method"] for sale in content["Sales"]]
[sale['payment_method'] for sale in content['Sales']]
) )
def test_create_reconciliation_with_decimal_on_sale_lines(self): def test_create_reconciliation_with_decimal_on_sale_lines(self):
customer = Customer() customer = Customer()
customer.name = 'Consumidor final' customer.name = "Consumidor final"
customer.save() customer.save()
product = Product() product = Product()
@@ -249,7 +261,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase = Sale() purchase = Sale()
purchase.customer = customer purchase.customer = customer
purchase.date = "2024-07-30" purchase.date = "2024-07-30"
purchase.payment_method = 'CASH' purchase.payment_method = "CASH"
purchase.clean() purchase.clean()
purchase.save() purchase.save()
@@ -260,23 +272,23 @@ class TestJarReconcliation(TestCase, LoginMixin):
line.unit_price = "57.50" line.unit_price = "57.50"
line.save() line.save()
url = '/don_confiao/reconciliate_jar' url = "/don_confiao/reconciliate_jar"
total_purchases = 13.80 total_purchases = 13.80
data = { data = {
'date_time': '2024-12-02T21:07', "date_time": "2024-12-02T21:07",
'reconcilier': 'carlos', "reconcilier": "carlos",
'total_cash_purchases': total_purchases, "total_cash_purchases": total_purchases,
'cash_taken': total_purchases, "cash_taken": total_purchases,
'cash_discrepancy': 0, "cash_discrepancy": 0,
'cash_purchases': [purchase.id], "cash_purchases": [purchase.id],
} }
response = self.client.post( response = self.client.post(
url, data=json.dumps(data).encode('utf-8'), url,
content_type='application/json' data=json.dumps(data).encode("utf-8"),
content_type="application/json",
) )
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
def _create_simple_reconciliation(self): def _create_simple_reconciliation(self):
reconciliation = ReconciliationJar() reconciliation = ReconciliationJar()
reconciliation.date_time = "2024-07-30" reconciliation.date_time = "2024-07-30"
@@ -288,19 +300,22 @@ class TestJarReconcliation(TestCase, LoginMixin):
return reconciliation return reconciliation
def _create_reconciliation_with_purchase(self): def _create_reconciliation_with_purchase(self):
url = '/don_confiao/reconciliate_jar' url = "/don_confiao/reconciliate_jar"
total_purchases = (11 * 72500) + (27 * 72500) total_purchases = (11 * 72500) + (27 * 72500)
data = { data = {
'date_time': '2024-12-02T21:07', "date_time": "2024-12-02T21:07",
'reconcilier': 'carlos', "reconcilier": "carlos",
'total_cash_purchases': total_purchases, "total_cash_purchases": total_purchases,
'cash_taken': total_purchases, "cash_taken": total_purchases,
'cash_discrepancy': 0, "cash_discrepancy": 0,
'cash_purchases': [ "cash_purchases": [
self.purchase.id, self.purchase.id,
self.purchase2.id, self.purchase2.id,
self.purchase.id, self.purchase.id,
], ],
} }
return self.client.post(url, data=json.dumps(data).encode('utf-8'), return self.client.post(
content_type='application/json') url,
data=json.dumps(data).encode("utf-8"),
content_type="application/json",
)

View File

@@ -2,7 +2,7 @@
from django.test import TestCase from django.test import TestCase
from django.db.utils import IntegrityError from django.db.utils import IntegrityError
from ..models import Customer from ..models.customers import Customer
class TestCustomer(TestCase): class TestCustomer(TestCase):

View File

@@ -1,6 +1,9 @@
from django.test import Client, TestCase from django.test import Client, TestCase
from django.conf import settings from django.conf import settings
from ..models import ProductCategory, Product from rest_framework.test import APITestCase
from rest_framework import status
from ..models.products import ProductCategory, Product
from .Mixins import LoginMixin
import os import os
import json import json
@@ -20,24 +23,6 @@ class TestProducts(TestCase):
self.assertIsNone(product.external_id) self.assertIsNone(product.external_id)
self.assertIsNone(product.unit_external_id) self.assertIsNone(product.unit_external_id)
def test_import_products(self):
self._import_csv()
all_products = self._get_products()
self.assertEqual(len(all_products), 3)
def test_import_products_with_categories(self):
self._import_csv()
all_products = self._get_products()
self.assertIn("Aceites", all_products[0]["categories"])
def test_don_repeat_categories_on_import(self):
self._import_csv()
categories_on_csv = ["Cafes", "Alimentos", "Aceites"]
categories = ProductCategory.objects.all()
self.assertCountEqual(
[c.name for c in categories], categories_on_csv
)
def test_update_products(self): def test_update_products(self):
self._import_csv() self._import_csv()
first_products = self._get_products() first_products = self._get_products()
@@ -45,55 +30,6 @@ class TestProducts(TestCase):
seconds_products = self._get_products() seconds_products = self._get_products()
self.assertEqual(len(first_products), len(seconds_products)) self.assertEqual(len(first_products), len(seconds_products))
def test_preserve_id_on_import(self):
self._import_csv()
id_aceite = Product.objects.get(name="Aceite").id
self._import_csv("example_products2.csv")
id_post_updated = Product.objects.get(name="Aceite").id
self.assertEqual(id_aceite, id_post_updated)
def test_update_categories_on_import(self):
self._import_csv()
first_products = self._get_products()
first_categories = {
p["name"]: p["categories"] for p in first_products
}
self._import_csv("example_products2.csv")
updated_products = self._get_products()
updated_categories = {
p["name"]: p["categories"] for p in updated_products
}
self.assertIn("Cafes", first_categories["Arroz"])
self.assertNotIn("Granos", first_categories["Arroz"])
self.assertIn("Granos", updated_categories["Arroz"])
self.assertNotIn("Cafes", updated_categories["Arroz"])
def test_update_price(self):
self._import_csv()
first_products = self._get_products()
first_prices = {p["name"]: p["price_list"] for p in first_products}
expected_first_prices = {
"Aceite": "50000.00",
"Café": "14000.00",
"Arroz": "7000.00",
}
self.assertDictEqual(expected_first_prices, first_prices)
self._import_csv("example_products2.csv")
updated_products = self._get_products()
updated_prices = {
p["name"]: p["price_list"] for p in updated_products
}
expected_updated_prices = {
"Aceite": "50000.00",
"Café": "15000.00",
"Arroz": "6000.00",
}
self.assertDictEqual(expected_updated_prices, updated_prices)
def _get_products(self): def _get_products(self):
products_response = self.client.get("/don_confiao/productos") products_response = self.client.get("/don_confiao/productos")
return json.loads(products_response.content.decode("utf-8")) return json.loads(products_response.content.decode("utf-8"))
@@ -103,6 +39,211 @@ class TestProducts(TestCase):
app_dir = os.path.join(settings.BASE_DIR, app_name) app_dir = os.path.join(settings.BASE_DIR, app_name)
example_csv = os.path.join(app_dir, csv_file) example_csv = os.path.join(app_dir, csv_file)
with open(example_csv, "rb") as csv: with open(example_csv, "rb") as csv:
self.client.post( self.client.post("/don_confiao/importar_productos", {"csv_file": csv})
"/don_confiao/importar_productos", {"csv_file": csv}
)
class TestProductsAPIFiltering(APITestCase, LoginMixin):
"""Tests for filtering products by active status via API"""
def setUp(self):
self.login()
# Create active products
self.active_product_1 = Product.objects.create(
name="Active Product 1", price=100.00, active=True
)
self.active_product_2 = Product.objects.create(
name="Active Product 2", price=200.00, active=True
)
# Create inactive products
self.inactive_product_1 = Product.objects.create(
name="Inactive Product 1", price=150.00, active=False
)
self.inactive_product_2 = Product.objects.create(
name="Inactive Product 2", price=250.00, active=False
)
def test_get_products_default_returns_only_active(self):
"""By default, API should return only active products"""
response = self.client.get("/don_confiao/api/products/")
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 2)
product_names = [p["name"] for p in data]
self.assertIn("Active Product 1", product_names)
self.assertIn("Active Product 2", product_names)
self.assertNotIn("Inactive Product 1", product_names)
self.assertNotIn("Inactive Product 2", product_names)
def test_get_products_active_true(self):
"""Filter products with active=true should return only active products"""
response = self.client.get("/don_confiao/api/products/?active=true")
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 2)
for product in data:
self.assertTrue(product["active"])
def test_get_products_active_false(self):
"""Filter products with active=false should return only inactive products"""
response = self.client.get("/don_confiao/api/products/?active=false")
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 2)
for product in data:
self.assertFalse(product["active"])
product_names = [p["name"] for p in data]
self.assertIn("Inactive Product 1", product_names)
self.assertIn("Inactive Product 2", product_names)
def test_get_products_active_all(self):
"""Filter products with active=all should return all products"""
response = self.client.get("/don_confiao/api/products/?active=all")
self.assertEqual(response.status_code, status.HTTP_200_OK)
data = response.json()
self.assertEqual(len(data), 4)
def test_get_products_active_variations(self):
"""Test different variations of true/false values"""
# Test '1' for true
response = self.client.get("/don_confiao/api/products/?active=1")
self.assertEqual(len(response.json()), 2)
# Test 'yes' for true
response = self.client.get("/don_confiao/api/products/?active=yes")
self.assertEqual(len(response.json()), 2)
# Test '0' for false
response = self.client.get("/don_confiao/api/products/?active=0")
self.assertEqual(len(response.json()), 2)
# Test 'no' for false
response = self.client.get("/don_confiao/api/products/?active=no")
self.assertEqual(len(response.json()), 2)
def test_get_product_detail_regardless_of_status(self):
"""Getting a specific product by ID should work regardless of active status"""
# Get active product
response = self.client.get(
f"/don_confiao/api/products/{self.active_product_1.id}/"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["name"], "Active Product 1")
# Get inactive product
response = self.client.get(
f"/don_confiao/api/products/{self.inactive_product_1.id}/"
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.json()["name"], "Inactive Product 1")
class TestProductsAPIActivation(APITestCase, LoginMixin):
"""Tests for activating/deactivating products via API"""
def setUp(self):
self.login()
self.product = Product.objects.create(
name="Test Product", price=100.00, active=True
)
def test_deactivate_product_via_patch(self):
"""PATCH request should be able to deactivate a product"""
response = self.client.patch(
f"/don_confiao/api/products/{self.product.id}/",
{"active": False},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify product was deactivated
self.product.refresh_from_db()
self.assertFalse(self.product.active)
# Verify response contains updated data
self.assertFalse(response.json()["active"])
def test_activate_product_via_patch(self):
"""PATCH request should be able to activate a product"""
# First deactivate the product
self.product.active = False
self.product.save()
response = self.client.patch(
f"/don_confiao/api/products/{self.product.id}/",
{"active": True},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify product was activated
self.product.refresh_from_db()
self.assertTrue(self.product.active)
# Verify response contains updated data
self.assertTrue(response.json()["active"])
def test_update_other_fields_preserves_active_status(self):
"""Updating other fields should not affect active status"""
response = self.client.patch(
f"/don_confiao/api/products/{self.product.id}/",
{"price": "250.00"},
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
# Verify active status was preserved
self.product.refresh_from_db()
self.assertTrue(self.product.active)
self.assertEqual(self.product.price, 250.00)
def test_deactivated_product_not_in_default_list(self):
"""After deactivating a product, it should not appear in default list"""
# Deactivate product
self.client.patch(
f"/don_confiao/api/products/{self.product.id}/",
{"active": False},
format="json",
)
# Get default product list
response = self.client.get("/don_confiao/api/products/")
data = response.json()
# Product should not be in list
product_ids = [p["id"] for p in data]
self.assertNotIn(self.product.id, product_ids)
def test_activated_product_appears_in_default_list(self):
"""After activating a product, it should appear in default list"""
# Deactivate product first
self.product.active = False
self.product.save()
# Activate product
self.client.patch(
f"/don_confiao/api/products/{self.product.id}/",
{"active": True},
format="json",
)
# Get default product list
response = self.client.get("/don_confiao/api/products/")
data = response.json()
# Product should be in list
product_ids = [p["id"] for p in data]
self.assertIn(self.product.id, product_ids)

View File

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

View File

@@ -1,51 +0,0 @@
from django.test import Client, TestCase
from ..models import Payment, Sale, Product, Customer
class TestPurchaseWithPayment(TestCase):
def setUp(self):
self.client = Client()
self.product = Product()
self.product.name = "Arroz"
self.product.price = 5000
self.product.save()
customer = Customer()
customer.name = "Noelba Lopez"
customer.save()
self.customer = customer
def test_generate_payment_when_it_has_payment(self):
quantity = 2
unit_price = 2500
total = 5000
self.client.post(
'/don_confiao/comprar',
{
"customer": str(self.customer.id),
"date": "2024-07-27",
"phone": "3010101000",
"description": "Venta de contado",
"saleline_set-TOTAL_FORMS": "1",
"saleline_set-INITIAL_FORMS": "0",
"saleline_set-MIN_NUM_FORMS": "0",
"saleline_set-MAX_NUM_FORMS": "1000",
"saleline_set-0-product": str(self.product.id),
"saleline_set-0-quantity": str(quantity),
"saleline_set-0-unit_price": str(unit_price),
"saleline_set-0-description": "Linea de Venta",
"saleline_set-0-sale": "",
"saleline_set-0-id": "",
"quantity_lines": "1",
"quantity_products": str(quantity),
"ammount": str(quantity * unit_price),
"payment_method": "CASH",
}
)
purchases = Sale.objects.all()
self.assertEqual(1, len(purchases))
payments = Payment.objects.all()
self.assertEqual(1, len(payments))
self.assertEqual(total, payments[0].amount)
self.assertEqual('CASH', payments[0].type_payment)

View File

@@ -1,55 +0,0 @@
from django.test import TestCase
from ..models import Sale, Product, SaleLine, Customer
from .Mixins import LoginMixin
class TestSummaryViewPurchase(TestCase, LoginMixin):
def setUp(self):
self.login()
customer = Customer()
customer.name = 'Alejo Mono'
customer.save()
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"
purchase.clean()
purchase.save()
product = Product()
product.name = "cafe"
product.price = "72500"
product.save()
line = SaleLine()
line.sale = purchase
line.product = product
line.quantity = "11"
line.unit_price = "72500"
line.save()
self.purchase = purchase
def test_summary_has_customer(self):
url = "/don_confiao/resumen_compra/" + str(self.purchase.id)
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
self.assertEqual(
response.context["purchase"].customer,
self.purchase.customer
)
self.assertIn('Alejo Mono', response.content.decode('utf-8'))
def test_json_summary(self):
url = f"/don_confiao/resumen_compra_json/{self.purchase.id}"
response = self.client.get(url)
content = response.content.decode('utf-8')
self.assertEqual(response.status_code, 200)
self.assertIn('Alejo Mono', content)
self.assertIn('cafe', content)
self.assertIn('72500', content)
self.assertIn('quantity', content)
self.assertIn('11', content)
self.assertIn('date', content)
self.assertIn(self.purchase.date, content)
self.assertIn('lines', content)

View File

@@ -1,7 +1,9 @@
from django.test import TestCase from django.test import TestCase
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from ..models import Customer, Product, Sale, SaleLine from ..models.customers import Customer
from ..models.products import Product
from ..models.sales import Sale, SaleLine
class ConfiaoTest(TestCase): class ConfiaoTest(TestCase):
@@ -20,7 +22,7 @@ class ConfiaoTest(TestCase):
sale = Sale() sale = Sale()
sale.customer = self.customer sale.customer = self.customer
sale.date = "2024-06-22 12:05:00" sale.date = "2024-06-22 12:05:00"
sale.phone = '666666666' sale.phone = "666666666"
sale.description = "Description" sale.description = "Description"
sale.save() sale.save()
@@ -30,9 +32,9 @@ class ConfiaoTest(TestCase):
sale = Sale() sale = Sale()
sale.customer = self.customer sale.customer = self.customer
sale.date = "2024-06-22 12:05:00" sale.date = "2024-06-22 12:05:00"
sale.phone = '666666666' sale.phone = "666666666"
sale.description = "Description" sale.description = "Description"
sale.payment_method = '' sale.payment_method = ""
with self.assertRaises(ValidationError): with self.assertRaises(ValidationError):
sale.full_clean() sale.full_clean()
@@ -41,7 +43,7 @@ class ConfiaoTest(TestCase):
sale = Sale() sale = Sale()
sale.customer = self.customer sale.customer = self.customer
sale.date = "2024-06-22" sale.date = "2024-06-22"
sale.phone = '666666666' sale.phone = "666666666"
sale.description = "Description" sale.description = "Description"
line = SaleLine() line = SaleLine()
@@ -58,7 +60,7 @@ class ConfiaoTest(TestCase):
sale = Sale() sale = Sale()
sale.customer = self.customer sale.customer = self.customer
sale.date = "2024-06-22" sale.date = "2024-06-22"
sale.phone = '666666666' sale.phone = "666666666"
sale.description = "Description" sale.description = "Description"
line1 = SaleLine() line1 = SaleLine()
@@ -81,15 +83,14 @@ class ConfiaoTest(TestCase):
self.assertEqual(len(SaleLine.objects.all()), 2) self.assertEqual(len(SaleLine.objects.all()), 2)
self.assertEqual( self.assertEqual(
Sale.objects.all()[0].saleline_set.all()[0].quantity, Sale.objects.all()[0].saleline_set.all()[0].quantity, 2
2
) )
def test_allow_sale_without_description(self): def test_allow_sale_without_description(self):
sale = Sale() sale = Sale()
sale.customer = self.customer sale.customer = self.customer
sale.date = "2024-06-22" sale.date = "2024-06-22"
sale.phone = '666666666' sale.phone = "666666666"
sale.description = None sale.description = None
sale.save() sale.save()

View File

@@ -1,45 +0,0 @@
#!/usr/bin/env python3
from django.test import TestCase
from ..forms import PurchaseForm
from ..models import Customer
_csrf_token = \
"bVjBevJRavxRPFOlVgAWiyh9ceuiwPlyEcmbPZprNuCGHjFZRKZrBeunJvKTRgOx"
class PurchaseFormTest(TestCase):
def setUp(self):
self.customer = Customer()
self.customer.name = "Don Confiao Gonzalez"
self.customer.address = "Patio Bonito"
self.customer.save()
def test_add_purchase(self):
form_data = {
"csrfmiddlewaretoken": _csrf_token,
"customer": self.customer.id,
"date": "2024-08-03",
"payment_method": "CASH",
"phone": "sfasfd",
"description": "dasdadad",
"saleline_set-TOTAL_FORMS": "1",
"saleline_set-INITIAL_FORMS": "0",
"saleline_set-MIN_NUM_FORMS": "0",
"saleline_set-MAX_NUM_FORMS": "1000",
"saleline_set-0-product": "5",
"saleline_set-0-quantity": "1",
"saleline_set-0-unit_price": "500",
"saleline_set-0-description": "afasdfasdf",
"saleline_set-0-sale": "",
"saleline_set-0-id": "",
"quantity_lines": "1",
"quantity_products": "1",
"ammount": "500",
"form": ""
}
purchase_form = PurchaseForm(data=form_data)
purchase_form.is_valid()
# raise Exception(purchase_form)
self.assertTrue(purchase_form.is_valid())

View File

@@ -2,41 +2,97 @@ from django.urls import path, include
from rest_framework.routers import DefaultRouter from rest_framework.routers import DefaultRouter
from . import views from . import views
from . import api_views from .api import (
# Catalogue Images
CatalogueImageViewSet,
# Products
ProductView,
ProductsFromTrytonView,
# Customers
CustomerView,
CustomersFromTrytonView,
# Sales
SaleView,
CatalogSaleView,
SaleSummary,
CatalogSaleSummary,
SalesForTrytonView,
SalesToTrytonView,
CatalogSalesToTrytonView,
# Payments
ReconciliateJarView,
ReconciliateJarModelView,
PaymentMethodView,
SalesForReconciliationView,
# Admin
AdminCodeValidateView,
)
app_name = 'don_confiao' app_name = "don_confiao"
router = DefaultRouter() router = DefaultRouter()
router.register(r'sales', api_views.SaleView, basename='sale') router.register(r"sales", SaleView, basename="sale")
router.register(r'customers', api_views.CustomerView, basename='customer') router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
router.register(r'products', api_views.ProductView, basename='product') router.register(r"customers", CustomerView, basename="customer")
router.register(r'reconciliate_jar', api_views.ReconciliateJarModelView, router.register(r"products", ProductView, basename="product")
basename='reconciliate_jar') router.register(
r"catalogue_images",
CatalogueImageViewSet,
basename="catalogue_image",
)
router.register(
r"reconciliate_jar",
ReconciliateJarModelView,
basename="reconciliate_jar",
)
urlpatterns = [ urlpatterns = [
path("", views.index, name="wellcome"),
path("comprar", views.buy, name="buy"),
path("compras", views.purchases, name="purchases"),
path("productos", views.products, name="products"), path("productos", views.products, name="products"),
path("lista_productos", views.ProductListView.as_view(), name='product_list'), path(
path("importar_productos", views.import_products, name="import_products"), "resumen_compra_json/<int:id>",
path('api/importar_productos_de_tryton', SaleSummary.as_view(),
api_views.ProductsFromTrytonView.as_view(), name="purchase_json_summary",
name="products_from_tryton"), ),
path("importar_terceros", views.import_customers, name="import_customers"), path(
path('api/importar_clientes_de_tryton', "resumen_compra_catalogo_json/<int:id>",
api_views.CustomersFromTrytonView.as_view(), CatalogSaleSummary.as_view(),
name="customers_from_tryton"), name="catalog_purchase_json_summary",
path("exportar_ventas_para_tryton", ),
views.exportar_ventas_para_tryton, path(
name="exportar_ventas_para_tryton"), "payment_methods/all/select_format",
path('api/enviar_ventas_a_tryton', api_views.SalesToTrytonView.as_view(), name="send_tryton"), PaymentMethodView.as_view(),
path("resumen_compra/<int:id>", views.purchase_summary, name="purchase_summary"), name="payment_methods_to_select",
path("resumen_compra_json/<int:id>", api_views.SaleSummary.as_view(), name="purchase_json_summary"), ),
path("payment_methods/all/select_format", api_views.PaymentMethodView.as_view(), name="payment_methods_to_select"), path(
path('purchases/for_reconciliation', api_views.SalesForReconciliationView.as_view(), name='sales_for_reconciliation'), "purchases/for_reconciliation",
path('reconciliate_jar', api_views.ReconciliateJarView.as_view()), SalesForReconciliationView.as_view(),
path('api/admin_code/validate/<code>', api_views.AdminCodeValidateView.as_view()), name="sales_for_reconciliation",
path('api/sales/for_tryton', api_views.SalesForTrytonView.as_view()), ),
path('api/', include(router.urls)), path("reconciliate_jar", ReconciliateJarView.as_view()),
path("api/", include(router.urls)),
path(
"api/importar_productos_de_tryton",
ProductsFromTrytonView.as_view(),
name="products_from_tryton",
),
path(
"api/importar_clientes_de_tryton",
CustomersFromTrytonView.as_view(),
name="customers_from_tryton",
),
path(
"api/enviar_ventas_a_tryton",
SalesToTrytonView.as_view(),
name="send_tryton",
),
path(
"api/enviar_catalog_sales_a_tryton",
CatalogSalesToTrytonView.as_view(),
name="send_catalog_sales_tryton",
),
path(
"api/admin_code/validate/<code>",
AdminCodeValidateView.as_view(),
),
path("api/sales/for_tryton", SalesForTrytonView.as_view()),
] ]

View File

@@ -3,14 +3,11 @@ from django.http import HttpResponse, HttpResponseRedirect, JsonResponse
from django.views.generic import ListView from django.views.generic import ListView
from django.db import transaction from django.db import transaction
from .models import ( from .models.sales import Sale, SaleLine
Sale, SaleLine, Product, Customer, ProductCategory, Payment, PaymentMethods, ReconciliationJar) from .models.customers import Customer
from .forms import ( from .models.sales import Sale, SaleLine, Payment
ImportProductsForm, from .models.products import Product, ProductCategory
ImportCustomersForm, from .models.payments import PaymentMethods, ReconciliationJar
PurchaseForm,
SaleLineFormSet,
PurchaseSummaryForm)
import csv import csv
import io import io
@@ -26,105 +23,13 @@ class DecimalEncoder(json.JSONEncoder):
def index(request): def index(request):
return render(request, 'don_confiao/index.html') return render(request, "don_confiao/index.html")
def buy(request):
if request.method == "POST":
sale_form = PurchaseForm(request.POST)
line_formset = SaleLineFormSet(request.POST)
sale_summary_form = PurchaseSummaryForm(request.POST)
forms_are_valid = all([
sale_form.is_valid(),
line_formset.is_valid(),
sale_summary_form.is_valid()
])
payment_method = request.POST.get('payment_method')
valid_payment_methods = [PaymentMethods.CASH]
valid_payment_method = payment_method in valid_payment_methods
if forms_are_valid:
with transaction.atomic():
sale = sale_form.save()
line_formset.instance = sale
line_formset.save()
Payment.total_payment_from_sale(
payment_method,
sale
)
return HttpResponseRedirect("compras")
else:
sale_form = PurchaseForm()
line_formset = SaleLineFormSet()
sale_summary_form = PurchaseSummaryForm()
return render(
request,
'don_confiao/purchase.html',
{
'sale_form': sale_form,
'linea_formset': line_formset,
'summary_form': sale_summary_form,
'list_products': json.dumps(Product.to_list(), cls=DecimalEncoder),
}
)
def purchases(request):
purchases = Sale.objects.all()
context = {
"purchases": purchases,
}
return render(request, 'don_confiao/purchases.html', context)
def products(request): def products(request):
return JsonResponse(Product.to_list(), safe=False) return JsonResponse(Product.to_list(), safe=False)
def import_products(request):
if request.method == "POST":
form = ImportProductsForm(request.POST, request.FILES)
if form.is_valid():
handle_import_products_file(request.FILES["csv_file"])
return HttpResponseRedirect("productos")
else:
form = ImportProductsForm()
return render(
request,
"don_confiao/import_products.html",
{'form': form}
)
def import_customers(request):
if request.method == "POST":
form = ImportCustomersForm(request.POST, request.FILES)
if form.is_valid():
handle_import_customers_file(request.FILES["csv_file"])
return HttpResponseRedirect("productos")
else:
form = ImportCustomersForm()
return render(
request,
"don_confiao/import_customers.html",
{'form': form}
)
def reconciliations(request):
return HttpResponse('<h1>Reconciliaciones</h1>')
def purchase_summary(request, id):
purchase = Sale.objects.get(pk=id)
return render(
request,
"don_confiao/purchase_summary.html",
{
"purchase": purchase
}
)
def _categories_from_csv_string(categories_string, separator="&"): def _categories_from_csv_string(categories_string, separator="&"):
categories = categories_string.split(separator) categories = categories_string.split(separator)
clean_categories = [c.strip() for c in categories] clean_categories = [c.strip() for c in categories]
@@ -136,33 +41,32 @@ def _category_from_name(name):
def handle_import_products_file(csv_file): def handle_import_products_file(csv_file):
data = io.StringIO(csv_file.read().decode('utf-8')) data = io.StringIO(csv_file.read().decode("utf-8"))
reader = csv.DictReader(data, quotechar='"') reader = csv.DictReader(data, quotechar='"')
for row in reader: for row in reader:
product, created = Product.objects.update_or_create( product, created = Product.objects.update_or_create(
name=row['producto'], name=row["producto"],
defaults={ defaults={
'price': row['precio'], "price": row["precio"],
'measuring_unit': row['unidad'] "measuring_unit": row["unidad"],
} },
) )
categories = _categories_from_csv_string(row["categorias"]) categories = _categories_from_csv_string(row["categorias"])
product.categories.clear() product.categories.clear()
for category in categories: for category in categories:
product.categories.add(category) product.categories.add(category)
def handle_import_customers_file(csv_file): def handle_import_customers_file(csv_file):
data = io.StringIO(csv_file.read().decode('utf-8')) data = io.StringIO(csv_file.read().decode("utf-8"))
reader = csv.DictReader(data, quotechar='"') reader = csv.DictReader(data, quotechar='"')
for row in reader: for row in reader:
customer, created = Customer.objects.update_or_create( customer, created = Customer.objects.update_or_create(
name=row['nombre'], name=row["nombre"],
defaults={ defaults={"email": row["correo"], "phone": row["telefono"]},
'email': row['correo'],
'phone': row['telefono']
}
) )
def sales_to_tryton_csv(sales): def sales_to_tryton_csv(sales):
tryton_sales_header = [ tryton_sales_header = [
"Tercero", "Tercero",
@@ -182,7 +86,7 @@ def sales_to_tryton_csv(sales):
"Tienda", "Tienda",
"Terminal de venta", "Terminal de venta",
"Autorecogida", "Autorecogida",
"Comentario" "Comentario",
] ]
csv_data = [tryton_sales_header] csv_data = [tryton_sales_header]
@@ -194,7 +98,7 @@ def sales_to_tryton_csv(sales):
first_sale_line = sale_lines[0] first_sale_line = sale_lines[0]
customer_info = [sale.customer.name] * 3 + [sale.description] * 2 customer_info = [sale.customer.name] * 3 + [sale.description] * 2
first_line = customer_info + [ first_line = customer_info + [
sale.date.strftime('%Y-%m-%d'), sale.date.strftime("%Y-%m-%d"),
"Contado", "Contado",
"Almacén", "Almacén",
"Peso colombiano", "Peso colombiano",
@@ -206,39 +110,21 @@ def sales_to_tryton_csv(sales):
"Tienda La Ilusion", "Tienda La Ilusion",
"La Ilusion", "La Ilusion",
True, True,
sale.description] sale.description,
]
lines.append(first_line) lines.append(first_line)
for line in sale_lines[1:]: for line in sale_lines[1:]:
lines.append([""]*9+[ lines.append(
line.product.name, [""] * 9
line.quantity, + [
line.unit_price, line.product.name,
"Unidad"]+[""]*5) line.quantity,
line.unit_price,
"Unidad",
]
+ [""] * 5
)
for row in lines: for row in lines:
csv_data.append(row) csv_data.append(row)
return csv_data return csv_data
def exportar_ventas_para_tryton(request):
if request.method == "GET":
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = "attachment; filename=sales.csv"
sales = Sale.objects.all()
csv_data = sales_to_tryton_csv(sales)
writer = csv.writer(response)
for row in csv_data:
writer.writerow(row)
return response
class ProductListView(ListView):
model = Product
template_model = 'don_confiao/product_list.html'
def get_queryset(self):
name = self.request.GET.get('name')
if name:
return Product.objects.filter(name__icontains=name)
return Product.objects.all()