20 Commits

Author SHA1 Message Date
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
ecef46b4bb feat: Add task for execute test 2026-05-28 13:39:34 -05:00
75c030b554 fix: execution tests 2026-05-28 13:38:37 -05:00
75 changed files with 3236 additions and 1892 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
# Tryton ERP Configuration
TRYTON_HOST=localhost
TRYTON_DATABASE=tryton
TRYTON_USERNAME=admin
TRYTON_PASSWORD=admin
TRYTON_HOST=recreo.onecluster.com.co
TRYTON_DATABASE=ilusion_staging
TRYTON_USERNAME=alejandro.ayala
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",
"password": "123"
"password": "admin"
}
**** respuesta
#+begin_src json
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc"
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc4MDExNTc0NywiaWF0IjoxNzgwMDI5MzQ3LCJqdGkiOiIxNmVjZGMxZmY4Y2Y0MzA4ODM3ZjM5Y2ZiNjQwNmZiMCIsInVzZXJfaWQiOiIxIn0.wmN-wp3Izv0NrfL_ap_i8eyg29w-foHNrQCCL6HoZWg",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA"
}
#+end_src
*** Perfil de usuario
get /users/me/
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzMDE5LCJpYXQiOjE3NzExMDEyMTksImp0aSI6ImFmOWFjNGM1MzBiZjQ4ZGE4Yzg2MWFjYzIzNjQ3NjU3IiwidXNlcl9pZCI6IjIifQ.6wH5sx1fyFn3Wt3DVZGYbiYi79rGthUZkgGmTqzebXc
Authorization:Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDA2MzU4LCJpYXQiOjE3ODAwMDQ1NTgsImp0aSI6IjkwNzY4OGU2MmNlNTQ1M2JiYzU5MTA2MDhmMjY1MmY5IiwidXNlcl9pZCI6IjEifQ.aFls9WyA1VuDeMMUu8t7Pa9CbbLfyvIg9pB9xIxydpU
**** Respuesta
#+begin_src json
{
"id": 2,
"id": 1,
"username": "admin",
"email": "correo@example.com",
"email": "admin@admin.org",
"first_name": "",
"last_name": ""
"last_name": "",
"role": "administrator"
}
#+end_src
*** Renovar token
post /token/refresh/
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3MTE4NzYxOSwiaWF0IjoxNzcxMTAxMjE5LCJqdGkiOiI5ZTgzNGRlM2QzMmQ0NmQyODEwZGQ2MjI2ODUwNjgzNyIsInVzZXJfaWQiOiIyIn0.JaUOqEAZ2T8vVT36mXfweMmYjEWsP7toD07jeeyrl1k"
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc3ODU1Njc5MywiaWF0IjoxNzc4NDcwMzkzLCJqdGkiOiJlMDU0NTVkNWExYzA0YjFkYWZhNWZkNzFkZGM5Mzc1NyIsInVzZXJfaWQiOiIxIn0.wZcbBrGoxDMPjZxI-GR1GTAuRtzU4qaT0rgGS5Oblf4"
}
**** response
#+begin_src json
{
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA"
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzc4NDcyMjQ5LCJpYXQiOjE3Nzg0NzA0NDksImp0aSI6IjE5YTM0ZDQ5Mzk3ZDQzNGE4NDlkZTgyYzdkNWQyNjQ0IiwidXNlcl9pZCI6IjEifQ.jowmaa5SXKIWpmUGLV0dj9CydYFtuecc7s_RveJvjLA"
}
#+end_src
** Don confiao :verb:
template http://localhost:7000/don_confiao/api/
Content-Type: application/json;
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzcxMTAzNjA1LCJpYXQiOjE3NzExMDE4MDUsImp0aSI6ImJjZTY5ZTA3MTIyOTQxMTg5NmFjYzk1ZDNiOThhMTI0IiwidXNlcl9pZCI6IjIifQ.b4Z1c_Yi5tsLZ-7F0KZcM2tai-f1VeaE881j2pKDwYA
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA
*** todas las rutas
get
**** response
#+begin_src json
{
"sales": "http://localhost:7000/don_confiao/api/sales/",
"catalog_sales": "http://localhost:7000/don_confiao/api/catalog_sales/",
"customers": "http://localhost:7000/don_confiao/api/customers/",
"products": "http://localhost:7000/don_confiao/api/products/",
"reconciliate_jar": "http://localhost:7000/don_confiao/api/reconciliate_jar/"
@@ -76,3 +78,62 @@ get customers/
#+end_src
*** products
get products/
*** Productos Inactivos
get products/?active=false
*** Productos Activos
get products/?active=true
*** Traer todos los productos
get products/?active=all
*** Inactiva productos
patch products/1
{
"active": false
}
*** Obtener Ventas por catalogo
get catalog_sales/
**** response
#+begin_src json
{
"id": 6,
"customer": 1,
"date": "2024-09-02T00:00:00Z",
"catalogsaleline_set": [
{
"id": 2,
"catalog_sale": 6,
"product": 1,
"unit_price": "3000.00",
"quantity": "2.00"
},
{
"id": 3,
"catalog_sale": 6,
"product": 1,
"unit_price": "5000.00",
"quantity": "3.00"
}
],
"total": 21000.0
}
*** Crear una venta por catalogo
post catalog_sales/
{"customer": "1", "date": "2024-09-02", "catalogsaleline_set": [{"product": "1", "quantity": "2", "unit_price": "3000"}, {"product": "1", "quantity": "3", "unit_price": "5000"}]}
*** Importar Clientes de Tryton
post importar_productos_de_tryton
{}
**** response
#+begin_src json
[]
#+end_src

View File

@@ -28,10 +28,16 @@ dev = [
[tool.taskipy.tasks]
dev-up = "docker compose -f docker-compose.dev.yml up -d"
dev-logs = "docker compose -f docker-compose.dev.yml logs -f -n 50"
dev-stop = "docker compose -f docker-compose.dev.yml stop"
dev-restart = "docker compose -f docker-compose.dev.yml restart"
dev-down = "docker compose -f docker-compose.dev.yml down -vv --rmi all"
dev-tail = "docker compose -f docker-compose.dev.yml logs -f"
dev-sh = "docker compose -f docker-compose.dev.yml exec -it --user root django bash"
dev-migrate = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py migrate"
dev-createsuperuser = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py createsuperuser"
dev-test = "docker compose -f docker-compose.dev.yml exec -it --user root django python3 manage.py test"
live-up = "docker compose -f docker-compose.staging.yml up -d"
live-logs = "docker compose -f docker-compose.staging.yml logs -f -n 50"

View File

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

View File

@@ -138,6 +138,22 @@ USE_TZ = True
STATIC_URL = "static/"
STATIC_ROOT = BASE_DIR / "staticfiles"
# Media files
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# Catalogue image settings
CATALOGUE_IMAGE_WIDTH = int(os.environ.get("CATALOGUE_IMAGE_WIDTH", "600"))
CATALOGUE_IMAGE_HEIGHT = int(os.environ.get("CATALOGUE_IMAGE_HEIGHT", "600"))
CATALOGUE_STRICT_DIMENSION = os.environ.get("CATALOGUE_STRICT_DIMENSION", "False").lower() in ("true", "1", "yes")
CATALOGUE_BACKGROUND_IMAGES_RGBA = os.environ.get(
"CATALOGUE_BACKGROUND_IMAGES_RGBA", "0,0,0,0"
)
# Parse RGBA string to tuple
_rgba_parts = CATALOGUE_BACKGROUND_IMAGES_RGBA.split(",")
CATALOGUE_BACKGROUND_IMAGES_RGBA = tuple(int(p.strip()) for p in _rgba_parts)
CATALOGUE_MAX_UPLOAD_SIZE = int(os.environ.get("CATALOGUE_MAX_UPLOAD_SIZE", str(5 * 1024 * 1024)))
# Default primary key field type
# 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_URL = "/static/"
# Media files configuration
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"
# WhiteNoise configuration for development (optional, for consistency)
# In development with DEBUG=True, Django serves static files automatically
# But this ensures consistent behavior across all environments

View File

@@ -14,6 +14,8 @@ Including another URLconf
1. Import the include() function: from django.urls import include, path
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.urls import include, path
from rest_framework_simplejwt.views import (
@@ -32,3 +34,8 @@ urlpatterns = [
name='token_refresh'),
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 .models import (
Customer, Sale, SaleLine, Product, ProductCategory, Payment,
ReconciliationJar)
from .models.sales import Sale, SaleLine
from .models.customers import Customer
from .models.sales import (
Sale,
SaleLine,
CatalogSale,
CatalogSaleLine,
Payment,
)
from .models.products import Product, ProductCategory
from .models.payments import ReconciliationJar
@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(SaleLine)
admin.site.register(CatalogSale)
admin.site.register(CatalogSaleLine)
admin.site.register(Product)
admin.site.register(ProductCategory)
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 ..models import AdminCode
from ..models.admin import AdminCode
from .Mixins import LoginMixin
import json
@@ -10,33 +10,33 @@ class TestAdminCode(TestCase, LoginMixin):
def setUp(self):
self.login()
self.valid_code = 'some valid code'
self.valid_code = "some valid code"
admin_code = AdminCode()
admin_code.value = self.valid_code
admin_code.clean()
admin_code.save()
def test_validate_code(self):
url = '/don_confiao/api/admin_code/validate/' + self.valid_code
url = "/don_confiao/api/admin_code/validate/" + self.valid_code
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
self.assertTrue(content['validCode'])
content = json.loads(response.content.decode("utf-8"))
self.assertTrue(content["validCode"])
def test_invalid_code(self):
invalid_code = 'some invalid code'
url = '/don_confiao/api/admin_code/validate/' + invalid_code
invalid_code = "some invalid code"
url = "/don_confiao/api/admin_code/validate/" + invalid_code
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
self.assertFalse(content['validCode'])
content = json.loads(response.content.decode("utf-8"))
self.assertFalse(content["validCode"])
def test_empty_code(self):
empty_code = ''
url = '/don_confiao/api/admin_code/validate/' + empty_code
empty_code = ""
url = "/don_confiao/api/admin_code/validate/" + empty_code
response = self.client.get(url)
self.assertEqual(response.status_code, 404)

View File

@@ -4,7 +4,9 @@ import io
from rest_framework import status
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
@@ -13,18 +15,13 @@ class TestAPI(APITestCase, LoginMixin):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
measuring_unit='UNIT'
)
self.customer = Customer.objects.create(
name='Camilo',
external_id='18'
name="Panela", price=5000, measuring_unit="UNIT"
)
self.customer = Customer.objects.create(name="Camilo", external_id="18")
def test_create_sale(self):
response = self._create_sale()
content = json.loads(response.content.decode('utf-8'))
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0]
@@ -32,79 +29,132 @@ class TestAPI(APITestCase, LoginMixin):
sale.customer.name,
self.customer.name,
)
self.assertEqual(
sale.id,
content['id']
)
self.assertEqual(sale.id, content["id"])
self.assertIsNone(sale.external_id)
def test_create_catalog_sale(self):
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(CatalogSale.objects.count(), 1)
sale = CatalogSale.objects.all()[0]
self.assertEqual(sale.customer.name, self.customer.name)
self.assertEqual(sale.id, content["id"])
self.assertEqual(sale.catalogsaleline_set.count(), 2)
def test_create_sale_with_decimal(self):
response = self._create_sale_with_decimal()
content = json.loads(response.content.decode('utf-8'))
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
self.assertEqual(Sale.objects.count(), 1)
sale = Sale.objects.all()[0]
self.assertEqual(
sale.customer.name,
self.customer.name
)
self.assertEqual(
sale.id,
content['id']
)
self.assertEqual(
sale.get_total(),
16500.00
)
self.assertEqual(sale.customer.name, self.customer.name)
self.assertEqual(sale.id, content["id"])
self.assertEqual(sale.get_total(), 16500.00)
def test_get_products(self):
url = '/don_confiao/api/products/'
url = "/don_confiao/api/products/"
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
json_response = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.product.name, json_response[0]['name'])
self.assertEqual(self.product.name, json_response[0]["name"])
def test_get_customers(self):
url = '/don_confiao/api/customers/'
url = "/don_confiao/api/customers/"
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
json_response = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.name, json_response[0]['name'])
self.assertEqual(
self.customer.external_id,
json_response[0]['external_id']
)
self.assertEqual(self.customer.name, json_response[0]["name"])
self.assertEqual(self.customer.external_id, json_response[0]["external_id"])
def test_get_sales(self):
url = '/don_confiao/api/sales/'
url = "/don_confiao/api/sales/"
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
json_response = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.id, json_response[0]['customer'])
self.assertEqual(
None,
json_response[0]['external_id']
)
self.assertEqual(self.customer.id, json_response[0]["customer"])
self.assertEqual(None, json_response[0]["external_id"])
def test_get_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton'
url = "/don_confiao/api/sales/for_tryton"
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
json_response = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertIn('csv', json_response)
self.assertGreater(len(json_response['csv']), 0)
self.assertIn("csv", json_response)
self.assertGreater(len(json_response["csv"]), 0)
def test_catalog_sale_summary(self):
# Create a catalog sale
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
# Get the summary
url = f"/don_confiao/resumen_compra_catalogo_json/{catalog_sale_id}"
response = self.client.get(url)
# Verify response
self.assertEqual(response.status_code, status.HTTP_200_OK)
json_response = json.loads(response.content.decode("utf-8"))
# Verify structure
self.assertIn("id", json_response)
self.assertIn("date", json_response)
self.assertIn("customer", json_response)
self.assertIn("lines", json_response)
# Verify customer details
self.assertEqual(json_response["customer"]["id"], self.customer.id)
self.assertEqual(json_response["customer"]["name"], self.customer.name)
# Verify lines
self.assertEqual(len(json_response["lines"]), 2)
# Verify first line
line1 = json_response["lines"][0]
self.assertIn("product", line1)
self.assertIn("quantity", line1)
self.assertIn("unit_price", line1)
self.assertEqual(line1["product"]["id"], self.product.id)
self.assertEqual(line1["product"]["name"], self.product.name)
self.assertEqual(line1["quantity"], "2.00")
self.assertEqual(line1["unit_price"], "3000.00")
# Verify second line
line2 = json_response["lines"][1]
self.assertEqual(line2["product"]["id"], self.product.id)
self.assertEqual(line2["quantity"], "3.00")
self.assertEqual(line2["unit_price"], "5000.00")
def test_catalog_sale_has_external_id_field(self):
"""Verifica que CatalogSale tiene el campo external_id"""
response = self._create_catalog_sale()
content = json.loads(response.content.decode("utf-8"))
catalog_sale_id = content["id"]
catalog_sale = CatalogSale.objects.get(pk=catalog_sale_id)
# Debe tener el campo external_id
self.assertIsNone(catalog_sale.external_id)
# Se puede asignar un valor
catalog_sale.external_id = "123"
catalog_sale.save()
# Verificar que se guardó
catalog_sale.refresh_from_db()
self.assertEqual(catalog_sale.external_id, "123")
def test_csv_structure_in_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton'
url = "/don_confiao/api/sales/for_tryton"
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
json_response = json.loads(response.content.decode("utf-8"))
self.assertEqual(response.status_code, status.HTTP_200_OK)
csv_reader = csv.reader(io.StringIO(json_response['csv']))
csv_reader = csv.reader(io.StringIO(json_response["csv"]))
expected_header = [
"Tercero",
"Dirección de facturación",
@@ -123,53 +173,113 @@ class TestAPI(APITestCase, LoginMixin):
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario"
"Comentario",
]
self.assertEqual(next(csv_reader), expected_header)
expected_rows = [
[self.customer.name, self.customer.name, self.customer.name, "",
"", "2024-09-02", "Contado", "Almacén",
"Peso colombiano", self.product.name, "2.00", "3000.00", "Unidad",
"TIENDA LA ILUSIÓN", "Tienda La Ilusion", "La Ilusion", "True", ""
],
["", "", "", "", "", "", "", "", "", self.product.name, "3.00",
"5000.00", "Unidad", "", "", "", "", ""
],
[
self.customer.name,
self.customer.name,
self.customer.name,
"",
"",
"2024-09-02",
"Contado",
"Almacén",
"Peso colombiano",
self.product.name,
"2.00",
"3000.00",
"Unidad",
"TIENDA LA ILUSIÓN",
"Tienda La Ilusion",
"La Ilusion",
"True",
"",
],
[
"",
"",
"",
"",
"",
"",
"",
"",
"",
self.product.name,
"3.00",
"5000.00",
"Unidad",
"",
"",
"",
"",
"",
],
]
rows = list(csv_reader)
self.assertEqual(rows, expected_rows)
def _create_sale(self):
url = '/don_confiao/api/sales/'
url = "/don_confiao/api/sales/"
data = {
'customer': self.customer.id,
'date': '2024-09-02',
'payment_method': 'CASH',
'saleline_set': [
{'product': self.product.id, 'quantity': 2, 'unit_price': 3000},
{'product': self.product.id, 'quantity': 3, 'unit_price': 5000}
],
}
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': [
"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": 2,
"unit_price": 3000,
},
{
'product': self.product.id,
'quantity': 3,
'unit_price': 5000
}
"product": self.product.id,
"quantity": 3,
"unit_price": 5000,
},
],
}
return self.client.post(url, data, format='json')
return self.client.post(url, data, format="json")
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 django.test import TestCase
from ..models import Customer
from ..models.customers import Customer
from .Mixins import LoginMixin
@@ -11,61 +11,71 @@ class TestCustomersFromTryton(TestCase, LoginMixin):
self.login()
self.customer = Customer.objects.create(
name='Calos',
external_id=5
name="Calos", external_id=5
)
self.customer.save()
self.customer2 = Customer.objects.create(
name='Cristian',
external_id=6
name="Cristian", external_id=6
)
self.customer2.save()
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
@patch("sabatron_tryton_rpc_client.client.Client.call")
@patch("sabatron_tryton_rpc_client.client.Client.connect")
def test_create_import_customer(self, mock_connect, mock_call):
def fake_call(*args, **kwargs):
party_search = 'model.party.party.search'
search_args = [[], 0, 1000, [['name', 'ASC'], ['id', None]], {'company': 1}]
party_search = "model.party.party.search"
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]
party_read = 'model.party.party.read'
read_args = ([5, 6, 7, 8], ['id', 'name', 'addresses'], {'company': 1})
if (args == (party_read, read_args)):
party_read = "model.party.party.read"
read_args = (
[5, 6, 7, 8],
["id", "name", "addresses"],
{"company": 1},
)
if args == (party_read, read_args):
return [
{'id': 5, 'name': 'Carlos', 'addresses': [303]},
{'id': 6, 'name': 'Cristian', 'addresses': []},
{'id': 7, 'name': 'Ana', 'addresses': [302]},
{'id': 8, 'name': 'José', 'addresses': []},
{"id": 5, "name": "Carlos", "addresses": [303]},
{"id": 6, "name": "Cristian", "addresses": []},
{"id": 7, "name": "Ana", "addresses": [302]},
{"id": 8, "name": "José", "addresses": []},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
raise Exception(
f"Sorry, args non expected on this test: {args}"
)
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_clientes_de_tryton'
url = "/don_confiao/api/importar_clientes_de_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
content = json.loads(response.content.decode("utf-8"))
expected_response = {
'checked_tryton_parties': [5, 6, 7, 8],
'created_customers': [3, 4],
'untouched_customers': [2],
'failed_parties': [],
'updated_customers': [1]
"checked_tryton_parties": [5, 6, 7, 8],
"created_customers": [3, 4],
"untouched_customers": [2],
"failed_parties": [],
"updated_customers": [1],
}
self.assertEqual(content, expected_response)
created_customer = Customer.objects.get(id=3)
self.assertEqual(created_customer.external_id, str(7))
self.assertEqual(created_customer.name, 'Ana')
self.assertEqual(created_customer.name, "Ana")
self.assertEqual(created_customer.address_external_id, str(302))
updated_customer = Customer.objects.get(id=1)
self.assertEqual(updated_customer.external_id, str(5))
self.assertEqual(updated_customer.name, 'Carlos')
self.assertEqual(updated_customer.name, "Carlos")
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.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
import json
@@ -11,13 +17,13 @@ class TestJarReconcliation(TestCase, LoginMixin):
self.login()
customer = Customer()
customer.name = 'Alejo Mono'
customer.name = "Alejo Mono"
customer.save()
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"
purchase.payment_method = 'CASH'
purchase.payment_method = "CASH"
purchase.clean()
purchase.save()
@@ -37,7 +43,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase2 = Sale()
purchase2.customer = customer
purchase2.date = "2024-07-30"
purchase.payment_method = 'CASH'
purchase.payment_method = "CASH"
purchase2.clean()
purchase2.save()
@@ -52,7 +58,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase3 = Sale()
purchase3.customer = customer
purchase3.date = "2024-07-30"
purchase3.payment_method = 'CASH'
purchase3.payment_method = "CASH"
purchase3.clean()
purchase3.save()
@@ -67,7 +73,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase4 = Sale()
purchase4.customer = customer
purchase4.date = "2024-07-30"
purchase4.payment_method = 'CONFIAR'
purchase4.payment_method = "CONFIAR"
purchase4.clean()
purchase4.save()
@@ -90,19 +96,19 @@ class TestJarReconcliation(TestCase, LoginMixin):
self.purchase3.clean()
self.purchase3.save()
url = '/don_confiao/purchases/for_reconciliation'
url = "/don_confiao/purchases/for_reconciliation"
response = self.client.get(url)
self.assertEqual(response.status_code, 200)
rawContent = response.content.decode('utf-8')
rawContent = response.content.decode("utf-8")
content = json.loads(rawContent)
self.assertIn('CASH', content.keys())
self.assertIn('CONFIAR', content.keys())
self.assertEqual(2, len(content.get('CASH')))
self.assertEqual(1, len(content.get('CONFIAR')))
self.assertNotIn(str(37*72500), rawContent)
self.assertIn(str(47*72500), rawContent)
self.assertIn("CASH", content.keys())
self.assertIn("CONFIAR", content.keys())
self.assertEqual(2, len(content.get("CASH")))
self.assertEqual(1, len(content.get("CONFIAR")))
self.assertNotIn(str(37 * 72500), rawContent)
self.assertIn(str(47 * 72500), rawContent)
def test_don_create_reconcialiation_with_bad_numbers(self):
reconciliation = ReconciliationJar()
@@ -114,131 +120,137 @@ class TestJarReconcliation(TestCase, LoginMixin):
reconciliation.clean()
reconciliation.save()
def test_fail_create_reconciliation_with_wrong_total_purchases_purchases(self):
url = '/don_confiao/reconciliate_jar'
def test_fail_create_reconciliation_with_wrong_total_purchases_purchases(
self,
):
url = "/don_confiao/reconciliate_jar"
total_purchases = (11 * 72500) + (27 * 72500)
bad_total_purchases = total_purchases + 2
data = {
'date_time': '2024-12-02T21:07',
'reconcilier': 'carlos',
'total_cash_purchases': bad_total_purchases,
'cash_taken': total_purchases,
'cash_discrepancy': 0,
'cash_purchases': [
"date_time": "2024-12-02T21:07",
"reconcilier": "carlos",
"total_cash_purchases": bad_total_purchases,
"cash_taken": total_purchases,
"cash_discrepancy": 0,
"cash_purchases": [
self.purchase.id,
self.purchase2.id,
self.purchase.id,
],
}
response = self.client.post(url, data=json.dumps(data).encode('utf-8'),
content_type='application/json')
rawContent = response.content.decode('utf-8')
response = self.client.post(
url,
data=json.dumps(data).encode("utf-8"),
content_type="application/json",
)
rawContent = response.content.decode("utf-8")
content = json.loads(rawContent)
self.assertEqual(response.status_code, 400)
self.assertIn('error', content)
self.assertIn('total_cash_purchases', content['error'])
self.assertIn("error", content)
self.assertIn("total_cash_purchases", content["error"])
def test_create_reconciliation_with_purchases(self):
response = self._create_reconciliation_with_purchase()
rawContent = response.content.decode('utf-8')
rawContent = response.content.decode("utf-8")
content = json.loads(rawContent)
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)
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)
data = {
'date_time': '2024-12-02T21:07',
'reconcilier': 'carlos',
'total_cash_purchases': total_purchases,
'cash_taken': total_purchases,
'cash_discrepancy': 0,
'cash_purchases': [
"date_time": "2024-12-02T21:07",
"reconcilier": "carlos",
"total_cash_purchases": total_purchases,
"cash_taken": total_purchases,
"cash_discrepancy": 0,
"cash_purchases": [
self.purchase.id,
self.purchase2.id,
],
'other_totals': {
'Confiar': {
'total': (47 * 72500) + 1,
'purchases': [self.purchase4.id],
"other_totals": {
"Confiar": {
"total": (47 * 72500) + 1,
"purchases": [self.purchase4.id],
},
},
}
response = self.client.post(url, data=json.dumps(data).encode('utf-8'),
content_type='application/json')
response = self.client.post(
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)
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)
def test_list_reconciliations(self):
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)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(2, content['count'])
self.assertEqual(2, len(content['results']))
self.assertEqual('2024-07-30T00:00:00Z',
content['results'][0]['date_time'])
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(2, content["count"])
self.assertEqual(2, len(content["results"]))
self.assertEqual(
"2024-07-30T00:00:00Z", content["results"][0]["date_time"]
)
def test_list_reconciliations_pagination(self):
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)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
self.assertEqual(1, len(content['results']))
self.assertEqual('2024-07-30T00:00:00Z',
content['results'][0]['date_time'])
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(1, len(content["results"]))
self.assertEqual(
"2024-07-30T00:00:00Z", content["results"][0]["date_time"]
)
def test_get_single_reconciliation(self):
createResponse = self._create_reconciliation_with_purchase()
reconciliationId = json.loads(
createResponse.content.decode('utf-8')
)['id']
createResponse.content.decode("utf-8")
)["id"]
self.assertGreater(reconciliationId, 0)
url = f'/don_confiao/api/reconciliate_jar/{reconciliationId}/'
response = self.client.get(url, content_type='application/json')
content = json.loads(
response.content.decode('utf-8')
)
self.assertEqual(reconciliationId, content['id'])
self.assertGreater(len(content['Sales']), 0)
url = f"/don_confiao/api/reconciliate_jar/{reconciliationId}/"
response = self.client.get(url, content_type="application/json")
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(reconciliationId, content["id"])
self.assertGreater(len(content["Sales"]), 0)
self.assertIn(
self.purchase.id,
[sale['id'] for sale in content['Sales']]
self.purchase.id, [sale["id"] for sale in content["Sales"]]
)
self.assertIn(
'CASH',
[sale['payment_method'] for sale in content['Sales']]
"CASH", [sale["payment_method"] for sale in content["Sales"]]
)
def test_create_reconciliation_with_decimal_on_sale_lines(self):
customer = Customer()
customer.name = 'Consumidor final'
customer.name = "Consumidor final"
customer.save()
product = Product()
@@ -249,7 +261,7 @@ class TestJarReconcliation(TestCase, LoginMixin):
purchase = Sale()
purchase.customer = customer
purchase.date = "2024-07-30"
purchase.payment_method = 'CASH'
purchase.payment_method = "CASH"
purchase.clean()
purchase.save()
@@ -260,23 +272,23 @@ class TestJarReconcliation(TestCase, LoginMixin):
line.unit_price = "57.50"
line.save()
url = '/don_confiao/reconciliate_jar'
url = "/don_confiao/reconciliate_jar"
total_purchases = 13.80
data = {
'date_time': '2024-12-02T21:07',
'reconcilier': 'carlos',
'total_cash_purchases': total_purchases,
'cash_taken': total_purchases,
'cash_discrepancy': 0,
'cash_purchases': [purchase.id],
"date_time": "2024-12-02T21:07",
"reconcilier": "carlos",
"total_cash_purchases": total_purchases,
"cash_taken": total_purchases,
"cash_discrepancy": 0,
"cash_purchases": [purchase.id],
}
response = self.client.post(
url, data=json.dumps(data).encode('utf-8'),
content_type='application/json'
url,
data=json.dumps(data).encode("utf-8"),
content_type="application/json",
)
self.assertEqual(response.status_code, 200)
def _create_simple_reconciliation(self):
reconciliation = ReconciliationJar()
reconciliation.date_time = "2024-07-30"
@@ -288,19 +300,22 @@ class TestJarReconcliation(TestCase, LoginMixin):
return reconciliation
def _create_reconciliation_with_purchase(self):
url = '/don_confiao/reconciliate_jar'
url = "/don_confiao/reconciliate_jar"
total_purchases = (11 * 72500) + (27 * 72500)
data = {
'date_time': '2024-12-02T21:07',
'reconcilier': 'carlos',
'total_cash_purchases': total_purchases,
'cash_taken': total_purchases,
'cash_discrepancy': 0,
'cash_purchases': [
"date_time": "2024-12-02T21:07",
"reconcilier": "carlos",
"total_cash_purchases": total_purchases,
"cash_taken": total_purchases,
"cash_discrepancy": 0,
"cash_purchases": [
self.purchase.id,
self.purchase2.id,
self.purchase.id,
],
}
return self.client.post(url, data=json.dumps(data).encode('utf-8'),
content_type='application/json')
return self.client.post(
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.db.utils import IntegrityError
from ..models import Customer
from ..models.customers import Customer
class TestCustomer(TestCase):

View File

@@ -1,6 +1,9 @@
from django.test import Client, TestCase
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 json
@@ -20,95 +23,227 @@ class TestProducts(TestCase):
self.assertIsNone(product.external_id)
self.assertIsNone(product.unit_external_id)
def test_import_products(self):
self._import_csv()
all_products = self._get_products()
self.assertEqual(
len(all_products),
3
)
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):
self._import_csv()
first_products = self._get_products()
self._import_csv('example_products2.csv')
self._import_csv("example_products2.csv")
seconds_products = self._get_products()
self.assertEqual(len(first_products), len(seconds_products))
def test_preserve_id_on_import(self):
self._import_csv()
id_aceite = Product.objects.get(name='Aceite').id
self._import_csv('example_products2.csv')
id_post_updated = Product.objects.get(name='Aceite').id
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):
products_response = self.client.get("/don_confiao/productos")
return json.loads(products_response.content.decode('utf-8'))
return json.loads(products_response.content.decode("utf-8"))
def _import_csv(self, csv_file='example_products.csv'):
app_name = "don_confiao"
def _import_csv(self, csv_file="example_products.csv"):
app_name = "don_confiao/tests/data_example"
app_dir = os.path.join(settings.BASE_DIR, app_name)
example_csv = os.path.join(app_dir, csv_file)
with open(example_csv, 'rb') as csv:
self.client.post(
"/don_confiao/importar_productos",
{"csv_file": csv}
)
with open(example_csv, "rb") as csv:
self.client.post("/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 django.test import TestCase
from ..models import Product
from ..models.products import Product
from .Mixins import LoginMixin
@@ -12,120 +12,163 @@ class TestProductsFromTryton(TestCase, LoginMixin):
self.login()
self.product = Product.objects.create(
name='Panela',
name="Panela",
price=5000,
measuring_unit='UNIT',
measuring_unit="UNIT",
unit_external_id=1,
external_id=191
external_id=191,
)
self.product.save()
self.product2 = Product.objects.create(
name='Papa',
name="Papa",
price=4500,
measuring_unit='Kilogram',
measuring_unit="Kilogram",
unit_external_id=2,
external_id=192
external_id=192,
)
self.product2.save()
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
@patch("sabatron_tryton_rpc_client.client.Client.call")
@patch("sabatron_tryton_rpc_client.client.Client.connect")
def test_create_import_products(self, mock_connect, mock_call):
mock_connect.return_value = None
def fake_call(*args, **kwargs):
product_search = 'model.product.product.search'
search_args = [[["salable", "=", True]], 0, 1000, [['rec_name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (product_search, search_args)):
product_search = "model.product.product.search"
search_args = [
[["salable", "=", True]],
0,
1000,
[["rec_name", "ASC"], ["id", None]],
{"company": 1},
]
if args == (product_search, search_args):
return [190, 191, 192]
product_read = 'model.product.product.read'
product_args = ([190, 191, 192],
['id', 'name', 'default_uom.id',
'default_uom.rec_name', 'list_price'],
{'company': 1}
)
if (args == (product_read, product_args)):
product_read = "model.product.product.read"
product_args = (
[190, 191, 192],
[
"id",
"name",
"default_uom.id",
"default_uom.rec_name",
"list_price",
],
{"company": 1},
)
if args == (product_read, product_args):
return [
{'id': 190, 'list_price': Decimal('25000'),
'name': 'Producto 1',
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
{'id': 191, 'list_price': Decimal('6000'),
'name': 'Panela2',
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
{'id': 192, 'list_price': Decimal('4500'),
'name': 'Papa',
'default_uom.': {'id': 2, 'rec_name': 'Kilogram'}},
{
"id": 190,
"list_price": Decimal("25000"),
"name": "Producto 1",
"default_uom.": {"id": 1, "rec_name": "Unit"},
},
{
"id": 191,
"list_price": Decimal("6000"),
"name": "Panela2",
"default_uom.": {"id": 1, "rec_name": "Unit"},
},
{
"id": 192,
"list_price": Decimal("4500"),
"name": "Papa",
"default_uom.": {"id": 2, "rec_name": "Kilogram"},
},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
raise Exception(
f"Sorry, args non expected on this test: {args}"
)
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_productos_de_tryton'
url = "/don_confiao/api/importar_productos_de_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
content = json.loads(response.content.decode("utf-8"))
expected_response = {
'checked_tryton_products': [190, 191, 192],
'created_products': [3],
'untouched_products': [2],
'failed_products': [],
'updated_products': [1]
"checked_tryton_products": [190, 191, 192],
"created_products": [3],
"untouched_products": [2],
"failed_products": [],
"updated_products": [1],
}
self.assertEqual(content, expected_response)
created_product = Product.objects.get(id=3)
self.assertEqual(created_product.external_id, str(190))
self.assertEqual(created_product.name, 'Producto 1')
self.assertEqual(created_product.price, Decimal('25000'))
self.assertEqual(created_product.measuring_unit, 'Unit')
self.assertEqual(created_product.name, "Producto 1")
self.assertEqual(created_product.price, Decimal("25000"))
self.assertEqual(created_product.measuring_unit, "Unit")
updated_product = Product.objects.get(id=1)
self.assertEqual(updated_product.external_id, str(191))
self.assertEqual(updated_product.name, 'Panela2')
self.assertEqual(updated_product.price, Decimal('6000'))
self.assertEqual(updated_product.measuring_unit, 'Unit')
self.assertEqual(updated_product.name, "Panela2")
self.assertEqual(updated_product.price, Decimal("6000"))
self.assertEqual(updated_product.measuring_unit, "Unit")
@patch('sabatron_tryton_rpc_client.client.Client.call')
@patch('sabatron_tryton_rpc_client.client.Client.connect')
def test_import_duplicated_name_products(self, mock_connect, mock_call):
@patch("sabatron_tryton_rpc_client.client.Client.call")
@patch("sabatron_tryton_rpc_client.client.Client.connect")
def test_import_duplicated_name_products(
self, mock_connect, mock_call
):
mock_connect.return_value = None
def fake_call(*args, **kwargs):
product_search = 'model.product.product.search'
search_args = [[["salable", "=", True]], 0, 1000, [['rec_name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (product_search, search_args)):
product_search = "model.product.product.search"
search_args = [
[["salable", "=", True]],
0,
1000,
[["rec_name", "ASC"], ["id", None]],
{"company": 1},
]
if args == (product_search, search_args):
return [200]
product_read = 'model.product.product.read'
product_args = ([200],
['id', 'name', 'default_uom.id',
'default_uom.rec_name', 'list_price'],
{'company': 1}
)
if (args == (product_read, product_args)):
product_read = "model.product.product.read"
product_args = (
[200],
[
"id",
"name",
"default_uom.id",
"default_uom.rec_name",
"list_price",
],
{"company": 1},
)
if args == (product_read, product_args):
return [
{'id': 200, 'list_price': Decimal('25000'),
'name': self.product.name,
'default_uom.': {'id': 1, 'rec_name': 'Unit'}},
{
"id": 200,
"list_price": Decimal("25000"),
"name": self.product.name,
"default_uom.": {"id": 1, "rec_name": "Unit"},
},
]
raise Exception(f"Sorry, args non expected on this test: {args}")
raise Exception(
f"Sorry, args non expected on this test: {args}"
)
mock_call.side_effect = fake_call
url = '/don_confiao/api/importar_productos_de_tryton'
url = "/don_confiao/api/importar_productos_de_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
content = json.loads(response.content.decode("utf-8"))
expected_response = {
'checked_tryton_products': [200],
'created_products': [],
'untouched_products': [],
'failed_products': [200],
'updated_products': [],
"checked_tryton_products": [200],
"created_products": [],
"untouched_products": [],
"failed_products": [200],
"updated_products": [],
}
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.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):
@@ -20,7 +22,7 @@ class ConfiaoTest(TestCase):
sale = Sale()
sale.customer = self.customer
sale.date = "2024-06-22 12:05:00"
sale.phone = '666666666'
sale.phone = "666666666"
sale.description = "Description"
sale.save()
@@ -30,9 +32,9 @@ class ConfiaoTest(TestCase):
sale = Sale()
sale.customer = self.customer
sale.date = "2024-06-22 12:05:00"
sale.phone = '666666666'
sale.phone = "666666666"
sale.description = "Description"
sale.payment_method = ''
sale.payment_method = ""
with self.assertRaises(ValidationError):
sale.full_clean()
@@ -41,7 +43,7 @@ class ConfiaoTest(TestCase):
sale = Sale()
sale.customer = self.customer
sale.date = "2024-06-22"
sale.phone = '666666666'
sale.phone = "666666666"
sale.description = "Description"
line = SaleLine()
@@ -58,7 +60,7 @@ class ConfiaoTest(TestCase):
sale = Sale()
sale.customer = self.customer
sale.date = "2024-06-22"
sale.phone = '666666666'
sale.phone = "666666666"
sale.description = "Description"
line1 = SaleLine()
@@ -81,15 +83,14 @@ class ConfiaoTest(TestCase):
self.assertEqual(len(SaleLine.objects.all()), 2)
self.assertEqual(
Sale.objects.all()[0].saleline_set.all()[0].quantity,
2
Sale.objects.all()[0].saleline_set.all()[0].quantity, 2
)
def test_allow_sale_without_description(self):
sale = Sale()
sale.customer = self.customer
sale.date = "2024-06-22"
sale.phone = '666666666'
sale.phone = "666666666"
sale.description = None
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 . 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.register(r'sales', api_views.SaleView, basename='sale')
router.register(r'customers', api_views.CustomerView, basename='customer')
router.register(r'products', api_views.ProductView, basename='product')
router.register(r'reconciliate_jar', api_views.ReconciliateJarModelView,
basename='reconciliate_jar')
router.register(r"sales", SaleView, basename="sale")
router.register(r"catalog_sales", CatalogSaleView, basename="catalog_sale")
router.register(r"customers", CustomerView, basename="customer")
router.register(r"products", ProductView, basename="product")
router.register(
r"catalogue_images",
CatalogueImageViewSet,
basename="catalogue_image",
)
router.register(
r"reconciliate_jar",
ReconciliateJarModelView,
basename="reconciliate_jar",
)
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("lista_productos", views.ProductListView.as_view(), name='product_list'),
path("importar_productos", views.import_products, name="import_products"),
path('api/importar_productos_de_tryton',
api_views.ProductsFromTrytonView.as_view(),
name="products_from_tryton"),
path("importar_terceros", views.import_customers, name="import_customers"),
path('api/importar_clientes_de_tryton',
api_views.CustomersFromTrytonView.as_view(),
name="customers_from_tryton"),
path("exportar_ventas_para_tryton",
views.exportar_ventas_para_tryton,
name="exportar_ventas_para_tryton"),
path('api/enviar_ventas_a_tryton', api_views.SalesToTrytonView.as_view(), name="send_tryton"),
path("resumen_compra/<int:id>", views.purchase_summary, name="purchase_summary"),
path("resumen_compra_json/<int:id>", api_views.SaleSummary.as_view(), name="purchase_json_summary"),
path("payment_methods/all/select_format", api_views.PaymentMethodView.as_view(), name="payment_methods_to_select"),
path('purchases/for_reconciliation', api_views.SalesForReconciliationView.as_view(), name='sales_for_reconciliation'),
path('reconciliate_jar', api_views.ReconciliateJarView.as_view()),
path('api/admin_code/validate/<code>', api_views.AdminCodeValidateView.as_view()),
path('api/sales/for_tryton', api_views.SalesForTrytonView.as_view()),
path('api/', include(router.urls)),
path(
"resumen_compra_json/<int:id>",
SaleSummary.as_view(),
name="purchase_json_summary",
),
path(
"resumen_compra_catalogo_json/<int:id>",
CatalogSaleSummary.as_view(),
name="catalog_purchase_json_summary",
),
path(
"payment_methods/all/select_format",
PaymentMethodView.as_view(),
name="payment_methods_to_select",
),
path(
"purchases/for_reconciliation",
SalesForReconciliationView.as_view(),
name="sales_for_reconciliation",
),
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.db import transaction
from .models import (
Sale, SaleLine, Product, Customer, ProductCategory, Payment, PaymentMethods, ReconciliationJar)
from .forms import (
ImportProductsForm,
ImportCustomersForm,
PurchaseForm,
SaleLineFormSet,
PurchaseSummaryForm)
from .models.sales import Sale, SaleLine
from .models.customers import Customer
from .models.sales import Sale, SaleLine, Payment
from .models.products import Product, ProductCategory
from .models.payments import PaymentMethods, ReconciliationJar
import csv
import io
@@ -26,105 +23,13 @@ class DecimalEncoder(json.JSONEncoder):
def index(request):
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)
return render(request, "don_confiao/index.html")
def products(request):
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="&"):
categories = categories_string.split(separator)
clean_categories = [c.strip() for c in categories]
@@ -136,33 +41,32 @@ def _category_from_name(name):
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='"')
for row in reader:
product, created = Product.objects.update_or_create(
name=row['producto'],
name=row["producto"],
defaults={
'price': row['precio'],
'measuring_unit': row['unidad']
}
"price": row["precio"],
"measuring_unit": row["unidad"],
},
)
categories = _categories_from_csv_string(row["categorias"])
product.categories.clear()
for category in categories:
product.categories.add(category)
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='"')
for row in reader:
customer, created = Customer.objects.update_or_create(
name=row['nombre'],
defaults={
'email': row['correo'],
'phone': row['telefono']
}
name=row["nombre"],
defaults={"email": row["correo"], "phone": row["telefono"]},
)
def sales_to_tryton_csv(sales):
tryton_sales_header = [
"Tercero",
@@ -182,7 +86,7 @@ def sales_to_tryton_csv(sales):
"Tienda",
"Terminal de venta",
"Autorecogida",
"Comentario"
"Comentario",
]
csv_data = [tryton_sales_header]
@@ -194,7 +98,7 @@ def sales_to_tryton_csv(sales):
first_sale_line = sale_lines[0]
customer_info = [sale.customer.name] * 3 + [sale.description] * 2
first_line = customer_info + [
sale.date.strftime('%Y-%m-%d'),
sale.date.strftime("%Y-%m-%d"),
"Contado",
"Almacén",
"Peso colombiano",
@@ -206,39 +110,21 @@ def sales_to_tryton_csv(sales):
"Tienda La Ilusion",
"La Ilusion",
True,
sale.description]
sale.description,
]
lines.append(first_line)
for line in sale_lines[1:]:
lines.append([""]*9+[
line.product.name,
line.quantity,
line.unit_price,
"Unidad"]+[""]*5)
lines.append(
[""] * 9
+ [
line.product.name,
line.quantity,
line.unit_price,
"Unidad",
]
+ [""] * 5
)
for row in lines:
csv_data.append(row)
return csv_data
def exportar_ventas_para_tryton(request):
if request.method == "GET":
response = HttpResponse(content_type='text/csv')
response['Content-Disposition'] = "attachment; filename=sales.csv"
sales = Sale.objects.all()
csv_data = sales_to_tryton_csv(sales)
writer = csv.writer(response)
for row in csv_data:
writer.writerow(row)
return response
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()