12 Commits

Author SHA1 Message Date
4410cc919a Merge pull request 'test(Tryton): add catalog sale test and validate build_tryton_comment' (#44) from fix/EnviarComentarioConDetalleDelPedido into main
Reviewed-on: #44
2026-07-11 18:56:05 -05:00
d1c2b4f1bb test(Tryton): add catalog sale test and validate build_tryton_comment
- Added CatalogSale/CatalogSaleLine to setUp
- Added explicit assertion linking TrytonSale.build_tryton_comment()
  to sale.description
- Rewrote test_send_catalog_sales_to_tryton with proper CatalogSale
  data, validating comment format, description, reference and lines
- Added direct assertion of TrytonCatalogSale.build_tryton_comment()
2026-07-11 14:55:44 -05:00
d57b827e4e Merge pull request 'fix(Tryton): restore send sales test and fix TrytonSale.build_tryton_comment' (#43) from fix/EnviarComentarioConDetalleDelPedido into main
Reviewed-on: #43
2026-07-11 14:38:27 -05:00
1e32cfce7f fix(Tryton): restore send sales test and fix TrytonSale.build_tryton_comment
- Restored test_exportar_ventas_para_tryton.py with full parameter
  validation for client.call (method, context, sale fields, lines)
- Fixed TrytonSale.build_tryton_comment referencing self.catalog_sale
  (nonexistent) instead of self.sale.description
2026-07-11 14:32:41 -05:00
f65b85c7e7 feat: Add custom views to admin registered models 2026-07-05 11:08:33 -05:00
ace5091f8b feat: Update admin product view list. 2026-07-03 19:23:55 -05:00
de7c32b10f Merge pull request 'feat: add script to re-upload catalogue images and force resize' (#42) from feat/reupload-catalogue-images into main
Reviewed-on: #42
2026-07-03 18:38:31 -05:00
mono
b93ee95427 feat: add script to re-upload catalogue images and force resize 2026-07-03 18:26:42 -05:00
4fcc7c2503 fix: CatalogSaleSummary accesible para usuarios 'publico'
- Cambia permission_classes de IsNotPublico a IsAuthenticated
- El usuario publico necesita ver el resumen de su compra en catálogo
2026-06-22 12:49:04 -05:00
5b373bd15f feat: agrega rol 'publico' con UserProfile y permisos diferenciados
- Crea modelo UserProfile (OneToOne con User) con user_type: publico/user/administrator
- Señal post_save que crea UserProfile automáticamente al crear User
- UserSerializer.get_role() lee desde profile.user_type
- Nuevos permisos: IsNotPublico, IsPublico y actualiza IsAdministrator
- IsNotPublico restringe acceso a vistas de gestión (productos, clientes, ventas)
- CatalogSaleView.create permite publico; otras acciones requieren IsNotPublico
- UserProfile inline en admin de Usuarios
- Data migration para backfill de usuarios existentes
2026-06-22 12:27:23 -05:00
27b68a9ac2 chore: configuración de infraestructura y dependencias
- docker-compose.staging.yml: env vars a env_file, limpia defaults duplicados
- docker-compose.prod.yml: descomenta y activa servicio nginx
- staging.py: DEBUG=True, POSTGRES_* env vars, logging DEBUG
- pyproject.toml + poetry.lock: agrega pillow y whitenoise
2026-06-22 11:35:47 -05:00
d6b7fe428f feat: Update dependencies. 2026-06-22 11:28:06 -05:00
20 changed files with 751 additions and 48 deletions

View File

@@ -2,16 +2,12 @@ services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: tienda_ilusion_postgres_staging container_name: tienda_ilusion_postgres_staging
environment:
- POSTGRES_USER=${DB_USER:-tienda_ilusion_user}
- POSTGRES_DB=${DB_NAME:-tienda_ilusion_staging}
- POSTGRES_PASSWORD=${DB_PASSWORD:-staging_local_password}
env_file: env_file:
- .env.staging - .env.staging
volumes: volumes:
- postgres_staging_data:/var/lib/postgresql/data - postgres_staging_data:/var/lib/postgresql/data
ports: ports:
- "5433:5432" # Puerto diferente para no conflictuar con prod - "5433:5432"
networks: networks:
- tienda_staging_network - tienda_staging_network
restart: unless-stopped restart: unless-stopped
@@ -35,9 +31,6 @@ services:
environment: environment:
- DJANGO_ENV=staging - DJANGO_ENV=staging
- DB_HOST=postgres - DB_HOST=postgres
- DB_USER=${DB_USER:-tienda_ilusion_user}
- DB_NAME=${DB_NAME:-tienda_ilusion_staging}
- DB_PASSWORD=${DB_PASSWORD:-staging_local_password}
volumes: volumes:
- ./tienda_ilusion:/app/ - ./tienda_ilusion:/app/
- static_staging_volume:/app/staticfiles - static_staging_volume:/app/staticfiles

126
poetry.lock generated
View File

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

View File

@@ -12,7 +12,9 @@ dependencies = [
"djangorestframework (>=3.17.1,<4.0.0)", "djangorestframework (>=3.17.1,<4.0.0)",
"django-cors-headers (>=4.9.0,<5.0.0)", "django-cors-headers (>=4.9.0,<5.0.0)",
"djangorestframework-simplejwt (>=5.5.1,<6.0.0)", "djangorestframework-simplejwt (>=5.5.1,<6.0.0)",
"sabatron-tryton-rpc-client (>=7.4.0,<8.0.0)" "sabatron-tryton-rpc-client (>=7.4.0,<8.0.0)",
"pillow (>=12.2.0,<13.0.0)",
"whitenoise (>=6.12.0,<7.0.0)"
] ]

View File

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

View File

@@ -12,7 +12,7 @@ import os
from .base import * from .base import *
# SECURITY WARNING: DEBUG is False to simulate production behavior # SECURITY WARNING: DEBUG is False to simulate production behavior
DEBUG = False DEBUG = True
# SECRET_KEY for staging (use a fixed key for local staging, not for real production) # SECRET_KEY for staging (use a fixed key for local staging, not for real production)
SECRET_KEY = os.environ.get( SECRET_KEY = os.environ.get(
@@ -21,9 +21,9 @@ SECRET_KEY = os.environ.get(
) )
# Allow localhost for staging # Allow localhost for staging
ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0").split( ALLOWED_HOSTS = os.environ.get(
"," "ALLOWED_HOSTS", "localhost,127.0.0.1,0.0.0.0"
) ).split(",")
# CORS settings for staging - permissive for localhost testing # CORS settings for staging - permissive for localhost testing
CORS_ALLOWED_ORIGINS = os.environ.get( CORS_ALLOWED_ORIGINS = os.environ.get(
@@ -52,9 +52,11 @@ CORS_EXPOSE_HEADERS = ["Content-Type", "X-CSRFToken"]
DATABASES = { DATABASES = {
"default": { "default": {
"ENGINE": "django.db.backends.postgresql", "ENGINE": "django.db.backends.postgresql",
"NAME": os.environ.get("DB_NAME", "tienda_ilusion_staging"), "NAME": os.environ.get("POSTGRES_DB", "tienda_ilusion_staging"),
"USER": os.environ.get("DB_USER", "tienda_ilusion_user"), "USER": os.environ.get("POSTGRES_USER", "tienda_ilusion_user"),
"PASSWORD": os.environ.get("DB_PASSWORD", "staging_local_password"), "PASSWORD": os.environ.get(
"POSTGRES_PASSWORD", "staging_local_password"
),
"HOST": os.environ.get("DB_HOST", "localhost"), "HOST": os.environ.get("DB_HOST", "localhost"),
"PORT": os.environ.get("DB_PORT", "5432"), "PORT": os.environ.get("DB_PORT", "5432"),
"CONN_MAX_AGE": 600, # Persistent connections "CONN_MAX_AGE": 600, # Persistent connections
@@ -117,7 +119,9 @@ WHITENOISE_AUTOREFRESH = (
) )
WHITENOISE_USE_FINDERS = False # Use collected static files only WHITENOISE_USE_FINDERS = False # Use collected static files only
WHITENOISE_MANIFEST_STRICT = False # More permissive in staging WHITENOISE_MANIFEST_STRICT = False # More permissive in staging
WHITENOISE_MAX_AGE = 600 # 10 minutes cache for staging (allows easier testing) WHITENOISE_MAX_AGE = (
600 # 10 minutes cache for staging (allows easier testing)
)
# Staging logging - similar to production but more verbose # Staging logging - similar to production but more verbose
LOGGING = { LOGGING = {
@@ -148,7 +152,7 @@ LOGGING = {
}, },
"root": { "root": {
"handlers": ["console", "file"], "handlers": ["console", "file"],
"level": "INFO", "level": "DEBUG",
}, },
"loggers": { "loggers": {
"django": { "django": {
@@ -163,7 +167,7 @@ LOGGING = {
}, },
"django.db.backends": { "django.db.backends": {
"handlers": ["console"], "handlers": ["console"],
"level": "INFO", # Show SQL queries in staging for debugging "level": "DEBUG", # Show SQL queries in staging for debugging
"propagate": False, "propagate": False,
}, },
}, },
@@ -177,7 +181,10 @@ pathlib.Path(logs_dir).mkdir(parents=True, exist_ok=True)
# Admin email notifications # Admin email notifications
ADMINS = [ ADMINS = [
("Staging Admin", os.environ.get("ADMIN_EMAIL", "admin@tiendailusion.local")), (
"Staging Admin",
os.environ.get("ADMIN_EMAIL", "admin@tiendailusion.local"),
),
] ]
MANAGERS = ADMINS MANAGERS = ADMINS

View File

@@ -23,12 +23,51 @@ class CustomerAdmin(admin.ModelAdmin):
) )
search_fields = ("name", "email", "phone") search_fields = ("name", "email", "phone")
@admin.register(Sale)
class SaleAdmin(admin.ModelAdmin):
list_display = (
"id",
"date",
"customer",
"payment_method",
"external_id",
)
search_fields = ("date", "customer__name", "payment_method", "external_id")
list_filter = ("date", "customer__name", "payment_method")
@admin.register(SaleLine)
class SaleLineAdmin(admin.ModelAdmin):
list_display = ("sale", "product", "quantity", "unit_price")
search_fields = ("sale__id", "product__name")
list_filter = ("sale__date", "product__name")
@admin.register(CatalogSale)
class CatalogSaleAdmin(admin.ModelAdmin):
list_display = (
"id",
"date",
"customer_name",
"customer_phone",
"customer_address",
"pickup_method",
"external_id",
)
search_fields = ("date","customer_name", "customer_phone", "pickup_method","external_id")
list_filter = ("date", "customer_name", "pickup_method")
@admin.register(CatalogSaleLine)
class CatalogSaleLineAdmin(admin.ModelAdmin):
list_display = ("catalog_sale", "product", "quantity", "unit_price")
search_fields = ("catalog_sale__id", "product__name")
list_filter = ("catalog_sale__date", "product__name")
@admin.register(Product)
class ProductAdmin(admin.ModelAdmin):
list_display = ("name", "price", "measuring_unit", "external_id")
search_fields = ("id", "name",)
list_filter = ("name", "id")
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(ProductCategory)
admin.site.register(Payment) admin.site.register(Payment)
admin.site.register(ReconciliationJar) admin.site.register(ReconciliationJar)

View File

@@ -5,7 +5,7 @@ from rest_framework.permissions import IsAuthenticated
from ..models.customers import Customer from ..models.customers import Customer
from ..serializers import CustomerSerializer from ..serializers import CustomerSerializer
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.customers import CustomerTrytonService from ..services.tryton.customers import CustomerTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
@@ -13,7 +13,7 @@ from ..services.tryton.client import get_tryton_client
class CustomerView(viewsets.ModelViewSet): class CustomerView(viewsets.ModelViewSet):
queryset = Customer.objects.all() queryset = Customer.objects.all()
serializer_class = CustomerSerializer serializer_class = CustomerSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
class CustomersFromTrytonView(APIView): class CustomersFromTrytonView(APIView):

View File

@@ -5,7 +5,7 @@ from rest_framework.permissions import AllowAny, IsAuthenticated
from ..models.products import Product from ..models.products import Product
from ..serializers import ProductSerializer from ..serializers import ProductSerializer
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.products import ProductTrytonService from ..services.tryton.products import ProductTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
@@ -17,7 +17,7 @@ class ProductView(viewsets.ModelViewSet):
def get_permissions(self): def get_permissions(self):
if self.action in ("list", "retrieve"): if self.action in ("list", "retrieve"):
return [AllowAny()] return [AllowAny()]
return [IsAuthenticated()] return [IsNotPublico()]
def get_queryset(self): def get_queryset(self):
""" """

View File

@@ -14,7 +14,7 @@ from ..serializers import (
SaleSummarySerializer, SaleSummarySerializer,
CatalogSaleSummarySerializer, CatalogSaleSummarySerializer,
) )
from ..permissions import IsAdministrator from ..permissions import IsAdministrator, IsNotPublico
from ..services.tryton.sales import SaleTrytonService from ..services.tryton.sales import SaleTrytonService
from ..services.tryton.client import get_tryton_client from ..services.tryton.client import get_tryton_client
from ..views import sales_to_tryton_csv from ..views import sales_to_tryton_csv
@@ -23,7 +23,7 @@ from ..views import sales_to_tryton_csv
class SaleView(viewsets.ModelViewSet): class SaleView(viewsets.ModelViewSet):
queryset = Sale.objects.all() queryset = Sale.objects.all()
serializer_class = SaleSerializer serializer_class = SaleSerializer
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
def create(self, request): def create(self, request):
data = request.data data = request.data
@@ -59,11 +59,15 @@ class SaleView(viewsets.ModelViewSet):
class CatalogSaleView(viewsets.ModelViewSet): class CatalogSaleView(viewsets.ModelViewSet):
queryset = CatalogSale.objects.all() queryset = CatalogSale.objects.all()
serializer_class = CatalogSaleSerializer serializer_class = CatalogSaleSerializer
permission_classes = [IsAuthenticated]
def get_permissions(self):
if self.action == "create":
return [IsAuthenticated()]
return [IsNotPublico()]
class SaleSummary(APIView): class SaleSummary(APIView):
permission_classes = [IsAuthenticated] permission_classes = [IsNotPublico]
def get(self, request, id): def get(self, request, id):
sale = Sale.objects.get(pk=id) sale = Sale.objects.get(pk=id)

View File

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

View File

@@ -43,20 +43,28 @@ class TrytonSale:
"shipment_address": self.sale.customer.address_external_id, "shipment_address": self.sale.customer.address_external_id,
"invoice_address": self.sale.customer.address_external_id, "invoice_address": self.sale.customer.address_external_id,
"currency": TRYTON_COP_CURRENCY, "currency": TRYTON_COP_CURRENCY,
"comment": self.sale.description or "", "comment": self.build_tryton_comment(),
"description": "Metodo pago: " + str(self.sale.payment_method or ""), "description": "Metodo pago: "
+ str(self.sale.payment_method or ""),
"party": self.sale.customer.external_id, "party": self.sale.customer.external_id,
"reference": "don_confiao " + str(self.sale.id), "reference": "don_confiao " + str(self.sale.id),
"sale_date": self._format_date(self.sale.date), "sale_date": self._format_date(self.sale.date),
"lines": [ "lines": [
[ [
"create", "create",
[TrytonLineSale(line).to_tryton() for line in self.lines], [
TrytonLineSale(line).to_tryton()
for line in self.lines
],
] ]
], ],
"self_pick_up": True, "self_pick_up": True,
} }
def build_tryton_comment(self):
"""Construye el comentario para la venta en Tryton"""
return self.sale.description or ""
class TrytonLineSale: class TrytonLineSale:
"""Representa una línea de venta para exportación a Tryton""" """Representa una línea de venta para exportación a Tryton"""
@@ -98,20 +106,36 @@ class TrytonCatalogSale:
"shipment_address": self.catalog_sale.customer.address_external_id, "shipment_address": self.catalog_sale.customer.address_external_id,
"invoice_address": self.catalog_sale.customer.address_external_id, "invoice_address": self.catalog_sale.customer.address_external_id,
"currency": TRYTON_COP_CURRENCY, "currency": TRYTON_COP_CURRENCY,
"comment": self.catalog_sale.description or "", "comment": self.build_tryton_comment(),
"description": "Venta de catálogo", "description": "Venta de catálogo",
"party": self.catalog_sale.customer.external_id, "party": self.catalog_sale.customer.external_id,
"reference": "don_confiao_catalog " + str(self.catalog_sale.id), "reference": "don_confiao_catalog "
+ str(self.catalog_sale.id),
"sale_date": self._format_date(self.catalog_sale.date), "sale_date": self._format_date(self.catalog_sale.date),
"lines": [ "lines": [
[ [
"create", "create",
[TrytonCatalogSaleLine(line).to_tryton() for line in self.lines], [
TrytonCatalogSaleLine(line).to_tryton()
for line in self.lines
],
] ]
], ],
"self_pick_up": True, "self_pick_up": True,
} }
def build_tryton_comment(self):
"""Construye el comentario para la venta en Tryton"""
comment = f"Cliente: {self.catalog_sale.customer_name or ''}\n"
comment += f"Teléfono: {self.catalog_sale.customer_phone or ''}\n"
comment += (
f"Dirección: {self.catalog_sale.customer_address or ''}\n"
)
comment += f"Método de recogida: {self.catalog_sale.pickup_method or ''}\n"
return comment
class TrytonCatalogSaleLine: class TrytonCatalogSaleLine:
"""Representa una línea de catalog sale para exportación a Tryton""" """Representa una línea de catalog sale para exportación a Tryton"""
@@ -125,8 +149,12 @@ class TrytonCatalogSaleLine:
def to_tryton(self): def to_tryton(self):
return { return {
"product": self.catalog_sale_line.product.external_id, "product": self.catalog_sale_line.product.external_id,
"quantity": self._format_decimal(self.catalog_sale_line.quantity), "quantity": self._format_decimal(
self.catalog_sale_line.quantity
),
"type": "line", "type": "line",
"unit": self.catalog_sale_line.product.unit_external_id, "unit": self.catalog_sale_line.product.unit_external_id,
"unit_price": self._format_decimal(self.catalog_sale_line.unit_price), "unit_price": self._format_decimal(
self.catalog_sale_line.unit_price
),
} }

View File

@@ -0,0 +1,249 @@
import json
from unittest.mock import patch, MagicMock
from django.test import TestCase
from ..models.sales import Sale, SaleLine, CatalogSale, CatalogSaleLine
from ..models.customers import Customer
from ..models.products import Product
from ..services.tryton.client import TrytonSale, TrytonCatalogSale
from .Mixins import LoginMixin
class TestSendSalesToTryton(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",
)
SaleLine.objects.create(
product=self.product,
quantity=2,
unit_price=3000,
sale=self.sale,
)
SaleLine.objects.create(
product=self.product,
quantity=3,
unit_price=5000,
sale=self.sale,
)
self.catalog_sale = CatalogSale.objects.create(
customer=self.customer,
date="2024-09-02",
customer_name="Camilo",
customer_phone="3001234567",
customer_address="Calle 123",
pickup_method="Recogida en tienda",
)
CatalogSaleLine.objects.create(
catalog_sale=self.catalog_sale,
product=self.product,
quantity=2,
unit_price=3000,
)
CatalogSaleLine.objects.create(
catalog_sale=self.catalog_sale,
product=self.product,
quantity=3,
unit_price=5000,
)
@patch("don_confiao.api.sales.get_tryton_client")
def test_send_sales_to_tryton(self, mock_get_tryton_client):
external_id = "23423"
mock_client = MagicMock()
mock_client.call.return_value = [external_id]
mock_get_tryton_client.return_value = mock_client
url = "/don_confiao/api/enviar_ventas_a_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(
content, {"successful": [self.sale.id], "failed": []}
)
updated_sale = Sale.objects.get(id=self.sale.id)
self.assertEqual(updated_sale.external_id, external_id)
mock_get_tryton_client.assert_called_once()
mock_client.call.assert_called_once()
call_args = mock_client.call.call_args
method, params = call_args[0]
self.assertEqual(method, "model.sale.sale.create")
tryton_context = params[1]
self.assertEqual(tryton_context, {"company": 1, "shops": [1]})
sale_params = params[0][0]
self.assertEqual(sale_params["company"], 1)
self.assertEqual(sale_params["shipment_address"], "307")
self.assertEqual(sale_params["invoice_address"], "307")
self.assertEqual(sale_params["currency"], 31)
self.assertEqual(sale_params["comment"], "un comentario")
self.assertEqual(sale_params["description"], "Metodo pago: CASH")
self.assertEqual(sale_params["party"], "1")
self.assertEqual(
sale_params["reference"], f"don_confiao {self.sale.id}"
)
self.assertEqual(
sale_params["sale_date"],
{"__class__": "date", "year": 2024, "month": 9, "day": 2},
)
self.assertTrue(sale_params["self_pick_up"])
sale_tryton = TrytonSale(
self.sale, SaleLine.objects.filter(sale=self.sale)
)
self.assertEqual(
sale_tryton.build_tryton_comment(), self.sale.description
)
lines_block = sale_params["lines"]
self.assertEqual(len(lines_block), 1)
self.assertEqual(lines_block[0][0], "create")
lines = lines_block[0][1]
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]["product"], "1")
self.assertEqual(
lines[0]["quantity"],
{"__class__": "Decimal", "decimal": "2.00"},
)
self.assertEqual(lines[0]["type"], "line")
self.assertEqual(lines[0]["unit"], "1")
self.assertEqual(
lines[0]["unit_price"],
{"__class__": "Decimal", "decimal": "3000.00"},
)
self.assertEqual(lines[1]["product"], "1")
self.assertEqual(
lines[1]["quantity"],
{"__class__": "Decimal", "decimal": "3.00"},
)
self.assertEqual(lines[1]["type"], "line")
self.assertEqual(lines[1]["unit"], "1")
self.assertEqual(
lines[1]["unit_price"],
{"__class__": "Decimal", "decimal": "5000.00"},
)
@patch("don_confiao.api.sales.get_tryton_client")
def test_send_catalog_sales_to_tryton(self, mock_get_tryton_client):
external_id = "23423"
mock_client = MagicMock()
mock_client.call.return_value = [external_id]
mock_get_tryton_client.return_value = mock_client
url = "/don_confiao/api/enviar_catalog_sales_a_tryton"
response = self.client.post(url)
self.assertEqual(response.status_code, 200)
content = json.loads(response.content.decode("utf-8"))
self.assertEqual(
content,
{"successful": [self.catalog_sale.id], "failed": []},
)
updated = CatalogSale.objects.get(id=self.catalog_sale.id)
self.assertEqual(updated.external_id, external_id)
mock_get_tryton_client.assert_called_once()
mock_client.call.assert_called_once()
call_args = mock_client.call.call_args
method, params = call_args[0]
self.assertEqual(method, "model.sale.sale.create")
tryton_context = params[1]
self.assertEqual(tryton_context, {"company": 1, "shops": [1]})
sale_params = params[0][0]
self.assertEqual(sale_params["company"], 1)
self.assertEqual(sale_params["shipment_address"], "307")
self.assertEqual(sale_params["invoice_address"], "307")
self.assertEqual(sale_params["currency"], 31)
self.assertEqual(sale_params["description"], "Venta de catálogo")
self.assertEqual(sale_params["party"], "1")
self.assertEqual(
sale_params["reference"],
f"don_confiao_catalog {self.catalog_sale.id}",
)
self.assertEqual(
sale_params["sale_date"],
{"__class__": "date", "year": 2024, "month": 9, "day": 2},
)
self.assertTrue(sale_params["self_pick_up"])
expected_comment = (
"Cliente: Camilo\n"
"Teléfono: 3001234567\n"
"Dirección: Calle 123\n"
"Método de recogida: Recogida en tienda\n"
)
self.assertEqual(sale_params["comment"], expected_comment)
catalog_tryton = TrytonCatalogSale(
self.catalog_sale,
CatalogSaleLine.objects.filter(
catalog_sale=self.catalog_sale
),
)
self.assertEqual(
catalog_tryton.build_tryton_comment(), expected_comment
)
lines_block = sale_params["lines"]
self.assertEqual(len(lines_block), 1)
self.assertEqual(lines_block[0][0], "create")
lines = lines_block[0][1]
self.assertEqual(len(lines), 2)
self.assertEqual(lines[0]["product"], "1")
self.assertEqual(
lines[0]["quantity"],
{"__class__": "Decimal", "decimal": "2.00"},
)
self.assertEqual(lines[0]["type"], "line")
self.assertEqual(lines[0]["unit"], "1")
self.assertEqual(
lines[0]["unit_price"],
{"__class__": "Decimal", "decimal": "3000.00"},
)
self.assertEqual(lines[1]["product"], "1")
self.assertEqual(
lines[1]["quantity"],
{"__class__": "Decimal", "decimal": "3.00"},
)
self.assertEqual(lines[1]["type"], "line")
self.assertEqual(lines[1]["unit"], "1")
self.assertEqual(
lines[1]["unit_price"],
{"__class__": "Decimal", "decimal": "5000.00"},
)

View File

@@ -1,3 +1,23 @@
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.models import User
from django.contrib.auth.admin import UserAdmin
# Register your models here. from .models import UserProfile
class UserProfileInline(admin.StackedInline):
model = UserProfile
can_delete = False
class CustomUserAdmin(UserAdmin):
inlines = [UserProfileInline]
def get_inline_instances(self, request, obj=None):
if not obj:
return []
return super().get_inline_instances(request, obj)
admin.site.unregister(User)
admin.site.register(User, CustomUserAdmin)

View File

@@ -3,3 +3,6 @@ from django.apps import AppConfig
class UsersConfig(AppConfig): class UsersConfig(AppConfig):
name = 'users' name = 'users'
def ready(self):
import users.signals # noqa

View File

@@ -0,0 +1,25 @@
# Generated by Django 5.0.6 on 2026-06-22 17:26
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='UserProfile',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('user_type', models.CharField(choices=[('publico', 'Público'), ('user', 'Usuario'), ('administrator', 'Administrador')], default='user', max_length=20)),
('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='profile', to=settings.AUTH_USER_MODEL)),
],
),
]

View File

@@ -0,0 +1,24 @@
from django.db import migrations
def backfill_user_profiles(apps, schema_editor):
User = apps.get_model("auth", "User")
UserProfile = apps.get_model("users", "UserProfile")
for user in User.objects.all():
UserProfile.objects.get_or_create(
user=user,
defaults={
"user_type": "administrator" if user.is_staff else "user",
},
)
class Migration(migrations.Migration):
dependencies = [
('users', '0001_initial'),
]
operations = [
migrations.RunPython(backfill_user_profiles, migrations.RunPython.noop),
]

View File

@@ -1,3 +1,19 @@
from django.db import models from django.db import models
from django.contrib.auth.models import User
# Create your models here.
class UserProfile(models.Model):
USER_TYPE_CHOICES = [
("publico", "Público"),
("user", "Usuario"),
("administrator", "Administrador"),
]
user = models.OneToOneField(
User, on_delete=models.CASCADE, related_name="profile"
)
user_type = models.CharField(
max_length=20, choices=USER_TYPE_CHOICES, default="user"
)
def __str__(self):
return f"{self.user.username} - {self.user_type}"

View File

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

View File

@@ -0,0 +1,12 @@
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.contrib.auth.models import User
from .models import UserProfile
@receiver(post_save, sender=User)
def create_user_profile(sender, instance, created, **kwargs):
if created:
user_type = "administrator" if instance.is_staff else "user"
UserProfile.objects.create(user=instance, user_type=user_type)

View File

@@ -4,6 +4,8 @@ from django.contrib.auth.models import User
from rest_framework.test import APIClient from rest_framework.test import APIClient
from rest_framework_simplejwt.tokens import RefreshToken from rest_framework_simplejwt.tokens import RefreshToken
from .models import UserProfile
class MeEndpointTests(TestCase): class MeEndpointTests(TestCase):
def setUp(self): def setUp(self):
@@ -49,7 +51,6 @@ class MeEndpointTests(TestCase):
username='regular', username='regular',
email='regular@example.com', email='regular@example.com',
password='regularpass', password='regularpass',
is_staff=False
) )
refresh = RefreshToken.for_user(regular_user) refresh = RefreshToken.for_user(regular_user)
@@ -87,6 +88,31 @@ class MeEndpointTests(TestCase):
self.assertEqual(response.status_code, 200) self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()['role'], 'administrator') self.assertEqual(response.json()['role'], 'administrator')
def test_publico_user_role_is_publico(self):
"""
Verifica que un usuario con user_type='publico' recibe role 'publico'.
"""
publico_user = User.objects.create_user(
username='publico',
email='publico@example.com',
password='publicopass',
)
profile = UserProfile.objects.get(user=publico_user)
profile.user_type = 'publico'
profile.save()
refresh = RefreshToken.for_user(publico_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'], 'publico')
def test_me_endpoint_requires_authentication(self): def test_me_endpoint_requires_authentication(self):
""" """
Sin token el endpoint debe devolver 401 Unauthorized. Sin token el endpoint debe devolver 401 Unauthorized.