43 Commits

Author SHA1 Message Date
1d7beadf96 Merge pull request 'Agregar método de pago crédito #20' (#36) from add_credit_pay_method_#20 into main
Reviewed-on: #36
2026-03-14 23:46:27 -05:00
mono
5442a52ff6 feat(Payment): add CREDIT payment method 2026-03-14 23:45:52 -05:00
b852772c76 Merge pull request 'Agregando rol administrativo #31' (#35) from add_administrative_rol_#31 into main
Reviewed-on: #35
2026-03-14 18:26:31 -05:00
mono
0d5a34d366 feat: add IsAdministrator permission and protect admin endpoints 2026-03-14 16:37:44 -05:00
mono
7e2c03c81b test: add tests for user role field 2026-03-14 16:27:44 -05:00
mono
83029afd5b feat: add role field to user serializer for menu control
- Endpoint /me/ now returns 'role': 'administrator' or 'role': 'user'
- Uses Django's is_staff to determine administrator role
2026-03-07 17:58:12 -05:00
mono
2ab328b913 docs: add AGENTS.md with project context for opencode 2026-03-07 17:52:48 -05:00
a05061c14e Merge pull request 'Cambiando autenticación de api a JWT #29' (#33) from add_jwt_authentication_#29 into main
Reviewed-on: #33
2026-02-14 15:57:03 -05:00
7c0047b4d3 fix: rm duplicate file. 2026-02-14 15:55:59 -05:00
c021104b62 doc(API): add sample requests. 2026-02-14 15:50:30 -05:00
7a9034943a feat(API): change to jwt authentication. 2026-02-14 15:12:32 -05:00
4812160ea2 Merge pull request 'Se adiciona autenticación a django' (#30) from add_authentication_#29 into main
Reviewed-on: #30
2025-12-13 17:35:34 -05:00
fb4c82a94c #29 refactor(Tests): extract to mixin class. 2025-12-13 17:35:20 -05:00
fb3124246c #29 refactor(Tests): extract to mixin class. 2025-12-13 17:34:40 -05:00
f3d3681bc4 #29 fix(Tests): add auth to tests. 2025-12-13 17:10:00 -05:00
e6d2160d2e #29 feat(Auth): add logout and profile. 2025-12-13 16:31:20 -05:00
f323873d80 #29 feat(Auth): add logout and profile. 2025-12-13 16:30:53 -05:00
b730d24855 #29 feat(Auth): add login. 2025-12-13 12:24:35 -05:00
6261d64206 Merge pull request '#27 fix: reconciliation jar.' (#28) from test_decimal_error_jar_#27 into main
Reviewed-on: #28
2025-11-15 17:51:31 -05:00
64f07a2ce2 #27 fix: reconciliation jar. 2025-11-15 17:50:06 -05:00
308e2d08c1 Merge pull request 'feat(tryton): add comment to tryton sale.' (#26) from add_payment_method_to_tryton_sale_on_notes_field_#21 into main
Reviewed-on: #26
2025-11-08 15:38:44 -05:00
e1ff427856 feat(tryton): add comment to tryton sale. 2025-11-08 15:34:45 -05:00
bf70c47551 Merge pull request 'Se adiciona el método de pago en la descripción de la venta de tryton #21' (#25) from add_payment_method_to_tryton_sale_on_notes_field_#21 into main
Reviewed-on: #25
2025-09-05 20:26:10 -05:00
f02f754ae7 feat(tryton): add payment method to tryton sale description. 2025-09-05 20:24:13 -05:00
1668a37091 Merge pull request 'Adicionando autorecogida a las ventas que se envian a tryton #23' (#24) from add_self_pick_up_to_sales_in_tryton_#23 into main
Reviewed-on: #24
2025-09-05 19:24:54 -05:00
b33937d4a5 feat(Tryton): add self_pick_up in send sale to tryton. 2025-09-05 19:15:26 -05:00
a265b94460 Merge pull request 'Enviando ventas a Tryton así alguna de las ventas falle #16' (#19) from send_sales_to_tryton_in_a_no_bloqueant_way_#16 into main
Reviewed-on: #19
2025-08-30 15:48:26 -05:00
253fcbae27 fix(Tryton): add try except at send sales to tryton. #16 2025-08-30 15:45:13 -05:00
d127609508 Merge pull request 'Adicionando external id en venta y customers en la API #17' (#18) from add_external_id_in_api_sale_fields_#17 into main
Reviewed-on: #18
2025-08-30 15:32:57 -05:00
604bbd3ab9 #17 feat(API): add external id to sales on api. 2025-08-30 15:28:50 -05:00
e17b8f6973 style. 2025-08-30 15:25:33 -05:00
e3f571afc5 #17 feat(API): add external id to customers on api. 2025-08-30 15:24:42 -05:00
477405a094 Merge pull request 'agregada direccion de envio y facturación al enviar las compras a tryton #14' (#15) from add_shipment_address_to_tryton_sale_#14 into main
Reviewed-on: #15
2025-08-16 16:37:46 -05:00
4dae669397 #14 fix(Tryton): add address on updated customers from tryton. 2025-08-16 16:36:06 -05:00
937fe06de4 #14 feat(Tryton): add address on update customers from tryton. 2025-08-16 16:09:20 -05:00
69185f2460 fix(Tryton Shop): add shops. 2025-08-16 12:24:05 -05:00
7ac28154eb Merge branch 'handle_duplicate_product_name_from_tryton_#11' 2025-08-16 10:32:32 -05:00
e7eda79c69 Merge branch 'main' into handle_duplicate_product_name_from_tryton_#11 2025-08-16 10:28:28 -05:00
5f40b4098c feat(Tryton): handle duplicate named products from tryton. 2025-08-16 10:27:17 -05:00
rodia
80864137b6 Merge branch 'main' of https://gitea.onecluster.org/OneTeam/don_confiao_backend 2025-08-16 12:10:27 -03:00
rodia
2e8e956b69 feat: Add env_example 2025-08-16 12:09:41 -03:00
2e4c6592a3 fix test. 2025-08-16 09:55:20 -05:00
6b149b0134 Merge pull request 'Exportando ventas directamente al tryton' (#12) from export_sales_to_tryton_#9 into main
Reviewed-on: #12
2025-08-09 15:39:40 -05:00
29 changed files with 633 additions and 63 deletions

4
.env_example Normal file
View File

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

108
AGENTS.md Normal file
View File

@@ -0,0 +1,108 @@
# Don Confiao Backend - Contexto del Proyecto
## Tipo de Proyecto
Backend Django con Django REST Framework
## Estructura del Proyecto
```
don_confiao_backend/
├── requirements.txt # Dependencias Python
├── docker-compose.yml # Configuración Docker
├── django.Dockerfile # Dockerfile Django
├── .env # Variables de entorno
├── .env_example # Ejemplo de variables de entorno
├── README.rst # Documentación básica
├── Rakefile # Tareas rake
├── doc/ # Documentación adicional
│ └── requests.org
└── tienda_ilusion/ # Proyecto Django
├── manage.py
├── db.sqlite3 # Base de datos SQLite
├── don_confiao/ # App principal
│ ├── models.py # Modelos: Customer, Product, Sale, SaleLine, Payment, ReconciliationJar, AdminCode
│ ├── views.py
│ ├── api_views.py
│ ├── serializers.py
│ ├── forms.py
│ ├── admin.py
│ ├── urls.py
│ ├── export_csv.py
│ ├── tests/ # Tests
│ └── migrations/
├── users/ # App de usuarios
│ ├── models.py
│ ├── views.py
│ ├── serializers.py
│ ├── urls.py
│ └── tests/
└── tienda_ilusion/ # Configuración Django
├── settings.py
├── urls.py
├── wsgi.py
└── asgi.py
```
## Dependencias Principales
- Django==5.0.6
- djangorestframework
- django-cors-headers
- djangorestframework-simplejwt
- sabatron-tryton-rpc-client==7.4.0 (integración con Tryton ERP)
## Modelos Principales (don_confiao/models.py)
- **Customer**: Clientes (name, address, email, phone, external_id)
- **Product**: Productos (name, price, measuring_unit, categories)
- **ProductCategory**: Categorías de productos
- **Sale**: Ventas (customer, date, phone, description, payment_method, reconciliation)
- **SaleLine**: Líneas de venta (sale, product, quantity, unit_price, description)
- **Payment**: Pagos (date_time, type_payment, amount, reconciliation_jar)
- **PaymentSale**: Relación muchos a muchos entre Payment y Sale
- **ReconciliationJar**: Arqueo de caja (is_valid, date_time, reconcilier, cash_taken, cash_discrepancy)
- **AdminCode**: Códigos de administrador
## Autenticación
- JWT con djangorestframework-simplejwt
- ACCESS_TOKEN_LIFETIME: 30 minutos
- REFRESH_TOKEN_LIFETIME: 1 día
## API Endpoints
- REST API en don_confiao/api_views.py y users/
- URLs en don_confiao/urls.py y users/urls.py
## Ejecución con Docker Compose
El proyecto se ejecuta con docker-compose. Todos los comandos `manage.py` deben ejecutarse dentro del contenedor:
```bash
# Ejecutar tests
docker-compose run --rm django python manage.py test
# Migraciones
docker-compose run --rm django python manage.py makemigrations
docker-compose run --rm django python manage.py migrate
# Servidor desarrollo
docker-compose up
# Shell Django
docker-compose run --rm django python manage.py shell
# Crear superuser
docker-compose run --rm django python manage.py createsuperuser
```
Nota: El volumen monta `tienda_ilusion/` en `/app/`, por lo que el path correcto es `python manage.py` (no `python tienda_ilusion/manage.py`).
## Tests
- Framework: Django unittest
- Directorio: don_confiao/tests/
- Ejecutar: `docker-compose run --rm django python manage.py test`
## Comandos Útiles (dentro del contenedor)
- Migraciones: `docker-compose run --rm django python manage.py makemigrations && docker-compose run --rm django python manage.py migrate`
- Servidor desarrollo: `docker-compose up`
- Shell Django: `docker-compose run --rm django python manage.py shell`
- Superuser: `docker-compose run --rm django python manage.py createsuperuser`
## Integraciones
- **Tryton ERP**: Integración mediante sabatron-tryton-rpc-client para sincronización de clientes, productos y ventas

76
doc/requests.org Normal file
View File

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

View File

@@ -1,4 +1,5 @@
Django==5.0.6
djangorestframework
django-cors-headers
djangorestframework-simplejwt
sabatron-tryton-rpc-client==7.4.0

View File

@@ -3,10 +3,12 @@ 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
@@ -18,6 +20,9 @@ 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):
@@ -35,10 +40,12 @@ class SaleView(viewsets.ModelViewSet):
date = data['date']
lines = data['saleline_set']
payment_method = data['payment_method']
description = data.get('notes', '')
sale = Sale.objects.create(
customer=customer,
date=date,
payment_method=payment_method
payment_method=payment_method,
description=description
)
for line in lines:
@@ -69,6 +76,8 @@ class CustomerView(viewsets.ModelViewSet):
class ReconciliateJarView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
data = request.data
cash_purchases_id = data.get('cash_purchases')
@@ -94,7 +103,8 @@ class ReconciliateJarView(APIView):
def _is_valid_total(self, purchases, total):
calculated_total = sum(p.get_total() for p in purchases)
return calculated_total == Decimal(total)
return Decimal(calculated_total).quantize(Decimal('.0001')) == (
Decimal(total).quantize(Decimal('.0001')))
def _get_other_purchases(self, other_totals):
if not other_totals:
@@ -125,6 +135,8 @@ class PaymentMethodView(APIView):
class SalesForReconciliationView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request):
sales = Sale.objects.filter(reconciliation=None)
grouped_sales = {}
@@ -146,6 +158,8 @@ class SaleSummary(APIView):
class AdminCodeValidateView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def get(self, request, code):
codes = AdminCode.objects.filter(value=code)
return Response({'validCode': bool(codes)})
@@ -155,9 +169,12 @@ 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)
@@ -174,6 +191,8 @@ class SalesForTrytonView(APIView):
class SalesToTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
@@ -183,19 +202,26 @@ class SalesToTrytonView(APIView):
)
tryton_client.connect()
method = 'model.sale.sale.create'
tryton_context = {}
tryton_context = {'company': TRYTON_COMPANY_ID,
'shops': TRYTON_SHOPS}
successful = []
failed = []
sales = Sale.objects.filter(external_id=None)
for sale in sales:
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)
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},
@@ -219,18 +245,22 @@ class TrytonSale:
def to_tryton(self):
return {
"company": 1,
"company": TRYTON_COMPANY_ID,
"shipment_address": self.sale.customer.address_external_id,
"invoice_address": self.sale.customer.address_external_id,
"currency": 1,
"description": self.sale.description or '',
"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,
}
@@ -252,6 +282,8 @@ class TrytonLineSale:
class ProductsFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
@@ -279,9 +311,16 @@ class ProductsFromTrytonView(APIView):
external_id=tryton_product.get('id')
)
except Product.DoesNotExist:
product = self.__create_product(tryton_product)
created_products.append(product.id)
continue
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)
@@ -338,6 +377,8 @@ class ProductsFromTrytonView(APIView):
class CustomersFromTrytonView(APIView):
permission_classes = [IsAuthenticated, IsAdministrator]
def post(self, request):
tryton_client = Client(
hostname=TRYTON_HOST,
@@ -358,8 +399,6 @@ class CustomersFromTrytonView(APIView):
updated_customers = []
created_customers = []
untouched_customers = []
print('aqui')
print(tryton_parties)
for tryton_party in tryton_parties:
try:
@@ -388,7 +427,7 @@ class CustomersFromTrytonView(APIView):
)
def __get_party_datails(self, party_ids, tryton_client, context):
tryton_fields = ['id', 'name']
tryton_fields = ['id', 'name', 'addresses']
method = 'model.party.party.read'
params = (party_ids, tryton_fields, context)
response = tryton_client.call(method, params)
@@ -397,15 +436,22 @@ class CustomersFromTrytonView(APIView):
def __need_update(self, customer, tryton_party):
if not customer.name == tryton_party.get('name'):
return True
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
if not customer.address_external_id == str(tryton_party.get('addresses')[0]):
return True
def __create_customer(self, tryton_party):
customer = Customer()
customer.name = tryton_party.get('name')
customer.external_id = tryton_party.get('id')
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
customer.address_external_id = tryton_party.get('addresses')[0]
customer.save()
return customer
def __update_customer(self, customer, tryton_party):
customer.name = tryton_party.get('name')
customer.external_id = tryton_party.get('id')
if tryton_party.get('addresses') and tryton_party.get('addresses')[0]:
customer.address_external_id = tryton_party.get('addresses')[0]
customer.save()

View File

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

View File

@@ -10,6 +10,7 @@ class PaymentMethods(models.TextChoices):
CASH = 'CASH', _('Efectivo')
CONFIAR = 'CONFIAR', _('Confiar')
BANCOLOMBIA = 'BANCOLOMBIA', _('Bancolombia')
CREDIT = 'CREDIT', _('Crédito')
class Customer(models.Model):

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,21 +2,24 @@ import json
import csv
import io
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from ..models import Sale, Product, Customer
from .Mixins import LoginMixin
class TestAPI(APITestCase):
class TestAPI(APITestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
measuring_unit='UNIT'
)
self.customer = Customer.objects.create(
name='Camilo'
name='Camilo',
external_id='18'
)
def test_create_sale(self):
@@ -67,6 +70,23 @@ class TestAPI(APITestCase):
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.name, json_response[0]['name'])
self.assertEqual(
self.customer.external_id,
json_response[0]['external_id']
)
def test_get_sales(self):
url = '/don_confiao/api/sales/'
self._create_sale()
response = self.client.get(url)
json_response = json.loads(response.content.decode('utf-8'))
self.assertEqual(response.status_code, 200)
self.assertEqual(self.customer.id, json_response[0]['customer'])
self.assertEqual(
None,
json_response[0]['external_id']
)
def test_get_sales_for_tryton(self):
url = '/don_confiao/api/sales/for_tryton'

View File

@@ -1,12 +1,15 @@
import json
from unittest.mock import patch
from django.test import Client, TestCase
from django.test import TestCase
from ..models import Customer
from .Mixins import LoginMixin
class TestCustomersFromTryton(TestCase):
class TestCustomersFromTryton(TestCase, LoginMixin):
def setUp(self):
self.login()
self.customer = Customer.objects.create(
name='Calos',
external_id=5
@@ -24,19 +27,19 @@ class TestCustomersFromTryton(TestCase):
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, [['rec_name', 'ASC'], ['id', None]], {'company': 1}]
search_args = [[], 0, 1000, [['name', 'ASC'], ['id', None]], {'company': 1}]
if (args == (party_search, search_args)):
return [5, 6, 7, 8]
party_read = 'model.party.party.read'
read_args = ([5, 6, 7, 8], ['id', 'name'], {'company': 1})
read_args = ([5, 6, 7, 8], ['id', 'name', 'addresses'], {'company': 1})
if (args == (party_read, read_args)):
return [
{'id': 5, 'name': 'Carlos'},
{'id': 6, 'name': 'Cristian'},
{'id': 7, 'name': 'Ana'},
{'id': 8, 'name': 'José'},
{'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}")
@@ -60,7 +63,9 @@ class TestCustomersFromTryton(TestCase):
created_customer = Customer.objects.get(id=3)
self.assertEqual(created_customer.external_id, str(7))
self.assertEqual(created_customer.name, 'Ana')
self.assertEqual(created_customer.address_external_id, str(302))
updated_customer = Customer.objects.get(id=1)
self.assertEqual(updated_customer.external_id, str(5))
self.assertEqual(updated_customer.name, 'Carlos')
self.assertIn(updated_customer.address_external_id, str(303))

View File

@@ -2,13 +2,16 @@ import csv
import json
from unittest.mock import patch
from django.test import TestCase, Client
from django.urls import reverse
from django.test import TestCase
from ..models import Sale, SaleLine, Product, Customer
from .Mixins import LoginMixin
class TestExportarVentasParaTryton(TestCase):
class TestExportarVentasParaTryton(TestCase, LoginMixin):
def setUp(self):
self.login()
self.product = Product.objects.create(
name='Panela',
price=5000,
@@ -19,12 +22,13 @@ class TestExportarVentasParaTryton(TestCase):
self.customer = Customer.objects.create(
name='Camilo',
external_id=1,
address_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,
@@ -40,9 +44,8 @@ class TestExportarVentasParaTryton(TestCase):
)
def test_exportar_ventas_para_tryton(self):
client = Client()
url = '/don_confiao/exportar_ventas_para_tryton'
response = client.get(url)
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')
@@ -71,7 +74,7 @@ class TestExportarVentasParaTryton(TestCase):
self.assertEqual(next(csv_reader), expected_header)
expected_rows = [
["Camilo", "Camilo", "Camilo", "", "", "2024-09-02", "Contado", "Almacén", "Peso colombiano", "Panela", "2.00", "3000.00", "Unidad", "TIENDA LA ILUSIÓN", "Tienda La Ilusion", "La Ilusion", "True", ""],
["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)
@@ -81,12 +84,11 @@ class TestExportarVentasParaTryton(TestCase):
@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):
client = Client()
external_id = '23423'
url = '/don_confiao/api/enviar_ventas_a_tryton'
mock_connect.return_value = None
mock_call.return_value = [external_id]
response = client.post(url)
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode('utf-8'))
@@ -100,4 +102,4 @@ class TestExportarVentasParaTryton(TestCase):
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': '1', 'invoice_address': '1', 'currency': 1, 'description': '', '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'}}]]]}], {}])
mock_call.assert_called_with('model.sale.sale.create', [[{'company': 1, 'shipment_address': '307', 'invoice_address': '307', 'currency': 31, 'comment': 'un comentario', 'description': 'Metodo pago: CASH', 'party': '1', 'reference': 'don_confiao 1', 'sale_date': {'__class__': 'date', 'year': 2024, 'month': 9, 'day': 2}, 'lines': [['create', [{'product': '1', 'quantity': {'__class__': 'Decimal', 'decimal': '2.00'}, 'type': 'line', 'unit': '1', 'unit_price': {'__class__': 'Decimal', 'decimal': '3000.00'}}, {'product': '1', 'quantity': {'__class__': 'Decimal', 'decimal': '3.00'}, 'type': 'line', 'unit': '1', 'unit_price': {'__class__': 'Decimal', 'decimal': '5000.00'}}]]], 'self_pick_up': True}], {'company': 1, 'shops': [1]}])

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ https://docs.djangoproject.com/en/5.0/ref/settings/
"""
import os
from datetime import timedelta
from pathlib import Path
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -44,7 +45,9 @@ INSTALLED_APPS = [
'django.contrib.messages',
'django.contrib.staticfiles',
'rest_framework',
'rest_framework.authtoken',
'corsheaders',
'users',
# 'don_confiao'
]
@@ -57,7 +60,6 @@ MIDDLEWARE = [
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
'corsheaders.middleware.CorsMiddleware',
'django.middleware.common.CommonMiddleware',
]
ROOT_URLCONF = 'tienda_ilusion.urls'
@@ -65,7 +67,7 @@ ROOT_URLCONF = 'tienda_ilusion.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [],
'DIRS': [os.path.join(BASE_DIR, 'tienda_ilusion/templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
@@ -134,3 +136,22 @@ STATIC_URL = 'static/'
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
FIXTURE_DIRS = ['don_confiao/tests/Fixtures']
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
"rest_framework_simplejwt.authentication.JWTAuthentication",
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.IsAuthenticated',
],
}
SIMPLE_JWT = {
"ACCESS_TOKEN_LIFETIME": timedelta(minutes=30),
"REFRESH_TOKEN_LIFETIME": timedelta(days=1),
"AUTH_HEADER_TYPES": ("Bearer",),
}
# CORS_ALLOWED_ORIGINS = [
# "http://localhost:5173",
# ]

View File

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

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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