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)
This commit is contained in:
2026-05-29 00:01:29 -05:00
parent 7fe336b0ce
commit f526330f9e
6 changed files with 296 additions and 63 deletions

View File

@@ -14,8 +14,8 @@ post /token/
**** respuesta
#+begin_src json
{
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc4MDEwMzY0NiwiaWF0IjoxNzgwMDE3MjQ2LCJqdGkiOiI1NDk2NmQ0YTFmMGE0OWNjOGU5MGY5MmZmMTE0ZTMwZCIsInVzZXJfaWQiOiIxIn0.uWIe0Xm9i9eI4fFaM3Ha3FrIaQLfwvpHwbJue3OvhTo",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDE5MDQ2LCJpYXQiOjE3ODAwMTcyNDYsImp0aSI6IjQzYmYzOGM0ZWY3MTQ1YTk5ZjliMTQzODMyYjEwZmVkIiwidXNlcl9pZCI6IjEifQ.LMxWs0bHejpgcZvCpMCqfe5ue3YxAaWUweWHHoHhoH0"
"refresh": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoicmVmcmVzaCIsImV4cCI6MTc4MDExNTc0NywiaWF0IjoxNzgwMDI5MzQ3LCJqdGkiOiIxNmVjZGMxZmY4Y2Y0MzA4ODM3ZjM5Y2ZiNjQwNmZiMCIsInVzZXJfaWQiOiIxIn0.wmN-wp3Izv0NrfL_ap_i8eyg29w-foHNrQCCL6HoZWg",
"access": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA"
}
#+end_src
*** Perfil de usuario
@@ -47,7 +47,7 @@ post /token/refresh/
** Don confiao :verb:
template http://localhost:7000/don_confiao/api/
Content-Type: application/json;
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDE5MDQ2LCJpYXQiOjE3ODAwMTcyNDYsImp0aSI6IjQzYmYzOGM0ZWY3MTQ1YTk5ZjliMTQzODMyYjEwZmVkIiwidXNlcl9pZCI6IjEifQ.LMxWs0bHejpgcZvCpMCqfe5ue3YxAaWUweWHHoHhoH0
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0b2tlbl90eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzgwMDMxMTQ3LCJpYXQiOjE3ODAwMjkzNDcsImp0aSI6ImQ4ZjE1YTRmODc5NzRjZGViZDEzYzc1ZTU4ZDk3ZjEwIiwidXNlcl9pZCI6IjEifQ.kkQVT2pcYeS_TxlJ6QPU3rNOlZhOv96pyqVEGJI85KA
*** todas las rutas
get
**** response
@@ -79,6 +79,22 @@ get customers/
*** 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/

View File

@@ -95,6 +95,34 @@ 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 CustomerView(viewsets.ModelViewSet):
queryset = Customer.objects.all()
@@ -120,21 +148,15 @@ class ReconciliateJarView(APIView):
status=HTTP_400_BAD_REQUEST,
)
reconciliation = serializer.save()
other_purchases = self._get_other_purchases(
data.get("other_totals")
)
other_purchases = self._get_other_purchases(data.get("other_totals"))
self._link_purchases(
reconciliation, cash_purchases, other_purchases
)
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
)
serializer = ReconciliationJarSerializer(reconciliations, many=True)
return Response(serializer.data)
def _is_valid_total(self, purchases, total):
@@ -153,9 +175,7 @@ class ReconciliateJarView(APIView):
return Sale.objects.filter(pk__in=purchases)
return []
def _link_purchases(
self, reconciliation, cash_purchases, other_purchases
):
def _link_purchases(self, reconciliation, cash_purchases, other_purchases):
for purchase in cash_purchases:
purchase.reconciliation = reconciliation
purchase.clean()
@@ -169,9 +189,7 @@ class ReconciliateJarView(APIView):
class PaymentMethodView(APIView):
def get(self, request):
serializer = PaymentMethodSerializer(
PaymentMethods.choices, many=True
)
serializer = PaymentMethodSerializer(PaymentMethods.choices, many=True)
return Response(serializer.data)
@@ -255,23 +273,17 @@ class SalesToTrytonView(APIView):
for sale in sales:
try:
lines = SaleLine.objects.filter(sale=sale.id)
tryton_params = self.__to_tryton_params(
sale, lines, tryton_context
)
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}"
)
print(f"Error al enviar la venta: {e}venta_id: {sale.id}")
failed.append(sale.id)
continue
return Response(
{"successful": successful, "failed": failed}, status=200
)
return Response({"successful": successful, "failed": failed}, status=200)
def __to_tryton_params(self, sale, lines, tryton_context):
sale_tryton = TrytonSale(sale, lines)
@@ -279,7 +291,6 @@ class SalesToTrytonView(APIView):
class TrytonSale:
def __init__(self, sale, lines):
self.sale = sale
self.lines = lines
@@ -299,18 +310,14 @@ class TrytonSale:
"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 ""),
"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
],
[TrytonLineSale(line).to_tryton() for line in self.lines],
]
],
"self_pick_up": True,
@@ -366,9 +373,7 @@ class ProductsFromTrytonView(APIView):
for tryton_product in tryton_products:
try:
product = Product.objects.get(
external_id=tryton_product.get("id")
)
product = Product.objects.get(external_id=tryton_product.get("id"))
except Product.DoesNotExist:
try:
product = self.__create_product(tryton_product)
@@ -376,8 +381,7 @@ class ProductsFromTrytonView(APIView):
continue
except Exception as e:
print(
f"Error al importar productos: {e}"
f"El producto: {tryton_product}"
f"Error al importar productos: {e}El producto: {tryton_product}"
)
failed_products.append(tryton_product.get("id"))
continue
@@ -399,9 +403,7 @@ class ProductsFromTrytonView(APIView):
status=200,
)
def __get_product_datails_from_tryton(
self, product_ids, tryton_client, context
):
def __get_product_datails_from_tryton(self, product_ids, tryton_client, context):
tryton_fields = [
"id",
"name",
@@ -459,9 +461,7 @@ class CustomersFromTrytonView(APIView):
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
)
tryton_parties = self.__get_party_datails(party_ids, tryton_client, context)
checked_tryton_parties = party_ids
failed_parties = []
updated_customers = []
@@ -470,9 +470,7 @@ class CustomersFromTrytonView(APIView):
for tryton_party in tryton_parties:
try:
customer = Customer.objects.get(
external_id=tryton_party.get("id")
)
customer = Customer.objects.get(external_id=tryton_party.get("id"))
except Customer.DoesNotExist:
customer = self.__create_customer(tryton_party)
created_customers.append(customer.id)
@@ -504,10 +502,7 @@ 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 tryton_party.get("addresses") and tryton_party.get("addresses")[0]:
if not customer.address_external_id == str(
tryton_party.get("addresses")[0]
):
@@ -517,10 +512,7 @@ class CustomersFromTrytonView(APIView):
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]
):
if tryton_party.get("addresses") and tryton_party.get("addresses")[0]:
customer.address_external_id = tryton_party.get("addresses")[0]
customer.save()
return customer
@@ -528,9 +520,6 @@ class CustomersFromTrytonView(APIView):
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]
):
if tryton_party.get("addresses") and tryton_party.get("addresses")[0]:
customer.address_external_id = tryton_party.get("addresses")[0]
customer.save()

View File

@@ -0,0 +1,18 @@
# Generated by Django 5.0.6 on 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

@@ -14,6 +14,7 @@ class ProductCategory(models.Model):
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(

View File

@@ -82,6 +82,7 @@ class ProductSerializer(serializers.ModelSerializer):
fields = [
"id",
"name",
"active",
"price",
"measuring_unit",
"categories",

View File

@@ -1,6 +1,9 @@
from django.test import Client, TestCase
from django.conf import settings
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
@@ -36,6 +39,211 @@ class TestProducts(TestCase):
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}
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)