From 75c3c7b2c28542aa6051a5b9561d7fc2ca13939f Mon Sep 17 00:00:00 2001 From: jcv-dev Date: Fri, 28 Aug 2026 14:38:47 -0500 Subject: [PATCH] Lua update --- .coverage | Bin 0 -> 86016 bytes .dockerignore | 26 + .github/workflows/ci.yml | 124 + .pre-commit-config.yaml | 28 + .../invoice_INV-2026-000002.pdf | 74 + .../invoice_INV-2026-000001.pdf | 74 + .../invoice_INV-2026-000001.pdf | 74 + .../invoice_INV-2026-000001.pdf | 74 + AGENTS.md | 392 +++ Dockerfile | 61 + PLAN.md | 3125 +++++++++++++++++ .../invoice_INV-2026-000001.pdf | 74 + config/__init__.py | 3 + config/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 240 bytes config/__pycache__/celery.cpython-314.pyc | Bin 0 -> 585 bytes config/__pycache__/urls.cpython-314.pyc | Bin 0 -> 1150 bytes config/celery.py | 9 + config/settings/__init__.py | 8 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 369 bytes .../settings/__pycache__/base.cpython-314.pyc | Bin 0 -> 8717 bytes .../settings/__pycache__/dev.cpython-314.pyc | Bin 0 -> 580 bytes .../settings/__pycache__/test.cpython-314.pyc | Bin 0 -> 800 bytes config/settings/base.py | 289 ++ config/settings/dev.py | 12 + config/settings/test.py | 26 + config/urls.py | 15 + config/wsgi.py | 6 + .../invoice_INV-2026-000001.pdf | 74 + docker-compose.yml | 78 + docs/04dd318c64774fb08ece1060dbf8c7d0/img.png | Bin 0 -> 1061 bytes docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png | Bin 0 -> 1061 bytes .../2e5d7892b2d345dba141852e5b9d88f2/test.txt | 1 + docs/380be0205e7d4da7b718e96cb62ae2b4/img.png | Bin 0 -> 1061 bytes .../4b2e6d2c2c044354a29b53dd6b29998c/test.txt | 1 + .../513a00e428e848e8a77d007243a0f88e/test.txt | 1 + docs/569c1d2348dd42348e16221e92268ded/img.png | Bin 0 -> 1061 bytes .../5a05d7c2edca4d509722b05f57114874/test.txt | 1 + docs/60ac6c34c5c4442f9b2db03580f3073e/img.png | Bin 0 -> 1061 bytes docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png | Bin 0 -> 1061 bytes docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png | Bin 0 -> 1061 bytes docs/98fd1b7317fc46668f945560910d0d77/img.png | Bin 0 -> 1061 bytes .../a3f2d4ebbc87485d80544891363ca5b6/test.txt | 1 + .../c008ebbc2a974fa69a459e9d002f2ca8/test.txt | 1 + .../cfcb245ce6114826bd0063bef7b58075/test.txt | 1 + .../e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt | 1 + .../f454df5e834c4db2a9824a6387d7115d/test.txt | 1 + infrasynth/__init__.py | 3 + .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 245 bytes infrasynth/audit/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes .../audit/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1091 bytes .../audit/__pycache__/filters.cpython-314.pyc | Bin 0 -> 2051 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 0 -> 4227 bytes .../audit/__pycache__/models.cpython-314.pyc | Bin 0 -> 4125 bytes .../__pycache__/receivers.cpython-314.pyc | Bin 0 -> 5851 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 2035 bytes .../audit/__pycache__/signals.cpython-314.pyc | Bin 0 -> 305 bytes .../audit/__pycache__/urls.cpython-314.pyc | Bin 0 -> 835 bytes .../audit/__pycache__/views.cpython-314.pyc | Bin 0 -> 3518 bytes infrasynth/audit/apps.py | 20 + infrasynth/audit/filters.py | 40 + infrasynth/audit/middleware.py | 80 + infrasynth/audit/migrations/0001_initial.py | 91 + infrasynth/audit/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 4314 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 185 bytes infrasynth/audit/mixins.py | 26 + infrasynth/audit/models.py | 51 + infrasynth/audit/receivers.py | 130 + infrasynth/audit/serializers.py | 54 + infrasynth/audit/signals.py | 4 + infrasynth/audit/urls.py | 13 + infrasynth/audit/views.py | 40 + infrasynth/billing/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 176 bytes .../billing/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1056 bytes .../__pycache__/filters.cpython-314.pyc | Bin 0 -> 2752 bytes .../invoice_generator.cpython-314.pyc | Bin 0 -> 6289 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 8339 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 7551 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 9564 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 512 bytes .../billing/__pycache__/urls.cpython-314.pyc | Bin 0 -> 1267 bytes .../billing/__pycache__/views.cpython-314.pyc | Bin 0 -> 10362 bytes infrasynth/billing/apps.py | 18 + infrasynth/billing/filters.py | 56 + infrasynth/billing/gateways/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 185 bytes .../gateways/__pycache__/base.cpython-314.pyc | Bin 0 -> 3551 bytes .../__pycache__/mercadopago.cpython-314.pyc | Bin 0 -> 9525 bytes .../__pycache__/stripe.cpython-314.pyc | Bin 0 -> 9481 bytes .../__pycache__/wompi.cpython-314.pyc | Bin 0 -> 10627 bytes infrasynth/billing/gateways/base.py | 36 + infrasynth/billing/gateways/mercadopago.py | 134 + infrasynth/billing/gateways/stripe.py | 125 + infrasynth/billing/gateways/wompi.py | 149 + infrasynth/billing/invoice_generator.py | 138 + infrasynth/billing/migrations/0001_initial.py | 199 ++ infrasynth/billing/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 7962 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 187 bytes infrasynth/billing/models.py | 158 + infrasynth/billing/serializers.py | 185 + infrasynth/billing/services.py | 181 + infrasynth/billing/signals.py | 9 + infrasynth/billing/urls.py | 27 + infrasynth/billing/views.py | 188 + infrasynth/features/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes .../features/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1028 bytes .../__pycache__/decorators.cpython-314.pyc | Bin 0 -> 1330 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 2712 bytes .../__pycache__/registry.cpython-314.pyc | Bin 0 -> 2874 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 1471 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 6421 bytes .../features/__pycache__/urls.cpython-314.pyc | Bin 0 -> 697 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 2800 bytes infrasynth/features/apps.py | 18 + infrasynth/features/decorators.py | 21 + infrasynth/features/filters.py | 14 + .../features/migrations/0001_initial.py | 81 + infrasynth/features/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 3255 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 188 bytes infrasynth/features/models.py | 41 + infrasynth/features/registry.py | 42 + infrasynth/features/serializers.py | 28 + infrasynth/features/services.py | 89 + infrasynth/features/signals.py | 7 + infrasynth/features/urls.py | 12 + infrasynth/features/views.py | 43 + infrasynth/files/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes .../files/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1021 bytes .../files/__pycache__/models.cpython-314.pyc | Bin 0 -> 5144 bytes .../__pycache__/processing.cpython-314.pyc | Bin 0 -> 9908 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 2693 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 12803 bytes .../files/__pycache__/signals.cpython-314.pyc | Bin 0 -> 330 bytes .../files/__pycache__/storage.cpython-314.pyc | Bin 0 -> 14071 bytes .../files/__pycache__/urls.cpython-314.pyc | Bin 0 -> 803 bytes .../files/__pycache__/views.cpython-314.pyc | Bin 0 -> 4204 bytes infrasynth/files/apps.py | 18 + infrasynth/files/filters.py | 35 + infrasynth/files/migrations/0001_initial.py | 136 + infrasynth/files/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 4975 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 185 bytes infrasynth/files/models.py | 86 + infrasynth/files/processing.py | 145 + infrasynth/files/serializers.py | 79 + infrasynth/files/services.py | 199 ++ infrasynth/files/signals.py | 5 + infrasynth/files/storage.py | 162 + infrasynth/files/urls.py | 13 + infrasynth/files/views.py | 70 + infrasynth/notifications/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 182 bytes .../__pycache__/apps.cpython-314.pyc | Bin 0 -> 1086 bytes .../__pycache__/filters.cpython-314.pyc | Bin 0 -> 2069 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 4527 bytes .../__pycache__/resolvers.cpython-314.pyc | Bin 0 -> 3477 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 2225 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 14869 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 314 bytes .../__pycache__/tasks.cpython-314.pyc | Bin 0 -> 2528 bytes .../__pycache__/urls.cpython-314.pyc | Bin 0 -> 873 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 2702 bytes infrasynth/notifications/apps.py | 18 + infrasynth/notifications/channels/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 191 bytes .../channels/__pycache__/base.cpython-314.pyc | Bin 0 -> 2525 bytes .../email_sendgrid.cpython-314.pyc | Bin 0 -> 4854 bytes .../__pycache__/email_smtp.cpython-314.pyc | Bin 0 -> 5281 bytes .../__pycache__/sms_twilio.cpython-314.pyc | Bin 0 -> 4317 bytes .../__pycache__/telegram.cpython-314.pyc | Bin 0 -> 4378 bytes infrasynth/notifications/channels/base.py | 32 + .../notifications/channels/email_sendgrid.py | 90 + .../notifications/channels/email_smtp.py | 99 + .../notifications/channels/sms_twilio.py | 62 + infrasynth/notifications/channels/telegram.py | 71 + infrasynth/notifications/filters.py | 37 + .../notifications/migrations/0001_initial.py | 90 + .../notifications/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 4287 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 193 bytes infrasynth/notifications/models.py | 61 + infrasynth/notifications/resolvers.py | 55 + infrasynth/notifications/serializers.py | 58 + infrasynth/notifications/services.py | 238 ++ infrasynth/notifications/signals.py | 4 + infrasynth/notifications/tasks.py | 55 + infrasynth/notifications/urls.py | 17 + infrasynth/notifications/views.py | 44 + infrasynth/scheduler/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 178 bytes .../__pycache__/apps.cpython-314.pyc | Bin 0 -> 1044 bytes .../__pycache__/filters.cpython-314.pyc | Bin 0 -> 1385 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 3934 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 1976 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 8911 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 369 bytes .../__pycache__/urls.cpython-314.pyc | Bin 0 -> 830 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 5241 bytes infrasynth/scheduler/apps.py | 18 + infrasynth/scheduler/filters.py | 24 + .../scheduler/migrations/0001_initial.py | 74 + infrasynth/scheduler/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 3027 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 189 bytes infrasynth/scheduler/models.py | 49 + infrasynth/scheduler/serializers.py | 44 + infrasynth/scheduler/services.py | 153 + infrasynth/scheduler/signals.py | 6 + infrasynth/scheduler/urls.py | 13 + infrasynth/scheduler/views.py | 87 + infrasynth/security/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes .../security/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1056 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 7159 bytes .../__pycache__/permissions.cpython-314.pyc | Bin 0 -> 2320 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 4945 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 6096 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 566 bytes .../security/__pycache__/urls.cpython-314.pyc | Bin 0 -> 2300 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 19786 bytes infrasynth/security/altcha/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 184 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 3695 bytes infrasynth/security/altcha/services.py | 59 + infrasynth/security/apps.py | 18 + infrasynth/security/auth/__init__.py | 0 .../auth/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 182 bytes .../auth/__pycache__/api_keys.cpython-314.pyc | Bin 0 -> 3334 bytes .../auth/__pycache__/backends.cpython-314.pyc | Bin 0 -> 1558 bytes .../auth/__pycache__/cookies.cpython-314.pyc | Bin 0 -> 1573 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 0 -> 1279 bytes infrasynth/security/auth/api_keys.py | 49 + infrasynth/security/auth/backends.py | 21 + infrasynth/security/auth/cookies.py | 21 + infrasynth/security/auth/middleware.py | 18 + infrasynth/security/filters.py | 40 + .../security/migrations/0001_initial.py | 163 + .../migrations/0002_add_role_users_m2m.py | 21 + infrasynth/security/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 6454 bytes .../0002_add_role_users_m2m.cpython-314.pyc | Bin 0 -> 1080 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 188 bytes infrasynth/security/models.py | 120 + infrasynth/security/permissions.py | 29 + infrasynth/security/serializers.py | 64 + infrasynth/security/services.py | 69 + infrasynth/security/signals.py | 11 + infrasynth/security/two_factor/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 188 bytes .../__pycache__/middleware.cpython-314.pyc | Bin 0 -> 1910 bytes .../__pycache__/services.cpython-314.pyc | Bin 0 -> 7149 bytes .../__pycache__/utils.cpython-314.pyc | Bin 0 -> 1108 bytes infrasynth/security/two_factor/middleware.py | 32 + infrasynth/security/two_factor/services.py | 72 + infrasynth/security/two_factor/utils.py | 12 + infrasynth/security/urls.py | 61 + infrasynth/security/views.py | 378 ++ infrasynth/shared/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 175 bytes .../shared/__pycache__/crypto.cpython-314.pyc | Bin 0 -> 3950 bytes .../shared/__pycache__/enums.cpython-314.pyc | Bin 0 -> 2857 bytes .../__pycache__/protocols.cpython-314.pyc | Bin 0 -> 1937 bytes .../__pycache__/results.cpython-314.pyc | Bin 0 -> 2501 bytes .../settings_utils.cpython-314.pyc | Bin 0 -> 872 bytes infrasynth/shared/crypto.py | 46 + infrasynth/shared/enums.py | 55 + infrasynth/shared/protocols.py | 23 + infrasynth/shared/results.py | 23 + infrasynth/shared/settings_utils.py | 13 + infrasynth/webhooks/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes .../webhooks/__pycache__/apps.cpython-314.pyc | Bin 0 -> 1010 bytes .../__pycache__/dispatch.cpython-314.pyc | Bin 0 -> 7077 bytes .../__pycache__/filters.cpython-314.pyc | Bin 0 -> 2881 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 7652 bytes .../__pycache__/registry.cpython-314.pyc | Bin 0 -> 3965 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 3740 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 370 bytes .../__pycache__/signature.cpython-314.pyc | Bin 0 -> 2429 bytes .../webhooks/__pycache__/urls.cpython-314.pyc | Bin 0 -> 1531 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 5026 bytes infrasynth/webhooks/apps.py | 13 + infrasynth/webhooks/dispatch.py | 132 + infrasynth/webhooks/filters.py | 56 + infrasynth/webhooks/inbound/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 185 bytes .../__pycache__/handlers.cpython-314.pyc | Bin 0 -> 1324 bytes infrasynth/webhooks/inbound/handlers.py | 9 + .../webhooks/migrations/0001_initial.py | 138 + infrasynth/webhooks/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 5591 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 188 bytes infrasynth/webhooks/models.py | 98 + infrasynth/webhooks/registry.py | 52 + infrasynth/webhooks/serializers.py | 57 + infrasynth/webhooks/signals.py | 6 + infrasynth/webhooks/signature.py | 30 + infrasynth/webhooks/urls.py | 28 + infrasynth/webhooks/views.py | 101 + infrasynth/workflows/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 178 bytes .../__pycache__/apps.cpython-314.pyc | Bin 0 -> 1050 bytes .../__pycache__/engine.cpython-314.pyc | Bin 0 -> 15609 bytes .../__pycache__/filters.cpython-314.pyc | Bin 0 -> 3347 bytes .../__pycache__/models.cpython-314.pyc | Bin 0 -> 9493 bytes .../__pycache__/serializers.cpython-314.pyc | Bin 0 -> 8071 bytes .../__pycache__/signals.cpython-314.pyc | Bin 0 -> 427 bytes .../__pycache__/urls.cpython-314.pyc | Bin 0 -> 1210 bytes .../__pycache__/validators.cpython-314.pyc | Bin 0 -> 1880 bytes .../__pycache__/views.cpython-314.pyc | Bin 0 -> 13292 bytes infrasynth/workflows/apps.py | 18 + infrasynth/workflows/engine.py | 262 ++ infrasynth/workflows/filters.py | 70 + .../workflows/migrations/0001_initial.py | 221 ++ infrasynth/workflows/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-314.pyc | Bin 0 -> 7270 bytes .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 189 bytes infrasynth/workflows/models.py | 141 + infrasynth/workflows/serializers.py | 165 + infrasynth/workflows/signals.py | 7 + infrasynth/workflows/urls.py | 23 + infrasynth/workflows/validators.py | 18 + infrasynth/workflows/views.py | 259 ++ infrasynth_base.egg-info/PKG-INFO | 37 + infrasynth_base.egg-info/SOURCES.txt | 150 + infrasynth_base.egg-info/dependency_links.txt | 0 infrasynth_base.egg-info/requires.txt | 32 + infrasynth_base.egg-info/top_level.txt | 1 + manage.py | 14 + pyproject.toml | 112 + tests/__init__.py | 0 tests/__pycache__/__init__.cpython-314.pyc | Bin 0 -> 163 bytes .../conftest.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 4234 bytes tests/__pycache__/helpers.cpython-314.pyc | Bin 0 -> 3944 bytes ...est_debug_tmp.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 2223 bytes .../test_e2e.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 33747 bytes tests/conftest.py | 99 + tests/helpers.py | 54 + tests/test_audit/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes ...st_middleware.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 20085 bytes ...est_receivers.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 25846 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 22060 bytes tests/test_audit/test_middleware.py | 147 + tests/test_audit/test_receivers.py | 129 + tests/test_audit/test_views.py | 130 + tests/test_billing/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 176 bytes ...test_gateways.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 52079 bytes ...t_integration.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 28357 bytes ...ice_generator.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 10709 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 18958 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 43285 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 37675 bytes tests/test_billing/test_gateways.py | 302 ++ tests/test_billing/test_integration.py | 204 ++ tests/test_billing/test_invoice_generator.py | 80 + tests/test_billing/test_models.py | 109 + tests/test_billing/test_services.py | 191 + tests/test_billing/test_views.py | 222 ++ tests/test_e2e.py | 198 ++ tests/test_features/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes ...st_decorators.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 4296 bytes ...test_registry.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 8544 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 24062 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 13171 bytes tests/test_features/test_decorators.py | 49 + tests/test_features/test_registry.py | 39 + tests/test_features/test_services.py | 118 + tests/test_features/test_views.py | 66 + tests/test_files/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 174 bytes ...st_processing.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 20607 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 45273 bytes .../test_storage.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 16144 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 25182 bytes tests/test_files/test_processing.py | 129 + tests/test_files/test_services.py | 185 + tests/test_files/test_storage.py | 61 + tests/test_files/test_views.py | 101 + tests/test_notifications/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 182 bytes ...test_channels.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 41201 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 12337 bytes ...est_resolvers.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 14631 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 36125 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 25095 bytes tests/test_notifications/test_channels.py | 193 + tests/test_notifications/test_models.py | 95 + tests/test_notifications/test_resolvers.py | 81 + tests/test_notifications/test_services.py | 271 ++ tests/test_notifications/test_views.py | 140 + tests/test_scheduler/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 178 bytes ...test_services.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 33091 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 28346 bytes tests/test_scheduler/test_services.py | 190 + tests/test_scheduler/test_views.py | 125 + tests/test_security/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes .../test_altcha.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 26650 bytes ...test_api_keys.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 17437 bytes .../test_auth.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 50570 bytes ...authorization.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 80328 bytes ...st_two_factor.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 54552 bytes ...or_middleware.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 17202 bytes tests/test_security/test_altcha.py | 121 + tests/test_security/test_api_keys.py | 91 + tests/test_security/test_auth.py | 275 ++ tests/test_security/test_authorization.py | 297 ++ tests/test_security/test_two_factor.py | 243 ++ .../test_two_factor_middleware.py | 90 + tests/test_shared/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 175 bytes .../test_crypto.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 22328 bytes .../test_enums.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 34706 bytes ...est_protocols.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 20532 bytes .../test_results.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 19724 bytes ...ettings_utils.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 7268 bytes tests/test_shared/test_crypto.py | 92 + tests/test_shared/test_enums.py | 102 + tests/test_shared/test_protocols.py | 93 + tests/test_shared/test_results.py | 60 + tests/test_shared/test_settings_utils.py | 35 + tests/test_webhooks/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 177 bytes ...test_dispatch.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 25591 bytes .../test_inbound.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 5066 bytes ...t_integration.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 38576 bytes ...test_registry.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 19191 bytes ...est_signature.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 15569 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 41106 bytes tests/test_webhooks/test_dispatch.py | 143 + tests/test_webhooks/test_inbound.py | 27 + tests/test_webhooks/test_integration.py | 308 ++ tests/test_webhooks/test_registry.py | 130 + tests/test_webhooks/test_signature.py | 54 + tests/test_webhooks/test_views.py | 232 ++ tests/test_workflows/__init__.py | 0 .../__pycache__/__init__.cpython-314.pyc | Bin 0 -> 178 bytes .../test_engine.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 65425 bytes .../test_models.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 18581 bytes ...st_validators.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 10893 bytes .../test_views.cpython-314-pytest-9.1.1.pyc | Bin 0 -> 45695 bytes tests/test_workflows/test_engine.py | 307 ++ tests/test_workflows/test_models.py | 78 + tests/test_workflows/test_validators.py | 52 + tests/test_workflows/test_views.py | 212 ++ 455 files changed, 20071 insertions(+) create mode 100644 .coverage create mode 100644 .dockerignore create mode 100644 .github/workflows/ci.yml create mode 100644 .pre-commit-config.yaml create mode 100644 04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf create mode 100644 141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf create mode 100644 51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf create mode 100644 6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf create mode 100644 AGENTS.md create mode 100644 Dockerfile create mode 100644 PLAN.md create mode 100644 b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf create mode 100644 config/__init__.py create mode 100644 config/__pycache__/__init__.cpython-314.pyc create mode 100644 config/__pycache__/celery.cpython-314.pyc create mode 100644 config/__pycache__/urls.cpython-314.pyc create mode 100644 config/celery.py create mode 100644 config/settings/__init__.py create mode 100644 config/settings/__pycache__/__init__.cpython-314.pyc create mode 100644 config/settings/__pycache__/base.cpython-314.pyc create mode 100644 config/settings/__pycache__/dev.cpython-314.pyc create mode 100644 config/settings/__pycache__/test.cpython-314.pyc create mode 100644 config/settings/base.py create mode 100644 config/settings/dev.py create mode 100644 config/settings/test.py create mode 100644 config/urls.py create mode 100644 config/wsgi.py create mode 100644 d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf create mode 100644 docker-compose.yml create mode 100644 docs/04dd318c64774fb08ece1060dbf8c7d0/img.png create mode 100644 docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png create mode 100644 docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt create mode 100644 docs/380be0205e7d4da7b718e96cb62ae2b4/img.png create mode 100644 docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt create mode 100644 docs/513a00e428e848e8a77d007243a0f88e/test.txt create mode 100644 docs/569c1d2348dd42348e16221e92268ded/img.png create mode 100644 docs/5a05d7c2edca4d509722b05f57114874/test.txt create mode 100644 docs/60ac6c34c5c4442f9b2db03580f3073e/img.png create mode 100644 docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png create mode 100644 docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png create mode 100644 docs/98fd1b7317fc46668f945560910d0d77/img.png create mode 100644 docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt create mode 100644 docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt create mode 100644 docs/cfcb245ce6114826bd0063bef7b58075/test.txt create mode 100644 docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt create mode 100644 docs/f454df5e834c4db2a9824a6387d7115d/test.txt create mode 100644 infrasynth/__init__.py create mode 100644 infrasynth/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/audit/__init__.py create mode 100644 infrasynth/audit/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/middleware.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/receivers.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/audit/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/audit/apps.py create mode 100644 infrasynth/audit/filters.py create mode 100644 infrasynth/audit/middleware.py create mode 100644 infrasynth/audit/migrations/0001_initial.py create mode 100644 infrasynth/audit/migrations/__init__.py create mode 100644 infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/audit/mixins.py create mode 100644 infrasynth/audit/models.py create mode 100644 infrasynth/audit/receivers.py create mode 100644 infrasynth/audit/serializers.py create mode 100644 infrasynth/audit/signals.py create mode 100644 infrasynth/audit/urls.py create mode 100644 infrasynth/audit/views.py create mode 100644 infrasynth/billing/__init__.py create mode 100644 infrasynth/billing/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/billing/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/billing/apps.py create mode 100644 infrasynth/billing/filters.py create mode 100644 infrasynth/billing/gateways/__init__.py create mode 100644 infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc create mode 100644 infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc create mode 100644 infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc create mode 100644 infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc create mode 100644 infrasynth/billing/gateways/base.py create mode 100644 infrasynth/billing/gateways/mercadopago.py create mode 100644 infrasynth/billing/gateways/stripe.py create mode 100644 infrasynth/billing/gateways/wompi.py create mode 100644 infrasynth/billing/invoice_generator.py create mode 100644 infrasynth/billing/migrations/0001_initial.py create mode 100644 infrasynth/billing/migrations/__init__.py create mode 100644 infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/billing/models.py create mode 100644 infrasynth/billing/serializers.py create mode 100644 infrasynth/billing/services.py create mode 100644 infrasynth/billing/signals.py create mode 100644 infrasynth/billing/urls.py create mode 100644 infrasynth/billing/views.py create mode 100644 infrasynth/features/__init__.py create mode 100644 infrasynth/features/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/decorators.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/registry.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/features/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/features/apps.py create mode 100644 infrasynth/features/decorators.py create mode 100644 infrasynth/features/filters.py create mode 100644 infrasynth/features/migrations/0001_initial.py create mode 100644 infrasynth/features/migrations/__init__.py create mode 100644 infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/features/models.py create mode 100644 infrasynth/features/registry.py create mode 100644 infrasynth/features/serializers.py create mode 100644 infrasynth/features/services.py create mode 100644 infrasynth/features/signals.py create mode 100644 infrasynth/features/urls.py create mode 100644 infrasynth/features/views.py create mode 100644 infrasynth/files/__init__.py create mode 100644 infrasynth/files/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/processing.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/storage.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/files/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/files/apps.py create mode 100644 infrasynth/files/filters.py create mode 100644 infrasynth/files/migrations/0001_initial.py create mode 100644 infrasynth/files/migrations/__init__.py create mode 100644 infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/files/models.py create mode 100644 infrasynth/files/processing.py create mode 100644 infrasynth/files/serializers.py create mode 100644 infrasynth/files/services.py create mode 100644 infrasynth/files/signals.py create mode 100644 infrasynth/files/storage.py create mode 100644 infrasynth/files/urls.py create mode 100644 infrasynth/files/views.py create mode 100644 infrasynth/notifications/__init__.py create mode 100644 infrasynth/notifications/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/tasks.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/notifications/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/notifications/apps.py create mode 100644 infrasynth/notifications/channels/__init__.py create mode 100644 infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc create mode 100644 infrasynth/notifications/channels/base.py create mode 100644 infrasynth/notifications/channels/email_sendgrid.py create mode 100644 infrasynth/notifications/channels/email_smtp.py create mode 100644 infrasynth/notifications/channels/sms_twilio.py create mode 100644 infrasynth/notifications/channels/telegram.py create mode 100644 infrasynth/notifications/filters.py create mode 100644 infrasynth/notifications/migrations/0001_initial.py create mode 100644 infrasynth/notifications/migrations/__init__.py create mode 100644 infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/notifications/models.py create mode 100644 infrasynth/notifications/resolvers.py create mode 100644 infrasynth/notifications/serializers.py create mode 100644 infrasynth/notifications/services.py create mode 100644 infrasynth/notifications/signals.py create mode 100644 infrasynth/notifications/tasks.py create mode 100644 infrasynth/notifications/urls.py create mode 100644 infrasynth/notifications/views.py create mode 100644 infrasynth/scheduler/__init__.py create mode 100644 infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/scheduler/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/scheduler/apps.py create mode 100644 infrasynth/scheduler/filters.py create mode 100644 infrasynth/scheduler/migrations/0001_initial.py create mode 100644 infrasynth/scheduler/migrations/__init__.py create mode 100644 infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/scheduler/models.py create mode 100644 infrasynth/scheduler/serializers.py create mode 100644 infrasynth/scheduler/services.py create mode 100644 infrasynth/scheduler/signals.py create mode 100644 infrasynth/scheduler/urls.py create mode 100644 infrasynth/scheduler/views.py create mode 100644 infrasynth/security/__init__.py create mode 100644 infrasynth/security/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/permissions.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/security/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/security/altcha/__init__.py create mode 100644 infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/altcha/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/security/altcha/services.py create mode 100644 infrasynth/security/apps.py create mode 100644 infrasynth/security/auth/__init__.py create mode 100644 infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc create mode 100644 infrasynth/security/auth/__pycache__/backends.cpython-314.pyc create mode 100644 infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc create mode 100644 infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc create mode 100644 infrasynth/security/auth/api_keys.py create mode 100644 infrasynth/security/auth/backends.py create mode 100644 infrasynth/security/auth/cookies.py create mode 100644 infrasynth/security/auth/middleware.py create mode 100644 infrasynth/security/filters.py create mode 100644 infrasynth/security/migrations/0001_initial.py create mode 100644 infrasynth/security/migrations/0002_add_role_users_m2m.py create mode 100644 infrasynth/security/migrations/__init__.py create mode 100644 infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc create mode 100644 infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/models.py create mode 100644 infrasynth/security/permissions.py create mode 100644 infrasynth/security/serializers.py create mode 100644 infrasynth/security/services.py create mode 100644 infrasynth/security/signals.py create mode 100644 infrasynth/security/two_factor/__init__.py create mode 100644 infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc create mode 100644 infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc create mode 100644 infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc create mode 100644 infrasynth/security/two_factor/middleware.py create mode 100644 infrasynth/security/two_factor/services.py create mode 100644 infrasynth/security/two_factor/utils.py create mode 100644 infrasynth/security/urls.py create mode 100644 infrasynth/security/views.py create mode 100644 infrasynth/shared/__init__.py create mode 100644 infrasynth/shared/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/shared/__pycache__/crypto.cpython-314.pyc create mode 100644 infrasynth/shared/__pycache__/enums.cpython-314.pyc create mode 100644 infrasynth/shared/__pycache__/protocols.cpython-314.pyc create mode 100644 infrasynth/shared/__pycache__/results.cpython-314.pyc create mode 100644 infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc create mode 100644 infrasynth/shared/crypto.py create mode 100644 infrasynth/shared/enums.py create mode 100644 infrasynth/shared/protocols.py create mode 100644 infrasynth/shared/results.py create mode 100644 infrasynth/shared/settings_utils.py create mode 100644 infrasynth/webhooks/__init__.py create mode 100644 infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/registry.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/signature.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/webhooks/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/webhooks/apps.py create mode 100644 infrasynth/webhooks/dispatch.py create mode 100644 infrasynth/webhooks/filters.py create mode 100644 infrasynth/webhooks/inbound/__init__.py create mode 100644 infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc create mode 100644 infrasynth/webhooks/inbound/handlers.py create mode 100644 infrasynth/webhooks/migrations/0001_initial.py create mode 100644 infrasynth/webhooks/migrations/__init__.py create mode 100644 infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/webhooks/models.py create mode 100644 infrasynth/webhooks/registry.py create mode 100644 infrasynth/webhooks/serializers.py create mode 100644 infrasynth/webhooks/signals.py create mode 100644 infrasynth/webhooks/signature.py create mode 100644 infrasynth/webhooks/urls.py create mode 100644 infrasynth/webhooks/views.py create mode 100644 infrasynth/workflows/__init__.py create mode 100644 infrasynth/workflows/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/apps.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/engine.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/filters.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/models.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/serializers.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/signals.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/urls.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/validators.cpython-314.pyc create mode 100644 infrasynth/workflows/__pycache__/views.cpython-314.pyc create mode 100644 infrasynth/workflows/apps.py create mode 100644 infrasynth/workflows/engine.py create mode 100644 infrasynth/workflows/filters.py create mode 100644 infrasynth/workflows/migrations/0001_initial.py create mode 100644 infrasynth/workflows/migrations/__init__.py create mode 100644 infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc create mode 100644 infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc create mode 100644 infrasynth/workflows/models.py create mode 100644 infrasynth/workflows/serializers.py create mode 100644 infrasynth/workflows/signals.py create mode 100644 infrasynth/workflows/urls.py create mode 100644 infrasynth/workflows/validators.py create mode 100644 infrasynth/workflows/views.py create mode 100644 infrasynth_base.egg-info/PKG-INFO create mode 100644 infrasynth_base.egg-info/SOURCES.txt create mode 100644 infrasynth_base.egg-info/dependency_links.txt create mode 100644 infrasynth_base.egg-info/requires.txt create mode 100644 infrasynth_base.egg-info/top_level.txt create mode 100644 manage.py create mode 100644 pyproject.toml create mode 100644 tests/__init__.py create mode 100644 tests/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/helpers.cpython-314.pyc create mode 100644 tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/test_audit/__init__.py create mode 100644 tests/test_audit/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_audit/test_middleware.py create mode 100644 tests/test_audit/test_receivers.py create mode 100644 tests/test_audit/test_views.py create mode 100644 tests/test_billing/__init__.py create mode 100644 tests/test_billing/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_billing/test_gateways.py create mode 100644 tests/test_billing/test_integration.py create mode 100644 tests/test_billing/test_invoice_generator.py create mode 100644 tests/test_billing/test_models.py create mode 100644 tests/test_billing/test_services.py create mode 100644 tests/test_billing/test_views.py create mode 100644 tests/test_e2e.py create mode 100644 tests/test_features/__init__.py create mode 100644 tests/test_features/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_features/test_decorators.py create mode 100644 tests/test_features/test_registry.py create mode 100644 tests/test_features/test_services.py create mode 100644 tests/test_features/test_views.py create mode 100644 tests/test_files/__init__.py create mode 100644 tests/test_files/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_files/test_processing.py create mode 100644 tests/test_files/test_services.py create mode 100644 tests/test_files/test_storage.py create mode 100644 tests/test_files/test_views.py create mode 100644 tests/test_notifications/__init__.py create mode 100644 tests/test_notifications/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_notifications/test_channels.py create mode 100644 tests/test_notifications/test_models.py create mode 100644 tests/test_notifications/test_resolvers.py create mode 100644 tests/test_notifications/test_services.py create mode 100644 tests/test_notifications/test_views.py create mode 100644 tests/test_scheduler/__init__.py create mode 100644 tests/test_scheduler/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_scheduler/test_services.py create mode 100644 tests/test_scheduler/test_views.py create mode 100644 tests/test_security/__init__.py create mode 100644 tests/test_security/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_security/test_altcha.py create mode 100644 tests/test_security/test_api_keys.py create mode 100644 tests/test_security/test_auth.py create mode 100644 tests/test_security/test_authorization.py create mode 100644 tests/test_security/test_two_factor.py create mode 100644 tests/test_security/test_two_factor_middleware.py create mode 100644 tests/test_shared/__init__.py create mode 100644 tests/test_shared/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_shared/test_crypto.py create mode 100644 tests/test_shared/test_enums.py create mode 100644 tests/test_shared/test_protocols.py create mode 100644 tests/test_shared/test_results.py create mode 100644 tests/test_shared/test_settings_utils.py create mode 100644 tests/test_webhooks/__init__.py create mode 100644 tests/test_webhooks/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_webhooks/test_dispatch.py create mode 100644 tests/test_webhooks/test_inbound.py create mode 100644 tests/test_webhooks/test_integration.py create mode 100644 tests/test_webhooks/test_registry.py create mode 100644 tests/test_webhooks/test_signature.py create mode 100644 tests/test_webhooks/test_views.py create mode 100644 tests/test_workflows/__init__.py create mode 100644 tests/test_workflows/__pycache__/__init__.cpython-314.pyc create mode 100644 tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc create mode 100644 tests/test_workflows/test_engine.py create mode 100644 tests/test_workflows/test_models.py create mode 100644 tests/test_workflows/test_validators.py create mode 100644 tests/test_workflows/test_views.py diff --git a/.coverage b/.coverage new file mode 100644 index 0000000000000000000000000000000000000000..453678a295dacc74bae082017f3e16acdbe617d6 GIT binary patch literal 86016 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 RP@))Q_pNfhu{G3`{U5=*gBAb) literal 0 HcmV?d00001 diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..6cb2f28 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,26 @@ +__pycache__ +*.pyc +*.pyo +*.egg-info +.eggs +.venv +.git +.gitignore +.mypy_cache +.pytest_cache +.ruff_cache +.tox +.coverage +htmlcov +*.log +.env +docker-compose.yml +Dockerfile +.dockerignore +.pre-commit-config.yaml +AGENTS.md +PLAN.md +README.md +node_modules +static +media diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..d4fb3ab --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,124 @@ +name: CI + +on: + push: + branches: [master, main] + tags: ["v*"] + pull_request: + branches: [master, main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +env: + PYTHON_VERSION: "3.12" + +jobs: + test: + name: Tests + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_DB: infrasynth + POSTGRES_USER: infrasynth + POSTGRES_PASSWORD: infrasynth + ports: + - 5432:5432 + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies + run: | + pip install --upgrade pip setuptools wheel + pip install -e ".[dev]" + + - name: Run tests + run: pytest --cov=infrasynth --cov-report=xml --cov-report=term-missing -v + env: + DJANGO_SETTINGS_MODULE: config.settings.test + + - name: Upload coverage to Codecov + uses: codecov/codecov-action@v5 + with: + files: ./coverage.xml + flags: unittests + if: success() || failure() + + lint: + name: Lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + + - name: Install dependencies + run: pip install ruff + + - name: Run ruff + run: ruff check . + + typecheck: + name: Type Check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + + - name: Install dependencies + run: | + pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run mypy + run: mypy infrasynth/ + + docker: + name: Docker Build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build Docker image + uses: docker/build-push-action@v6 + with: + context: . + push: false + load: true + tags: infrasynth-base:ci + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..aed45f1 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,28 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-json + - id: check-toml + - id: check-added-large-files + args: ["--maxkb=500"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.0 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: local + hooks: + - id: mypy + name: mypy + entry: .venv/bin/mypy + language: system + types: [python] + args: ["infrasynth/"] + pass_filenames: false diff --git a/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf b/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf new file mode 100644 index 0000000..054d0f8 --- /dev/null +++ b/04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 +>> +stream +Gasam_/>bs&A@O6bc.>!+SK1O:+oV2VJ=XN9paqj$Y;mtZp;d>C-THh2Jccfh$+7'NM#CIH+&hXW(Hm2.tA-ZS6npN#jDF^'duVf_T-cgHQda3((a+C;_4"s)D**tN)`8oW(!4)BHJ%T8FCLsQAY5WaFs./E`SE5TFB7j\cV=]bI_V\]nQ(bdPLA?ZR"Ipg-gUS[1b9'@Y0Jj1P$hd9IO^m+2PRo"Puq5d>]bpB5=qP%mJn+q^>;@qkDBZW\!G<3F)%(-GG:j@V4_;`U`bu9"CZ#`Q*??DL]/imrHZFMN_koNh%js*gkImRr5heg78C*o"OmgSs<0mfr5'^5K-?%)j]R81=q6hg1eK_W""dmBH,WI?ebj)?9AZL*\&q[o2!.SeJ%#g9hqkg>Ip3YQVJ"cUN&Oa=?Hi9"$1^/@5Ik0h=%G%bj1!tcp,U-P;DXq0Um?*=S^r8!J5jij3O?DPGpO2OSk1E(2k$g`PGf@X`PDd7]Q)O]^ZJ>HERP%fO!*_DPc6!.$:U5?Wq=I];qL8h#PG^p=8#W!s(B4BOGVlQ~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<9e52a7c939f2a870451bc8004c9e7d1d><9e52a7c939f2a870451bc8004c9e7d1d>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1657 +%%EOF diff --git a/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf b/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf new file mode 100644 index 0000000..3cf5150 --- /dev/null +++ b/141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 650 +>> +stream +Gasam?#Q2d'Rf.Ggo$!/K)-BqE;),i+4I*p;A[o^]eJGH;uo$H[/Tu/gQ4Zed`)V`f>mWUH[H8(UOi,A;ug8\357aE%bW)"#R+a'i&^K/HIsGgK*2XN@NdL+&g?cnPm#mkR#$i\F84J"TE>2E;+Na-X3p2qXEn?+lJ=&J=.M5FX(Uec@=2`;-'Nr]B4(IgRABu*f?AN085P'*u2X-2(1ZN,BN$Xb4I>g7Ys=N2OrD+Zgbc3:9V:S>.7]DCuhpOc)[1r%Z@2F`>P^u^Ht@dakuHUR_Y$g&TJ(IA#nSYN,k^R[m'UhH92U.Onm^)4:\@9HBEM$b<%/ot?06920(\0e=>33P21TTal@BOfmbB&sK=ua$Fq$X[)BfWV)M!&8PY.[1H]V,KK3sP;&_7ftSc6rA)#*1\bs/tk')W[gY,o\9Z@[diYs-)Q&[m>%`IiS\8hNRd0#hB+2"MJ"O_Z:Wk!"#Sdl9(O\JfJ+hJkDlE])'LV5"",/2i!hVTW$W@'#iB(Qendstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1661 +%%EOF diff --git a/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf b/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf new file mode 100644 index 0000000..ef3029b --- /dev/null +++ b/51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 +>> +stream +Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<6708953b0665daa52c06093e6e045b34><6708953b0665daa52c06093e6e045b34>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1657 +%%EOF diff --git a/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf b/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf new file mode 100644 index 0000000..292a22e --- /dev/null +++ b/6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 680 +>> +stream +Gat=(9lldX&A@sBm&/*$GhSd^PlqTL`6HdM,L,LfhqP%>"MkQ72m33FV"!LZ!=@1Y5T**`^VIm3p3VN7;@/0,J2nC<5ckS!U0'n?LJ#LAK][0lM/])VZW*H]!#(Q_aCHS*"0%-TdhR+o=l/S8lntU5OIh"fRUlLYgdWJeXIP(YP9Hl,\08)0_@R(/RQL#A/V3Y[@WJ6Sm;1#_lG;$DdO.?'(ZpKrhFMrbk:MTd?<1XBC$XgPDJeBkW%Va\^61tb@8oj#<3Q'W+,/Gk;l4cQ2G/jjD#KBb0FNrcC5Zr@gD8&j!ZQDdr6;6sFV3\WFc&!'a^KmlUPf%rFuBa=(!)KPB8oUd[qD5K2!9Z,X/uKRS^0T-$HW))>nl&+.3J-O)(:&1Pu;LZ(%^6nr]2JYWbYr)d24Yf>hOr4ej-;.o8^s82TMda%DOkoo?f_oRN2I"MD=?#fr*s*fc1B%M=nTMklq:5'7*-;bOR!&ItU6YR$[,]1lo!9ce8"7)AUZ@5VJEiM1p'A)i9ge~>endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<50bd4355e2b23b12206fd030ee2a05ef><50bd4355e2b23b12206fd030ee2a05ef>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1691 +%%EOF diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..0d13b6c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,392 @@ +# AGENTS.md — InfraSynth Base + +## Project Overview + +InfraSynth Base is a **reusable Django backend infrastructure kit** distributed as a single pip package (`infrasynth-base`). It provides 9 Django apps that cover authentication, authorization, audit logging, file storage, notifications, webhooks, workflows, job scheduling, feature flags, and billing. External systems (App B) install this package and build their domain apps on top without modifying InfraSynth source code. + +**One version, one repo, one pip install.** Feature flags control what is active per tenant/user. + +--- + +## Stack + +| Component | Technology | +|-----------|-----------| +| Language | Python 3.12+ | +| Framework | Django 5.2+ | +| API | Django REST Framework 3.16+ | +| Database | PostgreSQL 16 | +| Cache/Broker | Redis | +| Task Queue | Celery 5.4+ (with django-celery-results + django-celery-beat) | +| Auth | JWT via HTTP-Only cookies (encrypted with Fernet) + API Keys | +| File Storage | S3, Cloudinary, GCS, local (via django-storages) | +| Payments | Stripe, MercadoPago, Wompi | +| 2FA | TOTP (pyotp + qrcode) | +| Anti-spam | ALTCHA (proof-of-work, self-hosted) | +| Monitoring | Flower (Celery dashboard) | +| Tests | pytest + pytest-django + factory-boy | +| Linting | ruff + mypy + pre-commit | + +--- + +## Package Structure + +``` +infrasynth-base/ +├── pyproject.toml # Root package metadata +├── docker-compose.yml +├── PLAN.md # Architecture blueprint +├── AGENTS.md # This file +│ +├── infrasynth/ # Namespace package root +│ ├── shared/ # NOT a Django app. Zero-Django utilities. +│ ├── audit/ # Django app: 'infrasynth.audit' +│ ├── security/ # Django app: 'infrasynth.security' +│ ├── files/ # Django app: 'infrasynth.files' +│ ├── notifications/ # Django app: 'infrasynth.notifications' +│ ├── webhooks/ # Django app: 'infrasynth.webhooks' +│ ├── workflows/ # Django app: 'infrasynth.workflows' +│ ├── scheduler/ # Django app: 'infrasynth.scheduler' +│ ├── features/ # Django app: 'infrasynth.features' +│ └── billing/ # Django app: 'infrasynth.billing' +│ +└── tests/ + ├── conftest.py + ├── test_audit/ + ├── test_security/ + ├── test_files/ + ├── test_notifications/ + ├── test_webhooks/ + ├── test_workflows/ + ├── test_scheduler/ + ├── test_features/ + └── test_billing/ +``` + +--- + +## Architecture Principles + +### 1. Zero Cross-App Import Rule + +**No Django app that depends on another Django app may import from it directly.** The only allowed intra-app imports are: +- `infrasynth.shared.*` (protocols, enums, crypto, types) +- Django stdlib (`django.db.models`, `django.conf.settings`, `django.dispatch.Signal`) + +### 2. Integration Mechanisms (in priority order) + +| Mechanism | When to use | Example | +|-----------|------------|---------| +| **Settings dict** | Configure which concrete class/backend to use | `INFRASYNTH_NOTIFICATIONS["CHANNELS"]["email"]` points to SMTPChannel | +| **Signals** | Loose async communication between apps | `billing` emits `payment_succeeded`, App B's receiver sends email via `notifications` | +| **Registries** | Apps self-register capabilities at startup | `EventRegistry.register("helpdesk.ticket.created")` in `apps.py:ready()` | +| **ABCs/Protocols** | Define swappable interfaces | `BasePaymentGateway`, `BaseChannel`, `DataValidatorProtocol` | +| **ForeignKey (SET_NULL)** | Weak model coupling | `StoredFile` referenced by any model, on_delete=SET_NULL, related_name="+" | +| **AUTH_USER_MODEL** | Reference the user model | Always `settings.AUTH_USER_MODEL`, never `auth.User` directly | +| **FeatureService** | Cross-cutting enable/disable | `FeatureService().is_enabled("billing")` gates billing views | + +### 3. Dependency Graph + +``` +infrasynth.shared ← Zero deps (protocols, enums, crypto) + ↑ +infrasynth.audit ← shared only + ↑ +All other Django apps ← shared + audit only + ↑ +infrasynth.features ← Used by ALL apps for feature gating + ↑ (but apps register flags, don't import features) +``` + +### 4. Feature Flags Are the Orchestrator + +`infrasynth.features` is the only app that is **always active**. Every other feature (module, endpoint, UI element) should be gated behind a feature flag. The frontend consumes `GET /api/features/active/` once at boot and renders conditionally. + +Each app registers its flags in `apps.py:ready()`: + +```python +class MyAppConfig(AppConfig): + def ready(self): + from infrasynth.features.registry import FeatureRegistry + FeatureRegistry.register("my_app.feature_x", default=True) +``` + +--- + +## Development Conventions + +### Django App Structure + +Every Django app follows this layout: + +``` +app_name/ +├── __init__.py +├── apps.py # AppConfig: name, feature_flag, ready() for registry registrations +├── models.py # Django models +├── services.py # Business logic (pure Python, no DRF) +├── urls.py # URL patterns +├── serializers.py # DRF serializers +├── views.py # DRF views +├── filters.py # DRF filtersets +├── signals.py # Signal definitions (Signal() instances) +├── middleware.py # Django middleware (if needed) +├── tasks.py # Celery tasks (if needed) +└── migrations/ # Django migrations +``` + +### Model Conventions + +1. **All models use `db_table` prefix:** `audit_model_change_log`, `security_api_key`, `files_stored_file`, etc. +2. **ForeignKey always uses `SET_NULL`** with `null=True, blank=True` unless cascade is semantically required. +3. **`related_name="+"`** on FK to other apps' models to avoid reverse relation clutter. +4. **`settings.AUTH_USER_MODEL`** for user references. Never hardcode `auth.User`. +5. **JSONField for flexible metadata**, not TextField. +6. **Use `infrasynth.shared.enums`** for choice fields (never hardcode strings in choices). + +### Serializer Conventions + +1. **FK fields need `{field}_info`** read-only serialized representations (for frontend display). +2. **Audit fields** (`created_by`, `created_at`, `updated_by`, `updated_at`) when present must be in `read_only_fields` and are populated by signals (not in `ModeloAuditable` base class since we avoid model inheritance). +3. **JSONField fields** need explicit serialization handling (the frontend expects objects, not strings). +4. **Use `SerializerMethodField`** sparingly — prefer annotations in the queryset. + +### View Conventions + +1. **All views are `ModelViewSet`** unless they have no model backing. +2. **Always set `permission_classes = [IsAuthenticated]`** plus specific permission classes. +3. **Always use `select_related()`/`prefetch_related()`** in `get_queryset()` to avoid N+1 queries. +4. **Feature flag check** in `initial()` method for gated views: + +```python +def initial(self, request, *args, **kwargs): + if not FeatureService().is_enabled("billing", user=request.user): + raise NotFound() + super().initial(request, *args, **kwargs) +``` + +5. **Pagination:** All list views use the standard `CustomPagination` class. Query param `?page_size=` (default 25, max 100). +6. **Filtering:** Use `DjangoFilterBackend` with a `FilterSet` class per view. + +### Signal Conventions + +1. **Define signals in `signals.py`** as module-level `Signal()` instances. +2. **Receiver functions go in `receivers.py` or `apps.py:ready()`** (for connecting signals across apps). +3. **Always use `sender=` parameter** when connecting to specific model signals. +4. **Use `@receiver(signal_name)`** decorator pattern. + +### Registry Conventions + +Registries are singleton classes (not instances) with `@classmethod` methods. They live in a `registry.py` file in their owning app: + +- `infrasynth.features.registry.FeatureRegistry` — feature flag definitions +- `infrasynth.webhooks.registry.EventRegistry` — event definitions +- `infrasynth.notifications.resolvers.VariableResolverRegistry` — template variable resolvers +- `infrasynth.workflows.validators.DataValidatorRegistry` — workflow data validators + +Pattern: +```python +class MyRegistry: + _items: dict = {} + + @classmethod + def register(cls, key, **kwargs): + cls._items[key] = kwargs + + @classmethod + def get(cls, key): + return cls._items.get(key) + + @classmethod + def get_all(cls): + return dict(cls._items) +``` + +### Testing Conventions + +1. **Use pytest** with `pytest-django` (`pytest.mark.django_db`). +2. **Use factory-boy** for model factories (`tests/factories.py` in each app test directory). +3. **API tests use `APIClient`** from DRF with JWT cookies set manually. +4. **Test structure:** + - `test_models.py` — model creation, validation, constraints + - `test_services.py` — business logic + - `test_views.py` — API endpoints (auth, permissions, CRUD, edge cases) + - `test_signals.py` — signal emission and receiver behavior + - `test_integration.py` — cross-app communication (registries, signals) + +5. **Conftest fixtures:** + - `api_client` — DRF APIClient + - `authenticated_client` — APIClient with JWT cookies set + - `admin_client` — authenticated superuser client + - `user_factory`, `role_factory`, etc. + +### Settings Conventions + +1. **All InfraSynth settings use the prefix `INFRASYNTH_`** followed by the app name in uppercase. +2. **Settings are dicts**, not flat keys: `INFRASYNTH_SECURITY = {"COOKIE_SECURE": True}`. +3. **Every setting has a sensible default** — the system must run with zero configuration in development. +4. **Read settings with the helper** (not `getattr` directly): + +```python +from infrasynth.shared.settings_utils import get_setting +cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) +``` + +### Crypto Conventions + +1. **Use `infrasynth.shared.crypto`** for Fernet encryption/decryption. +2. **Encrypt secrets at rest:** API keys, SMTP passwords, payment gateway credentials. +3. **Never log encrypted values** — log the fact of encryption, not the ciphertext or plaintext. +4. **CRYPTO_KEY** must be set in environment. Auto-generate in dev if missing (warn loudly). + +### Migration Conventions + +1. **Apps are namespaced** in migrations to avoid collisions: + - `infrasynth.audit.migrations` + - `infrasynth.security.migrations` +2. **Never use `RunPython`** with model imports — use `apps.get_model()`. +3. **Data migrations** go in separate migration files from schema migrations. + +--- + +## Integration Examples for External App B + +### App B needs: custom notification channel + +```python +# helpdesk/channels.py +from infrasynth.notifications.channels.base import BaseChannel + +class SlackChannel(BaseChannel): + channel_type = "slack" + + def send(self, recipient, subject, body, is_html=True, attachments=None): + # Send to Slack webhook + ... + return Result.ok(True) + +# settings.py +INFRASYNTH_NOTIFICATIONS = { + "CHANNELS": { + "slack": { + "primary": "helpdesk.channels.SlackChannel", + }, + }, +} +``` + +### App B needs: webhook handler for a new external service + +```python +# helpdesk/webhook_handlers.py +from infrasynth.webhooks.inbound.handlers import BaseInboundHandler + +class JiraWebhookHandler(BaseInboundHandler): + def verify(self, payload, headers, secret): + # Verify Jira HMAC + ... + + def process(self, event_type, payload): + # Sync Jira issue to local Ticket model + ... + +# Register via admin or data migration: InboundEndpoint(slug="jira", handler="helpdesk.webhook_handlers.JiraWebhookHandler") +``` + +### App B needs: workflow data validation for its domain + +```python +# helpdesk/validators.py +class TicketDataValidator: + def validate(self, node, data, context): + if not data.get("resolution_note"): + raise ValidationError({"resolution_note": "Required when resolving."}) + return data + +# helpdesk/apps.py → ready(): +DataValidatorRegistry.register("ticket_approval", TicketDataValidator()) +``` + +--- + +## Common Patterns and Anti-Patterns + +### ✅ DO + +- Use `settings.AUTH_USER_MODEL` for all user references +- Use signals for cross-app communication +- Register events/resolvers/validators in `apps.py:ready()` +- Gate views/endpoints behind feature flags +- Use `on_delete=SET_NULL` with `null=True, blank=True` for cross-app FKs +- Use `related_name="+"` for FKs to models in other apps +- Use `db_table` prefix for all models +- Encrypt secrets at rest with Fernet +- Use `Result[T, E]` monad for service methods that can fail +- Add `select_related()`/`prefetch_related()` in every view's `get_queryset()` + +### ❌ DON'T + +- Don't import models from one Django app into another Django app +- Don't hardcode `auth.User` — use `settings.AUTH_USER_MODEL` +- Don't use `on_delete=CASCADE` on cross-app FKs +- Don't bypass the FeatureRegistry — always register flags +- Don't hardcode channel URLs, gateway credentials, or SMTP settings in code +- Don't log plaintext secrets, tokens, or passwords +- Don't use signals for synchronous request-response flows (use direct method calls) +- Don't create circular imports — if app A needs app B, and app B needs app A, refactor into shared or use signals +- Don't store file contents in the database — always use the files app's storage abstraction + +--- + +## Key Files Reference + +| File | Purpose | +|------|---------| +| `infrasynth/shared/protocols.py` | All ABCs and Protocols | +| `infrasynth/shared/crypto.py` | Fernet encrypt/decrypt/rotation | +| `infrasynth/shared/enums.py` | All shared enums | +| `infrasynth/shared/results.py` | Result monad | +| `infrasynth/features/registry.py` | Feature flag registry | +| `infrasynth/features/services.py` | Feature flag evaluation | +| `infrasynth/webhooks/registry.py` | Event registry | +| `infrasynth/notifications/resolvers.py` | Template variable resolvers | +| `infrasynth/notifications/channels/base.py` | Channel ABC | +| `infrasynth/billing/gateways/base.py` | Payment gateway ABC | +| `infrasynth/workflows/validators.py` | Data validator protocol + registry | +| `infrasynth/workflows/models.py` | WorkflowAwareModel abstract mixin | +| `infrasynth/security/services.py` | AuthorizationService | +| `infrasynth/security/auth/cookies.py` | CookieJWTAuthentication | +| `infrasynth/security/auth/api_keys.py` | APIKeyAuthentication | +| `infrasynth/security/permissions.py` | HybridPermission + require_permission | +| `infrasynth/files/services.py` | FileService (upload, signed_url, delete) | +| `infrasynth/scheduler/services.py` | TaskService | + +--- + +## Setup for Development + +```bash +# Clone +git clone && cd infrasynth-base + +# Virtual environment +python -m venv .venv && source .venv/bin/activate + +# Install with dev dependencies +pip install -e ".[dev]" + +# Start services +docker compose up -d db redis + +# Run migrations +python manage.py migrate + +# Run tests +pytest + +# Run linter +ruff check . + +# Run type checker +mypy infrasynth/ +``` diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..6157ee5 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,61 @@ +FROM python:3.12-slim AS builder + +WORKDIR /app + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install --no-install-recommends -y \ + build-essential \ + libpq-dev \ + libjpeg-dev \ + zlib1g-dev \ + libfreetype-dev \ + liblcms2-dev \ + libwebp-dev \ + tcl8.6-dev \ + tk8.6-dev \ + libharfbuzz-dev \ + libfribidi-dev \ + libxcb1-dev + +COPY pyproject.toml pyproject.toml + +RUN --mount=type=cache,target=/root/.cache/pip \ + pip install --upgrade pip setuptools wheel && \ + pip install --no-deps --no-build-isolation pyproject.toml || true && \ + pip install --no-build-isolation -e ".[dev]" || \ + pip install --no-build-isolation . + +FROM python:3.12-slim AS runtime + +RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \ + --mount=type=cache,target=/var/lib/apt,sharing=locked \ + apt-get update && apt-get install --no-install-recommends -y \ + libpq5 \ + libjpeg62-turbo \ + libfreetype6 \ + liblcms2-2 \ + libwebp7 \ + libharfbuzz0b \ + libfribidi0 \ + && rm -rf /var/lib/apt/lists/* + +RUN groupadd -r app && useradd -r -g app -d /app -s /sbin/nologin app + +WORKDIR /app + +COPY --from=builder /usr/local/lib/python3.12/site-packages /usr/local/lib/python3.12/site-packages +COPY --from=builder /usr/local/bin /usr/local/bin + +COPY . . + +RUN chown -R app:app /app + +USER app + +ENV PYTHONUNBUFFERED=1 \ + PYTHONDONTWRITEBYTECODE=1 + +EXPOSE 8000 + +CMD ["gunicorn", "config.wsgi:application", "--bind", "0.0.0.0:8000", "--workers", "4", "--timeout", "120"] diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d107fd2 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,3125 @@ +# InfraSynth Base — Plan de Arquitectura + +[![CI](https://github.com/anomalyco/infrasynth-base/actions/workflows/ci.yml/badge.svg)](https://github.com/anomalyco/infrasynth-base/actions/workflows/ci.yml) +[![Coverage](https://codecov.io/gh/anomalyco/infrasynth-base/branch/master/graph/badge.svg)](https://codecov.io/gh/anomalyco/infrasynth-base) +[![Python](https://img.shields.io/badge/python-3.12+-blue.svg)](https://www.python.org/downloads/) +[![Django](https://img.shields.io/badge/django-5.2+-green.svg)](https://www.djangoproject.com/) +[![Ruff](https://img.shields.io/badge/code%20style-ruff-000000.svg)](https://github.com/astral-sh/ruff) +[![Mypy](https://img.shields.io/badge/type%20checked-mypy-2a5075.svg)](https://mypy-lang.org/) + +## 0. Visión General + +InfraSynth Base es un conjunto de Django apps reutilizables que proveen la infraestructura común para cualquier sistema de negocio. Se instala como un solo paquete pip (`infrasynth-base`), se configura desde `settings.py`, y cada app puede habilitarse/deshabilitarse dinámicamente vía feature flags. + +**Principio rector:** Una app externa (App B) nunca debe modificar el código fuente de InfraSynth para integrarse. Toda integración ocurre vía settings, registries, signals, ABCs swappables, o feature flags. + +> **IMPORTANTE:** Este plan debe actualizarse cada vez que una fase avanza. Marcar fases como `✅` (completada), `🔄` (en progreso), o `⬜` (pendiente) con la fecha del cambio. + +--- + +## 0.5 Estado de Implementación + +### Fase 1 — Scaffolding del Proyecto ✅ _(2026-07-30)_ +- [x] `pyproject.toml` + docker-compose.yml + manage.py +- [x] `config/` Django project (settings split: base/dev/test, celery, wsgi, urls) +- [x] Skeleton for all 10 modules (models, serializers, views, filters, urls, signals, apps) +- [x] `pip install -e ".[dev]"` + `python manage.py check` ✅ _(2026-07-30)_ + +### Fase 2 — Foundation (`infrasynth/shared/`) ✅ _(2026-07-30)_ +- [x] All 5 modules implemented (protocols, enums, results, crypto, settings_utils) +- [x] Tests: results, crypto, enums, settings_utils, protocols ✅ _(2026-07-30)_ + +### Fase 3 — Feature Flags (`infrasynth/features/`) ✅ _(2026-07-30)_ +- [x] `models.py` — FeatureFlag + FeatureFlagOverride +- [x] `registry.py` — FeatureRegistry (register/get_all) +- [x] `services.py` — FeatureService (is_enabled, get_active_flags, caching, overrides) +- [x] `decorators.py` — @feature_required +- [x] `views.py` — CRUD + /active/ + /check// endpoints +- [x] Tests: services, views, decorators, registry ✅ _(2026-07-30)_ + +### Fase 4 — Auditoría (`infrasynth/audit/`) ✅ _(2026-07-30)_ +- [x] `receivers.py` — post_save/post_delete tracking, excluded_models, excluded_fields, diff +- [x] `middleware.py` — body capture, sensitive key filtering, request_id +- [x] `views.py` — list/retrieve endpoints for all 3 models +- [x] Tests: model save/delete creates logs, middleware logs API calls ✅ _(2026-07-30)_ + - Fixes: filterset_class wired into views, ordering added, SystemUser actor guard in middleware + +### Fase 5 — Seguridad: Auth (`infrasynth/security/auth/`) ✅ _(2026-07-30)_ +- [x] `auth/cookies.py` — CookieJWTAuthentication (encrypt/decrypt tokens via Fernet) +- [x] `auth/api_keys.py` — APIKeyAuthentication (prefix.secret, PBKDF2, scopes, SystemUser) +- [x] `auth/backends.py` — EmailOrUsernameBackend +- [x] `auth/middleware.py` — JWTAuthenticationMiddleware +- [x] `views.py` — login/logout/refresh/check endpoints +- [x] Tests: all auth flows ✅ _(2026-07-30)_ + - Fixes: AUTHENTICATION_BACKENDS wired from INFRASYNTH_SECURITY, `import secrets` added, APIKeySerializer create/update + real key exposure + +### Fase 6 — Seguridad: Autorización (`infrasynth/security/`) ✅ _(2026-07-30)_ +- [x] `services.py` — AuthorizationService (has_permission, get_effective_permissions, chain) +- [x] `permissions.py` — HybridPermission + require_permission +- [x] Views: roles, grants, revokes CRUD +- [x] Tests: permission resolution chain (superuser → revoke → grant → role → default) ✅ _(2026-07-30)_ + - Fixes: Role.users M2M added (related_name="roles"), SystemUser scopes as permissions, RoleViewSet lookup by slug, PermissionDenied instead of PermissionError + +### Fase 7 — Seguridad: 2FA y ALTCHA ✅ _(2026-07-30)_ +- [x] `two_factor/services.py` — TOTPService (generate_secret, verify, QR), RecoveryCodeService +- [x] `two_factor/middleware.py` — enforce 2FA for configured users +- [x] `altcha/services.py` — create_challenge, verify PoW +- [x] Views: setup, verify-setup, verify, disable, recovery, challenge, verify +- [x] Tests: 2FA flow + ALTCHA flow ✅ _(2026-07-30)_ + - Fixes: session cleanup uses pop() (no KeyError on missing pre-auth token) + +### Fase 8 — Almacenamiento (`infrasynth/files/`) ✅ _(2026-07-30)_ +- [x] Models: StoredFile, FileCategory, ProcessingPipeline, PipelineExecution +- [x] Views: StoredFileViewSet, FileCategoryViewSet, ProcessingPipelineViewSet +- [x] `storage.py` — Storage router: S3, local, GCS, cloudinary backends implementados ✅ _(2026-07-30)_ +- [x] `services.py` — FileService: upload, get_signed_url, get_download_response, delete (soft/hard), get_file_info ✅ _(2026-07-30)_ +- [x] `processing.py` — PipelineExecutor (resize/optimize/watermark/scan) + Celery task run_pipeline_execution ✅ _(2026-07-30)_ +- [x] Views: download action wired to FileService + feature flag gates en los 3 viewsets ✅ _(2026-07-30)_ +- [x] Tests: upload retrieves file, download returns response, delete marks removed ✅ _(2026-07-30)_ + +### Fase 9 — Notificaciones (`infrasynth/notifications/`) ✅ _(2026-07-30)_ +- [x] Models: NotificationTemplate, NotificationDispatch, ChannelConfig +- [x] `channels/base.py` — BaseChannel ABC + Attachment +- [x] `resolvers.py` — VariableResolverRegistry (register/resolve/get_available_variables) +- [x] `services.py` — NotificationService.send() con template rendering + failover + tasks Celery (sync/celery/thread) ✅ _(2026-07-30)_ +- [x] `channels/` — SMTPChannel, SendGridChannel, TwilioSMSChannel, TelegramChannel ✅ _(2026-07-30)_ +- [x] Tests: template render, dispatch creates log, failover works ✅ _(2026-07-30)_ + +### Fase 10 — Webhooks (`infrasynth/webhooks/`) ✅ _(2026-07-30)_ +- [x] `registry.py` — EventRegistry (register/emit with OutboundSubscription lookup + Celery dispatch) +- [x] `signature.py` — HMAC sign_payload / verify_signature +- [x] `inbound/handlers.py` — BaseInboundHandler ABC +- [x] Views: outbound endpoints, subscriptions, deliveries, inbound endpoints/events, receive +- [x] `dispatch.py` — deliver_webhook Celery task: HTTP POST, HMAC signature, payload template, retry/backoff, signals ✅ _(2026-07-30)_ +- [x] Tests: emit notifies subscribers, inbound signature verification ✅ _(2026-07-30)_ + - Fix: payload template rendering usa `Context` explícito (compat Django 5.2+) + +### Fase 11 — Flujos de Trabajo (`infrasynth/workflows/`) ✅ _(2026-07-30)_ +- [x] Models: Workflow, WorkflowNode, Transition, WorkflowInstance, NodeAssignment, WorkflowObserver, WorkflowAwareModel +- [x] `validators.py` — DataValidatorProtocol + DataValidatorRegistry +- [x] `engine.py` — WorkflowEngine: start(), submit_decision(), get_route(), get_node_states(), get_role_in_instance(), assign_users(), add_observer() ✅ _(2026-07-30)_ +- [x] Views: route/state actions + submit/assign/observers wired al engine ✅ _(2026-07-30)_ +- [x] Tests: start workflow, approve/reject advances, route tracking ✅ _(2026-07-30)_ + +### Fase 12 — Scheduler (`infrasynth/scheduler/`) ✅ _(2026-07-30)_ +- [x] Models: ScheduledTask, TaskExecution +- [x] Views: tasks CRUD, executions, queue-status, workers +- [x] `services.py` — TaskService: run_now (Celery + plain functions), toggle, get_queue_status, get_workers ✅ _(2026-07-30)_ +- [x] Tests: run_now triggers Celery, toggle enables/disables ✅ _(2026-07-30)_ + +### Fase 13 — Facturación (`infrasynth/billing/`) ✅ _(2026-07-30)_ +- [x] Models: PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction +- [x] `gateways/base.py` — BasePaymentGateway ABC + CheckoutSessionResult, WebhookResult +- [x] Views: gateways, plans, subscriptions, subscribe, invoices, webhook receive +- [x] `services.py` — BillingService: create_checkout_session, create_subscription, cancel_subscription, sync_subscription, generate_invoice ✅ _(2026-07-30)_ +- [x] `invoice_generator.py` — generate_invoice_pdf Celery task con reportlab Platypus + almacenamiento via FileService ✅ _(2026-07-30)_ +- [x] Views: subscribe + webhook receive wired a BillingService/BCG ✅ _(2026-07-30)_ +- [x] `gateways/` — StripeGateway, MercadoPagoGateway, WompiGateway ✅ _(2026-07-30)_ +- [x] Tests: create subscription, webhook handling, invoice generation ✅ _(2026-07-30)_ + - Fix: signal `subscription_created` tolera gateway None; `StripeGateway` usa `datetime.timezone.utc` (compat Django 5.2+) + +### Fase 14 — Tests de Integración ✅ _(2026-07-31)_ +- [x] Cross-app signals: billing → notifications, webhooks → audit +- [x] Registries: FeatureRegistry, EventRegistry, VariableResolverRegistry, DataValidatorRegistry +- [x] E2E: login → feature flags → permission check → webhook emit → audit log +- [x] Coverage 93% (target: ≥80%) + +### Fase 15 — Linting, Type Checking y CI ✅ _(2026-07-31)_ +- [x] ruff check . sin errores +- [x] mypy infrasynth/ sin errores +- [x] pre-commit hooks configurados +- [x] GitHub Actions: tests + lint + typecheck + docker build +- [x] Dockerfile multi-stage + docker-compose con health checks +- [x] Badges (CI, coverage, python, django, ruff, mypy) en PLAN.md +- [ ] Docker push (pendiente de registry config) + +## 1. Estructura del Paquete + +``` +backend-package/ # ← repo root / pip package root +├── pyproject.toml # name="infrasynth-base", version="1.0.0" +├── README.md +├── docker-compose.yml +├── AGENTS.md +├── PLAN.md +├── manage.py # Django management script +│ +├── config/ # Django project config (local dev only, NOT in pip package) +│ ├── __init__.py +│ ├── settings/ +│ │ ├── __init__.py +│ │ ├── base.py # Common settings (all apps, middleware, DRF, Celery) +│ │ ├── dev.py # Development overrides (DEBUG=True, local DB) +│ │ └── test.py # Test settings (sqlite in-memory, eager Celery) +│ ├── urls.py # Root URL conf (routes all app endpoints) +│ ├── wsgi.py # WSGI application +│ └── celery.py # Celery application loader +│ +├── infrasynth/ # Namespace package (distributed via pip) +│ ├── __init__.py +│ │ +│ ├── shared/ # No es Django app. Utilidades base zero-Django. +│ │ ├── __init__.py +│ │ ├── protocols.py # ABCs: AuditableProtocol, EventProtocol, TenantProtocol +│ │ ├── crypto.py # FernetAES encrypt/decrypt, key rotation +│ │ ├── enums.py # Enums base (ChannelType, EventSeverity, BillingInterval, etc.) +│ │ ├── results.py # Result[T, E] monad +│ │ └── settings_utils.py # get_setting() helper con defaults +│ │ +│ ├── audit/ # Django app: 'infrasynth.audit' +│ │ ├── __init__.py +│ │ ├── apps.py # AuditConfig(AppConfig), registra flag "audit" +│ │ ├── receivers.py # Signal handlers (post_save, post_delete) +│ │ ├── models.py # ModelChangeLog, APIInteractionLog, SecurityEvent +│ │ ├── middleware.py # API audit middleware +│ │ ├── signals.py # model_changed, security_event_occurred +│ │ ├── mixins.py # OptionalAuditableMixin (opcional, no obligatorio) +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ └── migrations/ +│ │ +│ ├── security/ # Django app: 'infrasynth.security' +│ │ ├── __init__.py +│ │ ├── apps.py # SecurityConfig(AppConfig), registra flag "security" +│ │ ├── models.py # Role, Grant, Revoke, APIKey, TwoFactorConfig, ALTCHAChallenge +│ │ ├── services.py # AuthorizationService: has_permission(), get_permissions() +│ │ ├── permissions.py # HybridPermission, require_permission decorator +│ │ ├── auth/ +│ │ │ ├── __init__.py +│ │ │ ├── cookies.py # CookieJWTAuthentication +│ │ │ ├── api_keys.py # APIKeyAuthentication (DRF class) +│ │ │ ├── backends.py # EmailOrUsernameBackend +│ │ │ └── middleware.py # JWTAuthenticationMiddleware +│ │ ├── two_factor/ +│ │ │ ├── __init__.py +│ │ │ ├── services.py # TOTPService, RecoveryCodeService +│ │ │ ├── middleware.py # 2FA enforcement middleware +│ │ │ └── utils.py # Token generation +│ │ ├── altcha/ +│ │ │ ├── __init__.py +│ │ │ └── services.py # Challenge/verify PoW +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── files/ # Django app: 'infrasynth.files' +│ │ ├── __init__.py +│ │ ├── apps.py # FilesConfig, registra flag "files" +│ │ ├── models.py # StoredFile, FileCategory, ProcessingPipeline, PipelineExecution +│ │ ├── storage.py # Storage router (S3, Cloudinary, GCS, local) +│ │ ├── services.py # FileService: upload, get_signed_url, delete +│ │ ├── processing.py # Pipeline executor (resize, optimize, scan, watermark) +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── notifications/ # Django app: 'infrasynth.notifications' +│ │ ├── __init__.py +│ │ ├── apps.py # NotificationsConfig, registra flag "notifications" +│ │ ├── models.py # NotificationTemplate, NotificationDispatch, ChannelConfig +│ │ ├── services.py # NotificationService: send(), send_with_failover() +│ │ ├── resolvers.py # VariableResolverRegistry (global singleton) +│ │ ├── channels/ +│ │ │ ├── __init__.py +│ │ │ ├── base.py # BaseChannel ABC +│ │ │ ├── email_smtp.py # SMTPChannel (stub) +│ │ │ ├── email_sendgrid.py # SendGridChannel (stub) +│ │ │ ├── sms_twilio.py # TwilioSMSChannel (stub) +│ │ │ └── telegram.py # TelegramChannel (stub) +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── webhooks/ # Django app: 'infrasynth.webhooks' +│ │ ├── __init__.py +│ │ ├── apps.py # WebhooksConfig, registra flags "webhooks", "webhooks_outbound", "webhooks_inbound" +│ │ ├── models.py # OutboundEndpoint, OutboundSubscription, OutboundDelivery, +│ │ │ # InboundEndpoint, InboundEvent +│ │ ├── registry.py # EventRegistry (singleton global para registrar/disparar eventos) +│ │ ├── signature.py # HMAC signing/verification +│ │ ├── dispatch.py # Outbound delivery + retry (Celery tasks stub) +│ │ ├── inbound/ +│ │ │ ├── __init__.py +│ │ │ └── handlers.py # BaseInboundHandler ABC +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── workflows/ # Django app: 'infrasynth.workflows' +│ │ ├── __init__.py +│ │ ├── apps.py # WorkflowsConfig, registra flag "workflows" +│ │ ├── models.py # Workflow, WorkflowNode, Transition, WorkflowInstance, +│ │ │ # NodeAssignment, WorkflowObserver, WorkflowAwareModel (abstract) +│ │ ├── engine.py # Core engine: start(), decide(), get_route(), get_node_states() +│ │ ├── validators.py # DataValidatorProtocol + DataValidatorRegistry +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── scheduler/ # Django app: 'infrasynth.scheduler' +│ │ ├── __init__.py +│ │ ├── apps.py # SchedulerConfig, registra flag "scheduler" +│ │ ├── models.py # ScheduledTask, TaskExecution +│ │ ├── services.py # TaskService: run_now(), toggle(), get_queue_status() +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ ├── features/ # Django app: 'infrasynth.features' +│ │ ├── __init__.py +│ │ ├── apps.py # FeaturesConfig (esta es la UNICA app siempre activa) +│ │ ├── models.py # FeatureFlag, FeatureFlagOverride +│ │ ├── registry.py # FeatureRegistry (singleton global) +│ │ ├── services.py # FeatureService: is_enabled(), get_active_flags() +│ │ ├── decorators.py # @feature_required para views +│ │ ├── serializers.py +│ │ ├── views.py +│ │ ├── filters.py +│ │ ├── urls.py +│ │ ├── signals.py +│ │ └── migrations/ +│ │ +│ └── billing/ # Django app: 'infrasynth.billing' +│ ├── __init__.py +│ ├── apps.py # BillingConfig, registra flag "billing" +│ ├── models.py # PaymentGateway, BillingPlan, Subscription, Invoice, PaymentTransaction +│ ├── services.py # Billing service stub +│ ├── invoice_generator.py # Generación de PDF (Celery task stub) +│ ├── gateways/ +│ │ ├── __init__.py +│ │ ├── base.py # BasePaymentGateway ABC +│ │ ├── stripe.py # StripeGateway (stub) +│ │ ├── mercadopago.py # MercadoPagoGateway (stub) +│ │ └── wompi.py # WompiGateway (stub) +│ ├── serializers.py +│ ├── views.py +│ ├── filters.py +│ ├── urls.py +│ ├── signals.py +│ └── migrations/ +│ +└── tests/ + ├── conftest.py # Fixtures compartidos (factory boy, API client) + ├── test_audit/ + ├── test_security/ + ├── test_files/ + ├── test_notifications/ + ├── test_webhooks/ + ├── test_workflows/ + ├── test_scheduler/ + ├── test_features/ + └── test_billing/ +``` + +--- + +## 2. Especificación Detallada por App + +### 2.1 `infrasynth.shared` — Fundación Cero-Django + +**Propósito:** Tipos base, protocolos, utilidades criptográficas, y enums compartidos por todo el ecosistema. +**No tiene dependencias de Django.** Todo lo demás depende de este módulo. + +``` +infrasynth/shared/ +├── protocols.py +├── crypto.py +├── enums.py +├── results.py +└── settings_utils.py +``` + +#### `protocols.py` — ABCs y Protocolos + +```python +from typing import Protocol, runtime_checkable, Any +from datetime import datetime + +@runtime_checkable +class AuditableProtocol(Protocol): + """Cualquier modelo que quiera ser trackeado por audit debe exponer esta interfaz.""" + pk: Any + usuario_creacion: Any | None + fecha_creacion: datetime | None + usuario_actualizacion: Any | None + fecha_actualizacion: datetime | None + + class Meta: + abstract = True + +class EventProtocol(Protocol): + """Contrato que todo evento (webhook, signal) debe cumplir.""" + event_name: str + payload: dict + timestamp: str + +class TenantProtocol(Protocol): + """Protocolo para modelos que soporten multi-tenant.""" + tenant_id: str | None +``` + +#### `crypto.py` — Utilidades Criptográficas + +```python +from cryptography.fernet import Fernet +from django.conf import settings + +def get_fernet() -> Fernet: + """Obtiene instancia Fernet desde CRYPTO_KEY en settings.""" + ... + +def encrypt(value: str) -> str: + """Encripta un string y retorna el token Fernet.""" + ... + +def decrypt(token: str) -> str: + """Desencripta un token Fernet. Lanza ValueError si es inválido.""" + ... + +def generate_key() -> str: + """Genera una nueva Fernet key (para bootstraping).""" + ... + +def rotate_keys(old_key: str, new_key: str, tokens: list[str]) -> list[str]: + """Re-encripta tokens de old_key a new_key.""" + ... +``` + +Esta implementación usa Fernet simétrico (mismo secreto para encrypt/decrypt), igual que el sistema actual pero con soporte de rotación de claves. + +#### `enums.py` + +```python +from enum import StrEnum + +class ChannelType(StrEnum): + EMAIL = "email" + SMS = "sms" + WHATSAPP = "whatsapp" + TELEGRAM = "telegram" + PUSH = "push" + +class EventSeverity(StrEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + +class BillingInterval(StrEnum): + MONTHLY = "monthly" + YEARLY = "yearly" + +class AuditAction(StrEnum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + +class SubscriptionStatus(StrEnum): + ACTIVE = "active" + PAST_DUE = "past_due" + CANCELLED = "cancelled" + EXPIRED = "expired" + TRIALING = "trialing" + +class InvoiceStatus(StrEnum): + DRAFT = "draft" + OPEN = "open" + PAID = "paid" + VOID = "void" + UNCOLLECTIBLE = "uncollectible" + +class ApprovalStrategy(StrEnum): + ANY = "any" # Cualquier aprobación avanza + ALL = "all" # Todas las aprobaciones requeridas + MAJORITY = "majority" # Mayoría simple +``` + +#### `results.py` — Result Monad + +```python +from dataclasses import dataclass +from typing import Generic, TypeVar + +T = TypeVar("T") +E = TypeVar("E") + +@dataclass +class Result(Generic[T, E]): + """Monad para manejo explícito de errores sin excepciones.""" + value: T | None = None + error: E | None = None + + @property + def is_ok(self) -> bool: + return self.error is None + + @property + def is_err(self) -> bool: + return self.error is not None + + @staticmethod + def ok(value: T) -> "Result[T, E]": + return Result(value=value) + + @staticmethod + def err(error: E) -> "Result[T, E]": + return Result(error=error) +``` + +#### `settings_utils.py` — Helper de Configuración + +```python +from django.conf import settings + +def get_setting(prefix: str, key: str, default=None): + """ + Lee una setting con prefijo de app. + Ej: get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) + Busca settings.INFRASYNTH_SECURITY["COOKIE_SECURE"] con fallback a default. + """ + ... +``` + +--- + +### 2.2 `infrasynth.audit` — Capa de Auditoría + +**Feature flag:** `audit` (default: True) +**Dependencias:** `infrasynth.shared` + +#### Modelos + +```python +class ModelChangeLog(models.Model): + """ + Registro de mutación en cualquier modelo Django. + Poblado automáticamente por signal handlers (post_save, post_delete). + Los modelos de dominio NO necesitan heredar nada. + """ + model_label = models.CharField(max_length=200, db_index=True) + object_id = models.CharField(max_length=200, db_index=True) + action = models.CharField(max_length=10, choices=[("create", "create"), ("update", "update"), ("delete", "delete")]) + changes = models.JSONField(help_text="Dict con {field_name: [old_value, new_value]}") + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64, help_text="UUID de request para correlación") + + class Meta: + db_table = "audit_model_change_log" + indexes = [ + models.Index(fields=["model_label", "object_id"]), + models.Index(fields=["timestamp"]), + ] + + +class APIInteractionLog(models.Model): + """ + Registro de request/response HTTP. + Poblado por middleware. + """ + method = models.CharField(max_length=10, db_index=True) + path = models.CharField(max_length=500, db_index=True) + status_code = models.PositiveSmallIntegerField(db_index=True) + request_body = models.JSONField(null=True, blank=True) + response_body = models.JSONField(null=True, blank=True) + ip_address = models.GenericIPAddressField(null=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + duration_ms = models.PositiveIntegerField() + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64, unique=True) + user_agent = models.TextField(blank=True, default="") + + class Meta: + db_table = "audit_api_interaction_log" + + +class SecurityEvent(models.Model): + """ + Eventos de seguridad (login, logout, failed login, permission denied, etc.) + Poblado vía seguridad security_event_occurred signal. + """ + event_type = models.CharField(max_length=50, db_index=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + ip_address = models.GenericIPAddressField(null=True) + metadata = models.JSONField(default=dict) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64) + + class Meta: + db_table = "audit_security_event" +``` + +#### Señales Expuestas + +```python +# infrasynth/audit/signals.py +from django.dispatch import Signal + +model_changed = Signal() # kwargs: model_label, object_id, action, changes, actor +security_event_occurred = Signal() # kwargs: event_type, actor, ip_address, metadata +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/audit/changes/` | GET | `audit.view_model_changes` | Listar cambios de modelos (filtrable) | +| `/audit/changes//` | GET | `audit.view_model_changes` | Detalle de un cambio | +| `/audit/api-logs/` | GET | `audit.view_api_logs` | Listar interacciones API | +| `/audit/api-logs//` | GET | `audit.view_api_logs` | Detalle de interacción | +| `/audit/security-events/` | GET | `audit.view_security_events` | Listar eventos de seguridad | + +#### Configuración Externalizable + +```python +# settings.py del proyecto consumidor +INFRASYNTH_AUDIT = { + "EXCLUDED_MODELS": ["sessions.Session", "admin.LogEntry", "contenttypes.ContentType"], + "EXCLUDED_FIELDS": ["password", "token", "secret", "credit_card"], + "SENSITIVE_KEYS": ["password", "token", "secret", "authorization", "api_key"], + "MAX_BODY_SIZE_BYTES": 5000, + "STORE_IN_DB": True, + "RETENTION_DAYS": 365, + "ENABLE_API_LOGGING": True, + "ENABLE_MODEL_CHANGE_TRACKING": True, + "ENABLE_SECURITY_EVENTS": True, +} +``` + +#### Patrón de Integración para App B + +```python +# App B: any_model.py — NO necesita importar audit ni heredar nada +class Ticket(models.Model): + subject = models.CharField(max_length=255) + # ... fields ... + +# El signal handler en audit/apps.py hace: +# @receiver(post_save) +# def track_model_changes(sender, instance, created, raw, **kwargs): +# if sender._meta.label in EXCLUDED_MODELS: return +# if created: log "create" +# else: log "update" with field diffs + +# App B también puede escuchar eventos de audit: +from infrasynth.audit.signals import security_event_occurred + +@receiver(security_event_occurred) +def on_security_event(sender, event_type, actor, ip_address, metadata, **kwargs): + if event_type == "login_failed": + # Notificar al equipo de seguridad + ... +``` + +--- + +### 2.3 `infrasynth.security` — Núcleo de Seguridad y Acceso + +**Feature flag:** `security` (default: True — es el core del sistema) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class Role(models.Model): + """ + Rol con permisos definidos como lista JSON. + Un usuario puede tener múltiples roles. + Los permisos de roles se suman (unión). + """ + name = models.CharField(max_length=100) + slug = models.SlugField(max_length=100, unique=True) + description = models.TextField(blank=True) + permissions = models.JSONField(default=list, help_text="Lista de codenames de permiso") + is_system = models.BooleanField(default=False, help_text="Roles de sistema no se pueden eliminar") + + class Meta: + db_table = "security_role" + + def __str__(self): + return self.name + + +class Grant(models.Model): + """ + Concesión directa de un permiso a un usuario específico. + Puede tener expiración. Prevalece sobre el rol (si hay conflicto, gana el grant). + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_grants") + codename = models.CharField(max_length=200, db_index=True) + granted_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="grants_given") + reason = models.TextField(blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "security_grant" + unique_together = [("user", "codename")] + + +class Revoke(models.Model): + """ + Revocación explícita de un permiso a un usuario. + Prevalece sobre grants y roles. Si existe un revoke, el permiso se deniega. + """ + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="direct_revokes") + codename = models.CharField(max_length=200, db_index=True) + revoked_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="revokes_given") + reason = models.TextField(blank=True) + + class Meta: + db_table = "security_revoke" + unique_together = [("user", "codename")] + + +class APIKey(models.Model): + """ + Clave de API para autenticación servicio-a-servicio. + El secret se hashea con PBKDF2. Solo el prefix es visible. + """ + name = models.CharField(max_length=200) + prefix = models.CharField(max_length=12, unique=True, help_text="Primeros 8 caracteres visibles en UI") + key_hash = models.CharField(max_length=255, help_text="Hash PBKDF2 del secret completo") + scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + is_active = models.BooleanField(default=True) + expires_at = models.DateTimeField(null=True, blank=True) + last_used_at = models.DateTimeField(null=True, blank=True) + rotated_from = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True) + + class Meta: + db_table = "security_api_key" + + +class TwoFactorConfig(models.Model): + """ + Configuración de doble factor por usuario. + Soporta TOTP, email, o ambos. + """ + METHOD_TOTP = "totp" + METHOD_EMAIL = "email" + METHOD_BOTH = "both" + + user = models.OneToOneField(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="two_factor_config") + is_enabled = models.BooleanField(default=False) + is_configured = models.BooleanField(default=False) + method = models.CharField(max_length=10, choices=[(METHOD_TOTP, "TOTP"), (METHOD_EMAIL, "Email"), (METHOD_BOTH, "Both")], default=METHOD_TOTP) + secret_key_encrypted = models.CharField(max_length=500, null=True, blank=True) + recovery_codes_encrypted = models.TextField(null=True, blank=True) + email_verified = models.BooleanField(default=False) + email_code = models.CharField(max_length=6, null=True, blank=True) + email_code_expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "security_two_factor_config" + + +class ALTCHAChallenge(models.Model): + """ + Desafío proof-of-work para protección anti-spam. + """ + challenge_id = models.CharField(max_length=64, primary_key=True) + salt = models.CharField(max_length=32) + difficulty = models.IntegerField(default=10000) + expires_at = models.DateTimeField(db_index=True) + is_verified = models.BooleanField(default=False) + + class Meta: + db_table = "security_altcha_challenge" +``` + +#### Algoritmo de Autorización Híbrida + +```python +# infrasynth/security/services.py +class AuthorizationService: + """Servicio singleton para resolución de permisos.""" + + def has_permission(self, user, codename: str) -> bool: + """ + Resuelve si un usuario tiene un permiso específico. + + Orden de evaluación (el primero que match gana): + 1. Superuser → ACCESO TOTAL + 2. Revoke explícito → DENEGAR + 3. Grant directo no expirado → CONCEDER + 4. Grant vía rol → CONCEDER (unión de todos los roles) + 5. Default → DENEGAR + """ + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + if Revoke.objects.filter(user=user, codename=codename).exists(): + return False + if Grant.objects.filter( + user=user, codename=codename + ).filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()) + ).exists(): + return True + user_roles = user.roles.values_list("permissions", flat=True) + for perm_list in user_roles: + if codename in (perm_list or []): + return True + return False + + def get_effective_permissions(self, user) -> set[str]: + """Devuelve el set completo de permisos efectivos del usuario.""" + if not user or not user.is_authenticated: + return set() + if user.is_superuser: + return {"*"} # Wildcard — el frontend debe interpretar "*" como acceso total en cualquier check + revoked = set(Revoke.objects.filter(user=user).values_list("codename", flat=True)) + granted = set( + Grant.objects.filter(user=user).filter( + Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now()) + ).values_list("codename", flat=True) + ) + role_perms = set() + for perm_list in user.roles.values_list("permissions", flat=True): + role_perms.update(perm_list or []) + return (granted | role_perms) - revoked + + def has_all_permissions(self, user, codenames: list[str]) -> bool: + """Verifica que el usuario tenga TODOS los permisos listados.""" + return all(self.has_permission(user, c) for c in codenames) + + def has_any_permission(self, user, codenames: list[str]) -> bool: + """Verifica que el usuario tenga AL MENOS UNO de los permisos listados.""" + return any(self.has_permission(user, c) for c in codenames) +``` + +#### Autenticación JWT via Cookies HTTP-Only + +```python +# infrasynth/security/auth/cookies.py +class CookieJWTAuthentication(JWTAuthentication): + """ + Lee el token JWT desde una cookie HTTP-Only cifrada con Fernet. + Igual que el sistema actual pero con configuración externalizada. + """ + + def authenticate(self, request): + cookie_name = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") + raw_token = request.COOKIES.get(cookie_name) + if not raw_token: + return None + try: + decrypted = decrypt(raw_token) + validated_token = self.get_validated_token(decrypted) + except Exception: + raise AuthenticationFailed("Token inválido o expirado.") + return self.get_user(validated_token), validated_token +``` + +#### API Key Authentication + +```python +# infrasynth/security/auth/api_keys.py +class APIKeyAuthentication(BaseAuthentication): + """ + Autenticación servicio-a-servicio via header X-API-Key. + Formato: X-API-Key: {prefix}.{secret} + """ + keyword = "X-API-Key" + + def authenticate(self, request): + raw_key = request.META.get(f"HTTP_{self.keyword.replace('-', '_').upper()}") + if not raw_key: + return None + try: + prefix, secret = raw_key.split(".", 1) + except ValueError: + raise AuthenticationFailed("Formato de API key inválido.") + api_key = APIKey.objects.filter(prefix=prefix, is_active=True).first() + if not api_key: + raise AuthenticationFailed("API key no encontrada.") + if not check_password(secret, api_key.key_hash): + raise AuthenticationFailed("API key inválida.") + if api_key.expires_at and api_key.expires_at < timezone.now(): + raise AuthenticationFailed("API key expirada.") + api_key.last_used_at = timezone.now() + api_key.save(update_fields=["last_used_at"]) + # Crear un "system user" anónimo con scopes como permisos + return (SystemUser(scopes=api_key.scopes), api_key) +``` + +#### Señales Expuestas + +```python +user_logged_in = Signal() # kwargs: user, ip, user_agent +user_logged_out = Signal() # kwargs: user +login_failed = Signal() # kwargs: credentials_key, ip, reason +two_factor_setup = Signal() # kwargs: user, method +two_factor_verified = Signal() # kwargs: user, method +api_key_created = Signal() # kwargs: key_name, created_by +api_key_rotated = Signal() # kwargs: key_name, rotated_by +grant_created = Signal() # kwargs: user, codename, granted_by +grant_revoked = Signal() # kwargs: user, codename, reason +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/auth/login/` | POST | None | Login con email/username + password. Setea cookies HTTP-Only | +| `/auth/logout/` | POST | IsAuthenticated | Limpia cookies | +| `/auth/refresh/` | POST | None | Refresh token desde cookie refresh | +| `/auth/check/` | GET | None | Verifica sesión activa. Retorna user info + `effective_permissions: string[]` (permisos efectivos del usuario, incluyendo `["*"]` para superusuarios) | +| `/auth/2fa/setup/` | POST | IsAuthenticated | Inicia setup TOTP (retorna secret + QR URL) | +| `/auth/2fa/verify-setup/` | POST | IsAuthenticated | Verifica código TOTP durante setup | +| `/auth/2fa/verify/` | POST | None | Verifica código TOTP en login (requiere pre-auth token) | +| `/auth/2fa/disable/` | POST | IsAuthenticated | Deshabilita 2FA | +| `/auth/2fa/recovery/` | POST | None | Usa recovery code para bypass 2FA | +| `/auth/altcha/challenge/` | POST | None | Obtiene challenge PoW | +| `/auth/altcha/verify/` | POST | None | Verifica solución PoW | +| `/auth/api-keys/` | GET, POST | `security.manage_api_keys` | Lista/crea API keys | +| `/auth/api-keys//` | GET, DELETE | `security.manage_api_keys` | Detalle/elimina API key | +| `/auth/api-keys//rotate/` | POST | `security.manage_api_keys` | Rota API key (invalida anterior) | +| `/security/roles/` | GET, POST | `security.manage_roles` | CRUD roles | +| `/security/roles//` | GET, PUT, DELETE | `security.manage_roles` | Detalle/actualiza/elimina rol | +| `/security/grants/` | GET, POST | `security.manage_grants` | Lista/crea grants | +| `/security/grants//` | DELETE | `security.manage_grants` | Revoca grant | +| `/security/revokes/` | GET, POST | `security.manage_grants` | Lista/crea revokes | +| `/security/revokes//` | DELETE | `security.manage_grants` | Elimina revoke | +| `/security/users//permissions/` | GET | `security.view_permissions` | Permisos efectivos del usuario | +| `/security/users//roles/` | GET, PUT | `security.manage_roles` | Roles del usuario | + +#### Configuración Externalizable + +```python +INFRASYNTH_SECURITY = { + # JWT + "ACCESS_TOKEN_LIFETIME_MINUTES": 30, + "REFRESH_TOKEN_LIFETIME_DAYS": 7, + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "ACCESS_COOKIE_NAME": "access_token", + "REFRESH_COOKIE_NAME": "refresh_token", + "COOKIE_SECURE": True, + "COOKIE_HTTPONLY": True, + "COOKIE_SAMESITE": "Lax", + "PRE_AUTH_COOKIE_NAME": "pre_auth_token", + "CRYPTO_KEY": None, # Fernet key para encrypt/decrypt cookies + + # Login + "AUTH_BACKEND_CLASS": "infrasynth.security.auth.backends.EmailOrUsernameBackend", + "LOGIN_RATE_LIMIT": "10/m", + "IP_BLACKLIST_THRESHOLD": 100, + "IP_BLACKLIST_WINDOW_MINUTES": 15, + + # 2FA + "TWO_FACTOR_ISSUER_NAME": "InfraSynth", + "TWO_FACTOR_RECOVERY_CODES_COUNT": 8, + "TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1, + "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5, + + # ALTCHA + "ALTCHA_DIFFICULTY": 10000, + "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, + + # API Keys + "API_KEY_PREFIX_LENGTH": 8, + "API_KEY_HASH_ALGORITHM": "pbkdf2_sha256", + "API_KEY_DEFAULT_EXPIRY_DAYS": 365, + + # Password Policy + "PASSWORD_MIN_LENGTH": 8, + "PASSWORD_REQUIRE_UPPERCASE": True, + "PASSWORD_REQUIRE_DIGIT": True, + "PASSWORD_REQUIRE_SPECIAL_CHAR": True, +} +``` + +#### DRF Permission Class + +```python +# infrasynth/security/permissions.py +class HybridPermission(BasePermission): + """ + Clase de permiso DRF que usa el AuthorizationService. + Define required_permissions en la view. + """ + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + required = getattr(view, "required_permissions", []) + if not required: + return True + authz = AuthorizationService() + return authz.has_any_permission(request.user, required) + +def require_permission(*codenames: str): + """Decorador/clase para views DRF.""" + class PermissionRequired(HybridPermission): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + authz = AuthorizationService() + return authz.has_all_permissions(request.user, list(codenames)) + return PermissionRequired +``` + +#### Patrón de Integración para App B + +```python +# En settings.py de App B +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "infrasynth.security.auth.cookies.CookieJWTAuthentication", + "infrasynth.security.auth.api_keys.APIKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], +} + +# En views de App B +from infrasynth.security.permissions import require_permission +from infrasynth.security.services import AuthorizationService + +class TicketViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")] + + def get_queryset(self): + authz = AuthorizationService() + if authz.has_permission(self.request.user, "helpdesk.view_all_tickets"): + return Ticket.objects.all() + return Ticket.objects.filter(assigned_to=self.request.user) +``` + +#### Integración con Frontend (React) + +El sistema de autorización entrega al frontend un array plano de permisos que permite construir una experiencia React declarativa, donde ningún usuario ve elementos UI para acciones que no puede ejecutar. + +##### 1. Endpoint `/auth/check/` como fuente única de permisos + +La respuesta de `GET /auth/check/` incluye `effective_permissions: string[]`. El frontend lo consume inmediatamente después del login para inicializar el contexto de permisos. + +```typescript +// Ejemplo de respuesta de /auth/check/ +{ + "id": 42, + "email": "user@example.com", + "name": "Juan Pérez", + "effective_permissions": [ + "webhooks.view_webhookendpoint", + "webhooks.add_webhook", + "webhooks.change_webhook", + "notifications.manage_templates" + ] +} +``` + +Para superusuarios se retorna `["*"]`. El frontend debe interpretar `"*"` como acceso total en cualquier verificación. + +##### 2. PermissionContext + useHasPermission hook + +El frontend almacena el array en un contexto React global y expone un hook con tres modos de consulta: + +| Modo | Función | Ejemplo | +|------|---------|---------| +| Individual | `hasPermission("webhooks.delete_webhook")` | Un solo permiso | +| Cualquiera (any) | `hasAnyPermission(["a", "b"])` | Al menos uno | +| Todos (all) | `hasAllPermissions(["a", "b"])` | Todos requeridos | + +El hook debe manejar el wildcard `"*"`: si el array incluye `"*"`, cualquier permiso consultado retorna `true`. + +##### 3. Componente `` + +Componente declarativo que envuelve elementos UI y los muestra solo si el usuario cumple el permiso requerido. + +``` + + + + + + + +``` + +Soporta: +- `I`: string (permiso único) o `string[]` (múltiples permisos) +- `mode`: `"all"` (default para arrays) o `"any"` +- `fallback`: ReactNode opcional para renderizar cuando no hay acceso +- `children`: se renderiza solo si el permiso es concedido + +##### 4. Rutas protegidas (router guards) + +Cada módulo o sección protegida por un permiso base (ej. `webhooks.view_webhookendpoint` para el módulo de webhooks) implementa un wrapper de ruta que verifica el permiso antes de renderizar la página. + +- Si el usuario no tiene el permiso, redirige a una página 403 o renderiza un mensaje de "acceso denegado" +- Los enlaces de navegación al módulo se esconden condicionalmente con `` + +Este patrón evita que el usuario vea botones o pantallas para acciones que no puede realizar, eliminando frustrantes errores "Permission Denied" después del clic. + +--- + +### 2.4 `infrasynth.files` — Almacenamiento Cloud + +**Feature flag:** `files` (default: True) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class StoredFile(models.Model): + """ + Metadatos de archivo almacenado. El archivo físico se guarda en el storage + backend configurado (S3, Cloudinary, local, etc.) + """ + storage_backend = models.CharField(max_length=50, help_text="S3, cloudinary, gcs, local") + storage_key = models.CharField(max_length=500, help_text="Key/path en el storage backend") + original_filename = models.CharField(max_length=500) + mime_type = models.CharField(max_length=100) + size_bytes = models.BigIntegerField() + checksum_sha256 = models.CharField(max_length=64, blank=True) + is_public = models.BooleanField(default=False) + category = models.ForeignKey("FileCategory", on_delete=models.SET_NULL, null=True, blank=True) + metadata = models.JSONField(default=dict, help_text="Metadatos extra (width, height, duration, etc.)") + uploaded_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "files_stored_file" + + +class FileCategory(models.Model): + """ + Categoría de archivos con reglas de validación y ruta de almacenamiento. + """ + slug = models.SlugField(max_length=100, primary_key=True) + name = models.CharField(max_length=100) + description = models.TextField(blank=True) + storage_path = models.CharField(max_length=500, help_text="Prefijo de ruta en storage") + allowed_extensions = models.TextField(blank=True, help_text="CSV: pdf,doc,docx") + max_size_bytes = models.BigIntegerField(null=True, blank=True) + is_active = models.BooleanField(default=True) + storage_backend_override = models.CharField(max_length=50, blank=True, help_text="Si se especifica, usa otro backend") + + class Meta: + db_table = "files_category" + + +class ProcessingPipeline(models.Model): + """ + Pipeline de post-procesamiento (resize, optimize, watermark, virus scan). + Se ejecuta async via Celery después del upload. + """ + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=100, unique=True) + steps = models.JSONField(help_text='[{"type": "resize", "params": {"width": 800}}, {"type": "optimize"}]') + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "files_pipeline" + + +class PipelineExecution(models.Model): + """ + Log de ejecución de un pipeline sobre un archivo. + """ + file = models.ForeignKey(StoredFile, on_delete=models.CASCADE, related_name="pipeline_executions") + pipeline = models.ForeignKey(ProcessingPipeline, on_delete=models.SET_NULL, null=True) + status = models.CharField(max_length=20, choices=[("pending","pending"),("running","running"),("completed","completed"),("failed","failed")], default="pending") + started_at = models.DateTimeField(null=True) + completed_at = models.DateTimeField(null=True) + output_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, related_name="+") + error = models.TextField(blank=True) + + class Meta: + db_table = "files_pipeline_execution" +``` + +#### FileService API + +```python +# infrasynth/files/services.py +class FileService: + """ + API pública para gestión de archivos. Usada por App B y por otras apps base. + """ + + def upload(self, file_obj, *, filename: str, category_slug: str = None, + user=None, is_public: bool = False, metadata: dict = None, + pipeline_slug: str = None) -> StoredFile: + """Sube un archivo al storage configurado. Retorna el StoredFile.""" + ... + + def get_signed_url(self, file_or_id, *, expiry_seconds: int = 3600) -> str: + """Genera una URL firmada temporal para descarga directa del storage.""" + ... + + def get_download_response(self, file_or_id, request) -> HttpResponse: + """Retorna FileResponse o redirect a signed URL o X-Sendfile.""" + ... + + def delete(self, file_or_id, *, soft: bool = True) -> bool: + """Borra un archivo. soft=True solo marca como eliminado, soft=False borra del storage.""" + ... + + def get_file_info(self, file_or_id) -> dict: + """Metadatos completos del archivo.""" + ... +``` + +#### Señales + +```python +file_uploaded = Signal() # kwargs: file_id, storage_key, filename, size, uploaded_by +file_processed = Signal() # kwargs: file_id, pipeline_name, output_file_id, status +file_deleted = Signal() # kwargs: file_id, storage_key, deleted_by +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/files/upload/` | POST | IsAuthenticated | Subir archivo (multipart). Retorna file_id | +| `/files/` | GET | IsAuthenticated | Listar archivos (filtrable) | +| `/files//` | GET | IsAuthenticated | Metadatos del archivo | +| `/files//download/` | GET | IsAuthenticated | Descargar archivo (signed URL o proxy) | +| `/files//` | DELETE | IsAuthenticated | Borrado lógico | +| `/files/categories/` | GET, POST | `files.manage_categories` | CRUD categorías | +| `/files/categories//` | GET, PUT, DELETE | `files.manage_categories` | Detalle categoría | +| `/files/pipelines/` | GET, POST | `files.manage_pipelines` | CRUD pipelines | + +#### Configuración Externalizable + +```python +INFRASYNTH_FILES = { + "DEFAULT_STORAGE_BACKEND": "S3", # S3, cloudinary, gcs, local + "STORAGE_BACKENDS": { + "S3": { + "ACCESS_KEY": None, + "SECRET_KEY": None, + "BUCKET_NAME": None, + "REGION": "us-east-1", + "ENDPOINT_URL": None, # Para MinIO o compatibles S3 + }, + "cloudinary": { + "CLOUD_NAME": None, + "API_KEY": None, + "API_SECRET": None, + }, + "gcs": { + "PROJECT_ID": None, + "BUCKET_NAME": None, + "CREDENTIALS_PATH": None, + }, + "local": {}, + }, + "SIGNED_URL_EXPIRY_SECONDS": 3600, + "MAX_UPLOAD_SIZE_MB": 100, + "ENABLE_PROCESSING_PIPELINES": True, + "PROCESSING_BACKEND": "celery", # celery | sync + "ENABLE_X_SENDFILE": False, # Apache/Nginx X-Sendfile/X-Accel-Redirect +} +``` + +#### Patrón de Integración para App B + +```python +from infrasynth.files.services import FileService +from infrasynth.files.models import StoredFile + +# Subir archivo +fs = FileService() +stored = fs.upload( + request.FILES["attachment"], + filename="contrato_001.pdf", + category_slug="contracts", + user=request.user, +) + +# Vincular a modelo de negocio +class Contract(models.Model): + pdf_file = models.ForeignKey(StoredFile, on_delete=models.SET_NULL, null=True, blank=True, related_name="+") + # ... + +contract = Contract.objects.create(pdf_file=stored, ...) + +# Obtener URL de descarga +download_url = fs.get_signed_url(stored.id, expiry_seconds=300) +``` + +--- + +### 2.5 `infrasynth.notifications` — Dispatch Multi-Canal + +**Feature flag:** `notifications` (default: True) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class NotificationTemplate(models.Model): + """ + Plantilla de notificación con soporte multi-canal. + Usa Django Template Language con {{ variables }}. + """ + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + channel = models.CharField(max_length=20, choices=[(c.value, c.value) for c in ChannelType]) + subject_template = models.CharField(max_length=500, blank=True) + body_template = models.TextField() + is_html = models.BooleanField(default=True) + namespace = models.CharField(max_length=100, blank=True, help_text="Namespace de la app dueña") + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "notifications_template" + unique_together = [("slug", "namespace")] + + +class NotificationDispatch(models.Model): + """ + Registro de cada envío de notificación. Útil para debugging y auditoría. + """ + template = models.ForeignKey(NotificationTemplate, on_delete=models.SET_NULL, null=True) + recipient = models.CharField(max_length=500, help_text="Email, número de teléfono, chat ID") + channel = models.CharField(max_length=20) + subject = models.CharField(max_length=500, blank=True) + body = models.TextField() + status = models.CharField(max_length=20, choices=[("pending","pending"),("sent","sent"),("failed","failed"),("retrying","retrying")]) + error_message = models.TextField(blank=True) + attempt = models.PositiveSmallIntegerField(default=1) + next_retry_at = models.DateTimeField(null=True) + context_snapshot = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True) + request_id = models.CharField(max_length=64, blank=True) + + class Meta: + db_table = "notifications_dispatch" + + +class ChannelConfig(models.Model): + """ + Configuración de un canal de notificación. + Las credenciales se almacenan encriptadas (Fernet). + """ + slug = models.SlugField(max_length=50, primary_key=True) + channel_type = models.CharField(max_length=20) + display_name = models.CharField(max_length=200) + config = models.JSONField(default=dict, help_text="Credenciales encriptadas") + is_active = models.BooleanField(default=True) + priority = models.PositiveSmallIntegerField(default=0, help_text="Menor número = mayor prioridad para failover") + + class Meta: + db_table = "notifications_channel_config" +``` + +#### Channel ABC (Backend Interface) + +```python +# infrasynth/notifications/channels/base.py +from abc import ABC, abstractmethod +from dataclasses import dataclass +from infrasynth.shared.results import Result + +@dataclass +class Attachment: + filename: str + content: bytes + mime_type: str + cid: str | None = None + inline: bool = False + + +class BaseChannel(ABC): + """ + ABC que todo canal de notificación debe implementar. + App B puede crear sus propios canales implementando esta interfaz. + """ + channel_type: ChannelType + + @abstractmethod + def send(self, recipient: str, subject: str, body: str, + is_html: bool = True, attachments: list[Attachment] | None = None) -> Result[bool, str]: + """Envía la notificación. Retorna Result.ok(True) o Result.err("mensaje").""" + ... + + def health_check(self) -> bool: + """Verifica que el canal esté operativo.""" + return True + + def get_priority(self) -> int: + return 0 + + @classmethod + def from_config(cls, config: dict) -> "BaseChannel": + """Factory: construye el canal desde el dict de configuración.""" + ... +``` + +**Implementaciones built-in:** `SMTPChannel`, `SendGridChannel`, `SESChannel`, `TwilioSMSChannel`, `TelegramChannel`. + +#### NotificationService API + +```python +# infrasynth/notifications/services.py +class NotificationService: + """API pública para envío de notificaciones.""" + + def send(self, *, recipients: list[str], template_slug: str = None, + subject: str = "", body: str = "", channel: str = "email", + context: dict = None, attachments: list[dict] = None, + namespace: str = None, request=None) -> NotificationDispatch: + """Envía notificación síncrona o asíncrona según configuración.""" + ... + + def send_with_failover(self, *, recipients: list[str], template_slug: str = None, + subject: str = "", body: str = "", channel: str = "email", + context: dict = None, attachments: list[dict] = None, + namespace: str = None, request=None) -> NotificationDispatch: + """ + Envía con failover automático. + Si el canal primario falla, intenta el siguiente en la cadena de failover. + """ + ... + + def get_template(self, slug: str, namespace: str = None) -> NotificationTemplate | None: + """Recupera una plantilla por slug.""" + ... +``` + +#### VariableResolverRegistry (Extensibilidad) + +```python +# infrasynth/notifications/resolvers.py +from typing import Callable, Any +import logging + +logger = logging.getLogger(__name__) + +class VariableResolverRegistry: + """ + Registry global de resolvedores de variables para plantillas. + + APPS EXTERNAS se registran aquí en su apps.py:ready(). + InfraSynth nunca conoce los dominios de App B. + + Uso en App B: + ``` + class MyAppConfig(AppConfig): + def ready(self): + from infrasynth.notifications.resolvers import VariableResolverRegistry + + @VariableResolverRegistry.register("ticket_number", namespace="helpdesk") + def resolve_ticket(recipient, context, request): + return context["ticket"].id + ``` + """ + _resolvers: dict[str, list[dict]] = {} + + @classmethod + def register(cls, variable_name: str, label: str = None, + description: str = None, namespace: str = "__global__"): + """Decorador para registrar un resolvedor.""" + def decorator(func: Callable): + if namespace not in cls._resolvers: + cls._resolvers[namespace] = [] + cls._resolvers[namespace].append({ + "name": variable_name, + "label": label or variable_name, + "description": description or "", + "resolver": func, + }) + return func + return decorator + + @classmethod + def resolve(cls, variable_name: str, recipient: str, context: dict, + namespace: str = None, request=None) -> Any: + """Resuelve una variable. Busca en namespace + global.""" + search_namespaces = [namespace, "__global__"] if namespace else ["__global__"] + for ns in search_namespaces: + for entry in cls._resolvers.get(ns, []): + if entry["name"] == variable_name: + try: + return entry["resolver"](recipient, context, request) + except Exception: + logger.exception(f"Error resolving '{variable_name}'") + return None + return context.get(variable_name) + + @classmethod + def get_available_variables(cls, namespace: str = None) -> list[dict]: + """Retorna metadatos de variables para UI.""" + ... +``` + +#### Señales + +```python +notification_sent = Signal() # kwargs: dispatch_id, recipient, channel, status +notification_failed = Signal() # kwargs: dispatch_id, recipient, channel, error +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/notifications/templates/` | GET, POST | `notifications.manage_templates` | CRUD plantillas | +| `/notifications/templates//` | GET, PUT, DELETE | `notifications.manage_templates` | Detalle plantilla | +| `/notifications/dispatch/` | POST | IsAuthenticated | Enviar notificación | +| `/notifications/history/` | GET | IsAuthenticated | Historial de envíos | +| `/notifications/history//` | GET | IsAuthenticated | Detalle de envío | +| `/notifications/channels/` | GET | `notifications.manage_channels` | Canales y health status | + +#### Configuración Externalizable + +```python +INFRASYNTH_NOTIFICATIONS = { + "DEFAULT_FROM_EMAIL": "noreply@example.com", + "DEFAULT_FROM_SMS": "+1234567890", + + # Canales configurados con failover + "CHANNELS": { + "email": { + "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", + "fallback": "infrasynth.notifications.channels.email_sendgrid.SendGridChannel", + }, + "sms": { + "primary": "infrasynth.notifications.channels.sms_twilio.TwilioSMSChannel", + }, + }, + + "DISPATCH_BACKEND": "celery", # sync | celery | thread + "MAX_RETRIES": 3, + "RETRY_DELAY_SECONDS": [60, 300, 900], + "TEMPLATE_ENGINE": "django", # django | jinja2 + "RATE_LIMIT_PER_CHANNEL": { + "email": "50/m", + "sms": "10/m", + }, + "STORE_DISPATCH_LOGS": True, + "DISPATCH_LOG_RETENTION_DAYS": 90, +} +``` + +#### Patrón de Integración para App B + +```python +# App B: helpdesk/apps.py +class HelpdeskConfig(AppConfig): + name = "helpdesk" + + def ready(self): + from infrasynth.notifications.resolvers import VariableResolverRegistry + + @VariableResolverRegistry.register("ticket_number", namespace="helpdesk", + label="Número de ticket") + def resolve_ticket(recipient, context, request): + return context["ticket"].id + + @VariableResolverRegistry.register("agent_name", namespace="helpdesk", + label="Nombre del agente") + def resolve_agent(recipient, context, request): + return context["ticket"].assigned_to.get_full_name() + +# App B: helpdesk/services.py +from infrasynth.notifications.services import NotificationService + +ns = NotificationService() + +def notify_ticket_assigned(ticket): + ns.send( + recipients=[ticket.assigned_to.email], + template_slug="helpdesk.ticket_assigned", + namespace="helpdesk", + context={"ticket": ticket}, + channel="email", + ) +``` + +--- + +### 2.6 `infrasynth.webhooks` — Webhooks Inbound/Outbound + +**Feature flag:** `webhooks` (default: True) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class OutboundEndpoint(models.Model): + """Destino de webhooks salientes.""" + name = models.CharField(max_length=200) + url = models.URLField(max_length=1000) + secret = models.CharField(max_length=500, help_text="Clave HMAC para firmar requests") + is_active = models.BooleanField(default=True) + retry_policy = models.JSONField(default=dict, help_text='{"max_retries": 5, "backoff": "exponential"}') + headers = models.JSONField(default=dict, help_text="Headers adicionales") + timeout_seconds = models.PositiveIntegerField(default=10) + + class Meta: + db_table = "webhooks_outbound_endpoint" + + +class OutboundSubscription(models.Model): + """Vincula un evento con un endpoint outbound.""" + endpoint = models.ForeignKey(OutboundEndpoint, on_delete=models.CASCADE, related_name="subscriptions") + event_name = models.CharField(max_length=200, db_index=True) + is_active = models.BooleanField(default=True) + payload_template = models.TextField(blank=True, help_text="Template JSON opcional. Si está vacío, se usa el payload crudo.") + + class Meta: + db_table = "webhooks_outbound_subscription" + unique_together = [("endpoint", "event_name")] + + +class OutboundDelivery(models.Model): + """Log de cada intento de entrega outbound.""" + subscription = models.ForeignKey(OutboundSubscription, on_delete=models.CASCADE, related_name="deliveries") + payload = models.JSONField() + response_status = models.PositiveSmallIntegerField(null=True) + response_body = models.TextField(blank=True) + attempt = models.PositiveSmallIntegerField(default=1) + status = models.CharField(max_length=20, choices=[("success","success"),("failed","failed"),("retrying","retrying")]) + next_retry_at = models.DateTimeField(null=True) + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True) + + class Meta: + db_table = "webhooks_outbound_delivery" + + +class InboundEndpoint(models.Model): + """Receptor de webhooks entrantes (de Stripe, GitHub, etc.).""" + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=100, unique=True, help_text="Identificador en la URL: /webhooks/inbound/receive//") + source = models.CharField(max_length=50, choices=[("stripe","stripe"),("github","github"),("mercadopago","mercadopago"),("custom","custom")]) + secret = models.CharField(max_length=500, help_text="Clave para verificar firma entrante") + handler = models.CharField(max_length=500, help_text="Dotted path a handler class (implementa BaseInboundHandler)") + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "webhooks_inbound_endpoint" + + +class InboundEvent(models.Model): + """Evento recibido via webhook inbound.""" + endpoint = models.ForeignKey(InboundEndpoint, on_delete=models.CASCADE, related_name="events") + event_type = models.CharField(max_length=200) + raw_payload = models.JSONField() + is_verified = models.BooleanField(default=False) + is_processed = models.BooleanField(default=False) + error = models.TextField(blank=True) + received_at = models.DateTimeField(auto_now_add=True) + processed_at = models.DateTimeField(null=True) + + class Meta: + db_table = "webhooks_inbound_event" +``` + +#### EventRegistry (El Componente Clave de Extensibilidad) + +```python +# infrasynth/webhooks/registry.py +from dataclasses import dataclass, field +from django.db import transaction +import logging + +logger = logging.getLogger(__name__) + +@dataclass +class EventDefinition: + name: str + description: str = "" + example_payload: dict = field(default_factory=dict) + schema: dict | None = None + + +class EventRegistry: + """ + Registry global de eventos. + + LAS APPS EXTERNAS NUNCA MODIFICAN CÓDIGO DE WEBHOOKS. + Registran sus eventos aquí en su propio apps.py:ready(). + + Uso en App B: + ``` + class MyAppConfig(AppConfig): + def ready(self): + from infrasynth.webhooks.registry import EventRegistry + EventRegistry.register("helpdesk.ticket.created", + description="Se creó un nuevo ticket") + ``` + + En cualquier parte del código: + ``` + from infrasynth.webhooks.registry import EventRegistry + EventRegistry.emit("helpdesk.ticket.created", {"ticket_id": 123}) + ``` + """ + _events: dict[str, EventDefinition] = {} + + @classmethod + def register(cls, event_name: str, *, description: str = "", + example_payload: dict = None, schema: dict = None): + """ + Registra un evento que este sistema puede disparar. + Se llama en apps.py:ready() de cada app. + """ + cls._events[event_name] = EventDefinition( + name=event_name, + description=description, + example_payload=example_payload or {}, + schema=schema, + ) + logger.debug(f"Event registered: {event_name}") + + @classmethod + def emit(cls, event_name: str, payload: dict): + """ + Dispara un evento. Notifica a todas las suscripciones outbound activas. + Si el evento no está registrado, lo registra on-the-fly. + """ + if event_name not in cls._events: + cls._events[event_name] = EventDefinition(name=event_name) + + # Buscar suscripciones activas para este evento (o wildcard event_name="*") + from .models import OutboundSubscription + subscriptions = OutboundSubscription.objects.filter( + Q(event_name=event_name) | Q(event_name="*"), + is_active=True, + endpoint__is_active=True, + ).select_related("endpoint") + + if not subscriptions.exists(): + logger.debug(f"Event '{event_name}' emitted, no active subscriptions.") + return + + for sub in subscriptions: + # Disparar entrega asíncrona via Celery + from .dispatch import deliver_webhook + deliver_webhook.delay( + subscription_id=sub.id, + event_name=event_name, + payload=payload, + payload_template=sub.payload_template, + ) + + @classmethod + def get_registered_events(cls) -> dict[str, EventDefinition]: + """Retorna todos los eventos conocidos (para UI).""" + return dict(cls._events) +``` + +#### HMAC Signature + +```python +# infrasynth/webhooks/signature.py +import hmac +import hashlib +import time + +def sign_payload(secret: str, payload: str, timestamp: int = None) -> str: + """Genera header X-Webhook-Signature: t={timestamp},v1={hash}""" + ts = timestamp or int(time.time()) + signed = hmac.new( + secret.encode(), f"{ts}.{payload}".encode(), hashlib.sha256 + ).hexdigest() + return f"t={ts},v1={signed}" + +def verify_signature(secret: str, payload: str, signature_header: str, + tolerance_seconds: int = 300) -> bool: + """Verifica firma HMAC entrante con tolerancia de timestamp.""" + try: + parts = dict(p.split("=", 1) for p in signature_header.split(",")) + ts = int(parts["t"]) + sig = parts.get("v1", "") + if abs(time.time() - ts) > tolerance_seconds: + return False + expected = sign_payload(secret, payload, ts) + return hmac.compare_digest(sig, expected.split(",")[1].split("=")[1]) + except Exception: + return False +``` + +#### InboundHandler ABC + +```python +# infrasynth/webhooks/inbound/handlers.py +from abc import ABC, abstractmethod + +class BaseInboundHandler(ABC): + """ + App B puede implementar handlers para webhooks entrantes. + Se configura en InboundEndpoint.handler como dotted path. + """ + + @abstractmethod + def verify(self, payload: dict, headers: dict, secret: str) -> bool: + """Verifica la autenticidad del webhook entrante.""" + ... + + @abstractmethod + def process(self, event_type: str, payload: dict) -> dict: + """Procesa el evento. Retorna resultado.""" + ... +``` + +#### Señales + +```python +outbound_delivery_succeeded = Signal() # kwargs: delivery_id, event_name, status_code +outbound_delivery_failed = Signal() # kwargs: delivery_id, event_name, error +inbound_event_received = Signal() # kwargs: event_id, source, event_type +inbound_event_processed = Signal() # kwargs: event_id, result +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/webhooks/outbound/endpoints/` | GET, POST | `webhooks.manage_outbound` | CRUD endpoints outbound | +| `/webhooks/outbound/endpoints//` | GET, PUT, DELETE | `webhooks.manage_outbound` | Detalle endpoint | +| `/webhooks/outbound/subscriptions/` | GET, POST | `webhooks.manage_outbound` | CRUD suscripciones | +| `/webhooks/outbound/subscriptions//` | GET, PUT, DELETE | `webhooks.manage_outbound` | Detalle suscripción | +| `/webhooks/outbound/deliveries/` | GET | `webhooks.view_outbound` | Historial de entregas | +| `/webhooks/outbound/deliveries//retry/` | POST | `webhooks.manage_outbound` | Reintentar entrega | +| `/webhooks/inbound/endpoints/` | GET, POST | `webhooks.manage_inbound` | CRUD endpoints inbound | +| `/webhooks/inbound/endpoints//` | GET, PUT, DELETE | `webhooks.manage_inbound` | Detalle endpoint | +| `/webhooks/inbound/events/` | GET | `webhooks.view_inbound` | Historial de eventos recibidos | +| `/webhooks/inbound/receive//` | POST | None (**público**) | Recibir webhook externo | +| `/webhooks/events/` | GET | IsAuthenticated | Catálogo de eventos registrados | + +#### Configuración Externalizable + +```python +INFRASYNTH_WEBHOOKS = { + "DEFAULT_TIMEOUT_SECONDS": 10, + "MAX_RETRIES": 5, + "RETRY_BACKOFF": "exponential", # fixed | exponential + "RETRY_INITIAL_DELAY_SECONDS": 60, + "SIGNATURE_ALGORITHM": "sha256", + "SIGNATURE_HEADER": "X-Webhook-Signature", + "DELIVERY_BACKEND": "celery", # sync | celery + "INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300, + "MAX_PAYLOAD_SIZE_BYTES": 1048576, # 1MB +} +``` + +#### Patrón de Integración para App B + +```python +# App B: helpdesk/apps.py +class HelpdeskConfig(AppConfig): + name = "helpdesk" + + def ready(self): + from infrasynth.webhooks.registry import EventRegistry + + EventRegistry.register( + "helpdesk.ticket.created", + description="Nuevo ticket de soporte creado", + example_payload={"ticket_id": 123, "subject": "Error en login"}, + ) + EventRegistry.register("helpdesk.ticket.resolved") + EventRegistry.register("helpdesk.ticket.escalated") + EventRegistry.register("helpdesk.sla.breached", + description="SLA del ticket excedido") + +# App B: helpdesk/services.py +from infrasynth.webhooks.registry import EventRegistry + +class TicketService: + def create_ticket(self, data, user): + ticket = Ticket.objects.create(**data, created_by=user) + + # Disparar evento → suscripciones outbound se notifican automáticamente + EventRegistry.emit("helpdesk.ticket.created", { + "ticket_id": ticket.id, + "subject": ticket.subject, + "priority": ticket.priority, + "created_by": user.email, + "timestamp": ticket.created_at.isoformat(), + }) + return ticket +``` + +--- + +### 2.7 `infrasynth.workflows` — Máquina de Estados + +**Feature flag:** `workflows` (default: True) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class Workflow(models.Model): + """Definición maestra de un flujo de trabajo.""" + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + + class Meta: + db_table = "workflows_definition" + + +class WorkflowNode(models.Model): + """Nodo/estado dentro de un workflow.""" + NODE_START = "START" + NODE_INTERMEDIATE = "INTERMEDIATE" + NODE_END = "END" + + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="nodes") + name = models.CharField(max_length=200) + node_type = models.CharField(max_length=20, choices=[(NODE_START,"Start"),(NODE_INTERMEDIATE,"Intermediate"),(NODE_END,"End")]) + min_approvals = models.PositiveSmallIntegerField(default=1) + approval_strategy = models.CharField(max_length=20, choices=[("ANY","Any"),("ALL","All"),("MAJORITY","Majority")], default="ALL") + position_x = models.IntegerField(default=0) + position_y = models.IntegerField(default=0) + + class Meta: + db_table = "workflows_node" + unique_together = [("workflow", "name")] + + +class Transition(models.Model): + """Transición entre nodos.""" + from_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="outgoing_transitions") + to_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="incoming_transitions") + condition_slug = models.CharField(max_length=200, blank=True, help_text="Etiqueta de la decisión que activa esta transición (ej. 'approved', 'rejected')") + is_default = models.BooleanField(default=False, help_text="Si ninguna condición match, se usa esta transición") + + class Meta: + db_table = "workflows_transition" + unique_together = [("from_node", "condition_slug")] + + +class WorkflowInstance(models.Model): + """Instancia viva de un workflow.""" + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="instances") + current_node = models.ForeignKey(WorkflowNode, on_delete=models.SET_NULL, null=True) + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + status = models.CharField(max_length=20, choices=[("IN_PROGRESS","In Progress"),("COMPLETED","Completed"),("CANCELLED","Cancelled")], default="IN_PROGRESS") + started_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "workflows_instance" + + +class NodeAssignment(models.Model): + """ + Asignación de un usuario a un nodo en una instancia. + Registra la decisión y los datos capturados. + """ + instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="assignments") + node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE) + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + visit_number = models.PositiveIntegerField(default=1, help_text="Incrementa en re-entradas") + is_required = models.BooleanField(default=True) + has_processed = models.BooleanField(default=False) + decision = models.CharField(max_length=200, null=True, blank=True) + comments = models.TextField(blank=True) + submitted_data = models.JSONField(default=dict) + processed_at = models.DateTimeField(null=True) + + class Meta: + db_table = "workflows_node_assignment" + + +class WorkflowObserver(models.Model): + """Usuario con acceso solo-lectura a una instancia.""" + instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="observers") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE) + + class Meta: + db_table = "workflows_observer" + unique_together = [("instance", "user")] +``` + +#### WorkflowAwareModel (Mixin Abstracto) + +```python +class WorkflowAwareModel(models.Model): + """ + Mixin abstracto. Cualquier modelo de App B hereda de esto para participar + en workflows. No requiere importar nada más de workflows. + + Uso: + class Ticket(WorkflowAwareModel): + subject = models.CharField(max_length=255) + """ + workflow_instance = models.ForeignKey( + WorkflowInstance, on_delete=models.SET_NULL, null=True, blank=True, + related_name="+", + ) + + class Meta: + abstract = True +``` + +#### Engine (Core Logic) + +```python +# infrasynth/workflows/engine.py +class WorkflowEngine: + """ + Motor de workflows. Métodos puros, no dependen de DRF. + """ + + @transaction.atomic + def start(self, workflow_slug: str, owner, metadata: dict = None, + assignees: dict[str, list] = None) -> WorkflowInstance: + """Inicia una nueva instancia de workflow. Asigna responsables iniciales.""" + ... + + @transaction.atomic + def submit_decision(self, instance_id: int, user, decision: str, + comments: str = "", data: dict = None) -> WorkflowInstance: + """ + Procesa la decisión de un usuario en el nodo actual. + Evalúa si se alcanzaron las aprobaciones mínimas y avanza si corresponde. + """ + ... + + def get_node_states(self, instance: WorkflowInstance) -> dict[int, str]: + """Estado visual de cada nodo: ACTIVE, COMPLETED, PENDING, REJECTED.""" + ... + + def get_route(self, instance: WorkflowInstance) -> list[dict]: + """Ruta seguida por la instancia (nodos visitados + decisiones).""" + ... + + def get_role_in_instance(self, user, instance: WorkflowInstance) -> str: + """OWNER | ASSIGNEE | OBSERVER | NONE""" + ... + + def assign_users(self, instance: WorkflowInstance, node: WorkflowNode, + users: list, is_required: bool = True): + """Asigna usuarios como responsables de un nodo.""" + ... + + def add_observer(self, instance: WorkflowInstance, user): + """Añade observador solo-lectura.""" + ... +``` + +#### DataValidatorProtocol (Swappable) + +```python +# infrasynth/workflows/validators.py +from typing import Protocol, runtime_checkable + +@runtime_checkable +class DataValidatorProtocol(Protocol): + """ + Protocolo para validación de datos de negocio durante decisiones de workflow. + App B implementa esto para su dominio específico. + """ + + def validate(self, node: "WorkflowNode", data: dict, context: dict) -> dict: + """ + Valida y limpia datos enviados en una decisión. + Retorna datos limpios o lanza ValidationError. + context contiene: instance, user, previous_decisions. + """ + ... + + +class DataValidatorRegistry: + """Registry de validadores por workflow.""" + _validators: dict[str, DataValidatorProtocol] = {} + + @classmethod + def register(cls, workflow_slug: str, validator: DataValidatorProtocol): + cls._validators[workflow_slug] = validator + + @classmethod + def get(cls, workflow_slug: str) -> DataValidatorProtocol | None: + return cls._validators.get(workflow_slug) +``` + +#### Señales + +```python +instance_started = Signal() # kwargs: instance, workflow_slug, owner +node_reached = Signal() # kwargs: instance, node, visit_number +decision_submitted = Signal() # kwargs: instance, node, user, decision, data +instance_completed = Signal() # kwargs: instance, workflow_slug, final_node +instance_cancelled = Signal() # kwargs: instance, reason +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/workflows/definitions/` | GET, POST | `workflows.manage_definitions` | CRUD workflows | +| `/workflows/definitions//` | GET, PUT, DELETE | `workflows.manage_definitions` | Detalle workflow | +| `/workflows/definitions//nodes/` | GET, POST | `workflows.manage_definitions` | CRUD nodos | +| `/workflows/definitions//nodes//` | GET, PUT, DELETE | `workflows.manage_definitions` | Detalle nodo | +| `/workflows/definitions//transitions/` | GET, POST | `workflows.manage_definitions` | CRUD transiciones | +| `/workflows/instances/` | GET, POST | IsAuthenticated | Listar/crear instancias | +| `/workflows/instances//` | GET | IsAuthenticated | Detalle con ruta + estados | +| `/workflows/instances//submit/` | POST | IsAuthenticated | Procesar decisión | +| `/workflows/instances//assign/` | POST | IsAuthenticated | Asignar responsables | +| `/workflows/instances//observers/` | POST, DELETE | IsAuthenticated | Gestionar observadores | +| `/workflows/instances//route/` | GET | IsAuthenticated | Ruta seguida + viabilidad | + +#### Configuración Externalizable + +```python +INFRASYNTH_WORKFLOWS = { + "MAX_INSTANCES_PER_WORKFLOW": 10000, + "DEFAULT_APPROVAL_STRATEGY": "ALL", + "AUTO_CLONE_ASSIGNEES_ON_REENTRY": True, + "ALLOW_SELF_ASSIGNMENT": False, + "ROUTE_MAX_DEPTH": 50, # Prevenir loops infinitos +} +``` + +#### Patrón de Integración para App B + +```python +# App B: helpdesk/models.py +from infrasynth.workflows.models import WorkflowAwareModel + +class Ticket(WorkflowAwareModel): + subject = models.CharField(max_length=255) + description = models.TextField() + priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")]) + assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + +# App B: helpdesk/validators.py +from infrasynth.workflows.validators import DataValidatorProtocol +from rest_framework.exceptions import ValidationError + +class TicketApprovalValidator: + """Valida datos de negocio cuando un aprobador decide sobre un ticket.""" + def validate(self, node, data, context): + instance = context["instance"] + ticket = Ticket.objects.get(workflow_instance=instance) + required_fields = {"resolution_note": str} + for field, field_type in required_fields.items(): + if field not in data: + raise ValidationError({field: "Este campo es requerido."}) + return data + +# App B: helpdesk/apps.py +class HelpdeskConfig(AppConfig): + name = "helpdesk" + def ready(self): + from infrasynth.workflows.validators import DataValidatorRegistry + DataValidatorRegistry.register("ticket_approval", TicketApprovalValidator()) +``` + +--- + +### 2.8 `infrasynth.scheduler` — Gestión de Jobs + +**Feature flag:** `scheduler` (default: True) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class ScheduledTask(models.Model): + """Tarea programada o bajo demanda.""" + name = models.CharField(max_length=200, unique=True) + task_path = models.CharField(max_length=500, help_text="Dotted path: helpdesk.tasks.cleanup_old_tickets") + schedule_type = models.CharField(max_length=20, choices=[("CRON","Cron"),("INTERVAL","Interval"),("DATE","Date"),("MANUAL","Manual")]) + schedule_config = models.JSONField(default=dict, help_text='{"cron": "0 2 * * *"} o {"interval": 3600}') + args = models.JSONField(default=list) + kwargs = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + queue = models.CharField(max_length=100, default="default") + priority = models.PositiveSmallIntegerField(default=5) + + class Meta: + db_table = "scheduler_task" + + +class TaskExecution(models.Model): + """Registro de ejecución de una tarea.""" + task = models.ForeignKey(ScheduledTask, on_delete=models.CASCADE, related_name="executions") + celery_task_id = models.CharField(max_length=255, blank=True) + status = models.CharField(max_length=20, choices=[("PENDING","Pending"),("RUNNING","Running"),("SUCCESS","Success"),("FAILURE","Failure")]) + started_at = models.DateTimeField(null=True) + completed_at = models.DateTimeField(null=True) + result = models.TextField(blank=True) + error_traceback = models.TextField(blank=True) + worker_hostname = models.CharField(max_length=255, blank=True) + + class Meta: + db_table = "scheduler_execution" + ordering = ["-started_at"] +``` + +#### Señales + +```python +task_scheduled = Signal() # kwargs: task_name, eta +task_started = Signal() # kwargs: task_name, task_id, worker +task_completed = Signal() # kwargs: task_name, task_id, result, duration_seconds +task_failed = Signal() # kwargs: task_name, task_id, error, traceback +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/scheduler/tasks/` | GET, POST | `scheduler.manage_tasks` | CRUD tareas | +| `/scheduler/tasks//` | GET, PUT, DELETE | `scheduler.manage_tasks` | Detalle tarea | +| `/scheduler/tasks//run/` | POST | `scheduler.manage_tasks` | Ejecución manual inmediata | +| `/scheduler/tasks//toggle/` | POST | `scheduler.manage_tasks` | Activar/desactivar | +| `/scheduler/executions/` | GET | `scheduler.view_executions` | Historial de ejecuciones | +| `/scheduler/executions//` | GET | `scheduler.view_executions` | Detalle ejecución | +| `/scheduler/queue-status/` | GET | `scheduler.view_status` | Estado de colas Celery | +| `/scheduler/workers/` | GET | `scheduler.view_status` | Workers activos y stats | + +#### Configuración Externalizable + +```python +INFRASYNTH_SCHEDULER = { + "BACKEND": "celery", # celery | django_q | apscheduler + "CELERY_BROKER_URL": "redis://localhost:6379/0", + "CELERY_RESULT_BACKEND": "redis://localhost:6379/1", + "CELERY_TASK_SOFT_TIME_LIMIT": 300, + "CELERY_TASK_TIME_LIMIT": 600, + "CELERY_WORKER_PREFETCH_MULTIPLIER": 1, + "DEFAULT_QUEUE": "default", + "MAX_EXECUTION_HISTORY_PER_TASK": 1000, + "AUTO_DISCOVER_TASKS": True, +} +``` + +--- + +### 2.9 `infrasynth.features` — Feature Flags + +**Feature flag:** `features` (default: **True — NUNCA se deshabilita**, es el orquestador) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +**Esta app es especial:** todas las demás apps (y App B) dependen conceptualmente de features para habilitarse/deshabilitarse. Features siempre está activa. + +#### Modelos + +```python +class FeatureFlag(models.Model): + """ + Feature flag con soporte multi-tenant. + tenant_id=NULL significa "global". + """ + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=False) + rollout_percentage = models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = todos los usuarios") + tenant_id = models.UUIDField(null=True, blank=True, help_text="Null = global. Valor = específico del tenant") + environments = models.JSONField(default=list, help_text='["production", "staging"] o [] = todos') + category = models.CharField(max_length=50, blank=True, help_text="Agrupación para UI") + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "features_flag" + unique_together = [("slug", "tenant_id")] + + +class FeatureFlagOverride(models.Model): + """ + Override puntual para un usuario o grupo específico. + Prevalece sobre la configuración global. + """ + flag = models.ForeignKey(FeatureFlag, on_delete=models.CASCADE, related_name="overrides") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, null=True, blank=True, related_name="+") + group = models.ForeignKey("auth.Group", on_delete=models.CASCADE, null=True, blank=True, related_name="+") + is_enabled = models.BooleanField() + + class Meta: + db_table = "features_override" + unique_together = [("flag", "user"), ("flag", "group")] +``` + +#### FeatureRegistry + +```python +# infrasynth/features/registry.py +@dataclass +class FeatureDefinition: + slug: str + name: str = "" + description: str = "" + default: bool = True + category: str = None + + +class FeatureRegistry: + """ + Registry donde CADA APP registra sus feature flags en apps.py:ready(). + Features NO conoce qué apps existen. + """ + _features: dict[str, FeatureDefinition] = {} + + @classmethod + def register(cls, slug: str, *, name: str = "", description: str = "", + default: bool = True, category: str = None): + cls._features[slug] = FeatureDefinition( + slug=slug, name=name or slug, description=description, + default=default, category=category, + ) + + @classmethod + def get_all(cls) -> dict[str, FeatureDefinition]: + return dict(cls._features) +``` + +Cada app registra sus flags en `apps.py:ready()`: + +```python +# infrasynth/webhooks/apps.py +class WebhooksConfig(AppConfig): + name = "infrasynth.webhooks" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + FeatureRegistry.register("webhooks", name="Webhooks", + description="Sistema de webhooks inbound/outbound", + default=True, category="integration") + FeatureRegistry.register("webhooks_outbound", + name="Webhooks Salientes", default=True) + FeatureRegistry.register("webhooks_inbound", + name="Webhooks Entrantes", default=True) + +# infrasynth/billing/apps.py +class BillingConfig(AppConfig): + name = "infrasynth.billing" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + FeatureRegistry.register("billing", name="Facturación y Pagos", + description="Módulo de suscripciones y facturación", + default=False, category="operations") +``` + +#### FeatureService + +```python +# infrasynth/features/services.py +from django.core.cache import cache + +class FeatureService: + """ + Servicio de evaluación de feature flags. + Usa cache para minimizar queries. + """ + + def is_enabled(self, slug: str, *, user=None, tenant_id: str = None, + default: bool = None, ttl_seconds: int = 60) -> bool: + """Evalúa si un feature flag está activo.""" + + # 1. Override por usuario (BD) + if user and user.is_authenticated: + override = self._get_user_override(slug, user) + if override is not None: + return override + + # 2. Override por grupo + if user and user.is_authenticated: + override = self._get_group_override(slug, user) + if override is not None: + return override + + # 3. Configuración del tenant + if tenant_id: + flag = self._get_flag(slug, tenant_id, ttl_seconds) + else: + flag = self._get_flag_global(slug, ttl_seconds) + + if flag: + return flag.is_active + + # 4. Default del registry + registry_default = FeatureRegistry.get_all().get(slug) + if registry_default: + return registry_default.default + + # 5. Default del caller + return default if default is not None else False + + def get_active_flags(self, *, user=None, tenant_id: str = None) -> dict[str, bool]: + """ + Retorna el estado de TODOS los flags conocidos para el usuario/tenant actual. + Este es el endpoint que el frontend consume. + """ + all_slugs = set(FeatureRegistry.get_all().keys()) + db_flags = set(FeatureFlag.objects.filter( + Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True) + ).values_list("slug", flat=True)) + all_slugs.update(db_flags) + + return { + slug: self.is_enabled(slug, user=user, tenant_id=tenant_id) + for slug in sorted(all_slugs) + } +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/features/` | GET, POST | `features.manage_flags` | CRUD feature flags | +| `/features//` | GET, PUT, DELETE | `features.manage_flags` | Detalle flag | +| `/features//overrides/` | GET, POST | `features.manage_flags` | CRUD overrides por usuario/grupo | +| `/features//overrides//` | DELETE | `features.manage_flags` | Eliminar override | +| `/features/active/` | GET | IsAuthenticated | **Endpoint central.** Retorna TODOS los flags activos + permisos + roles para el usuario/tenant actual. El frontend lo consume al montar. | +| `/features/check//` | GET | IsAuthenticated | Verificar un flag específico | + +#### Señales + +```python +flag_created = Signal() # kwargs: slug, created_by +flag_toggled = Signal() # kwargs: slug, new_state, toggled_by +flag_deleted = Signal() # kwargs: slug, deleted_by +override_created = Signal() # kwargs: flag_slug, user, group, is_enabled +override_deleted = Signal() # kwargs: flag_slug, user, group +``` + +#### Configuración Externalizable + +```python +INFRASYNTH_FEATURES = { + "CACHE_BACKEND": "default", + "CACHE_TTL_SECONDS": 60, + "CACHE_KEY_PREFIX": "features", + "ROLLOUT_HASH_ALGORITHM": "md5", # Para hashing determinístico de user_id en rollout_percentage + "AUTO_REGISTER_FROM_SETTINGS": True, # Descubrir flags de otras apps en sus settings + "EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True, + "EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True, +} +``` + +#### Flujo de Habilitación/Deshabilitación de Apps + +``` +1. Django arranca → carga INSTALLED_APPS (TODAS las apps base + apps de App B) +2. Cada apps.py:ready() registra sus feature flags en FeatureRegistry +3. Frontend llama GET /api/features/active/ + → Response: + { + "flags": { + "billing": false, + "workflows": true, + "webhooks": true, + "webhooks_outbound": true, + "webhooks_inbound": false, + "notifications": true, + "notifications_sms": false, + "helpdesk.ticket_priority": true, + "helpdesk.sla_tracking": false + }, + "permissions": ["helpdesk.view_tickets", "helpdesk.create_ticket"], + "roles": ["agent"] + } +4. Frontend renderiza condicionalmente: + - Menú "Facturación" → flags.billing ? mostrar : ocultar + - Menú "Flujos" → flags.workflows ? mostrar : ocultar + - Botón "Activar 2FA" → flags.two_factor ? mostrar : ocultar +5. Backend protege endpoints: + - GET /api/billing/plans/ → billing view chequea FeatureService().is_enabled("billing") + → false: retorna 404 +``` + +--- + +### 2.10 `infrasynth.billing` — Pagos y Suscripciones + +**Feature flag:** `billing` (default: **False** — requiere activación explícita) +**Dependencias:** `infrasynth.shared`, `infrasynth.audit` + +#### Modelos + +```python +class PaymentGateway(models.Model): + """Configuración de una pasarela de pago.""" + slug = models.SlugField(max_length=50, primary_key=True) + display_name = models.CharField(max_length=200) + gateway_class = models.CharField(max_length=500, help_text="Dotted path a la clase gateway") + config = models.JSONField(default=dict, help_text="Credenciales encriptadas") + is_active = models.BooleanField(default=False) + supported_currencies = models.JSONField(default=list) + webhook_secret = models.CharField(max_length=500, blank=True) + + class Meta: + db_table = "billing_gateway" + + +class BillingPlan(models.Model): + """Plan de suscripción/pago.""" + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + price_amount = models.DecimalField(max_digits=12, decimal_places=2) # En centavos/subunidad + price_currency = models.CharField(max_length=3, default="USD") + interval = models.CharField(max_length=20, choices=[("monthly","Monthly"),("yearly","Yearly")], default="monthly") + trial_days = models.PositiveIntegerField(default=0) + features = models.JSONField(default=list, help_text='["10,000 emails/mes", "Soporte prioritario"]') + is_active = models.BooleanField(default=True) + gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) + external_id = models.CharField(max_length=200, blank=True, help_text="ID del plan en la pasarela (ej. Stripe price ID)") + + class Meta: + db_table = "billing_plan" + + +class Subscription(models.Model): + """Suscripción activa de un usuario.""" + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="subscriptions") + plan = models.ForeignKey(BillingPlan, on_delete=models.SET_NULL, null=True) + gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) + external_id = models.CharField(max_length=200, blank=True) + status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in SubscriptionStatus]) + current_period_start = models.DateTimeField() + current_period_end = models.DateTimeField() + cancel_at_period_end = models.BooleanField(default=False) + cancelled_at = models.DateTimeField(null=True) + trial_end = models.DateTimeField(null=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_subscription" + + +class Invoice(models.Model): + """Factura generada.""" + subscription = models.ForeignKey(Subscription, on_delete=models.SET_NULL, null=True, related_name="invoices") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="invoices") + gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) + external_id = models.CharField(max_length=200, blank=True) + invoice_number = models.CharField(max_length=50, unique=True) + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, default="USD") + tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + tax_name = models.CharField(max_length=50, blank=True, default="") + status = models.CharField(max_length=20, choices=[(s.value, s.value) for s in InvoiceStatus], default="draft") + due_date = models.DateTimeField(null=True) + paid_at = models.DateTimeField(null=True) + line_items = models.JSONField(default=list, help_text='[{"description": "...", "amount": ..., "quantity": 1}]') + pdf_file = models.ForeignKey("infrasynth_files.StoredFile", on_delete=models.SET_NULL, null=True, related_name="+") + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_invoice" + + +class PaymentTransaction(models.Model): + """Transacción de pago individual.""" + invoice = models.ForeignKey(Invoice, on_delete=models.SET_NULL, null=True, related_name="transactions") + gateway = models.ForeignKey(PaymentGateway, on_delete=models.SET_NULL, null=True) + external_id = models.CharField(max_length=200, blank=True) + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, default="USD") + status = models.CharField(max_length=30) + payment_method = models.CharField(max_length=100, blank=True) + metadata = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "billing_transaction" +``` + +#### BasePaymentGateway ABC + +```python +# infrasynth/billing/gateways/base.py +from abc import ABC, abstractmethod +from dataclasses import dataclass + +@dataclass +class CheckoutSessionResult: + session_id: str + checkout_url: str | None = None + client_secret: str | None = None + +@dataclass +class WebhookResult: + event_type: str + is_handled: bool + data: dict + + +class BasePaymentGateway(ABC): + """ABC que toda pasarela de pago implementa.""" + + gateway_slug: str + + @abstractmethod + def create_checkout_session(self, plan, user, success_url: str, + cancel_url: str) -> CheckoutSessionResult: + """Crea una sesión de checkout en la pasarela.""" + ... + + @abstractmethod + def handle_webhook(self, payload: dict, headers: dict) -> WebhookResult: + """Procesa un webhook entrante de la pasarela.""" + ... + + @abstractmethod + def cancel_subscription(self, external_id: str) -> bool: + """Cancela una suscripción en la pasarela.""" + ... + + @abstractmethod + def sync_subscription(self, external_id: str) -> dict: + """Sincroniza estado de suscripción desde la pasarela.""" + ... + + @abstractmethod + def get_invoice(self, external_id: str) -> dict: + """Recupera factura desde la pasarela.""" + ... + + @abstractmethod + def health_check(self) -> bool: + """Verifica conectividad con la pasarela.""" + ... +``` + +**Implementaciones built-in:** `StripeGateway`, `MercadoPagoGateway`, `WompiGateway`. + +#### Señales + +```python +subscription_created = Signal() # kwargs: user, plan_slug, gateway, external_id +subscription_cancelled = Signal() # kwargs: user, plan_slug, reason +subscription_renewed = Signal() # kwargs: user, plan_slug, new_period_end +payment_succeeded = Signal() # kwargs: user, invoice_id, amount, gateway +payment_failed = Signal() # kwargs: user, invoice_id, amount, error +invoice_generated = Signal() # kwargs: user, invoice_id, amount +invoice_paid = Signal() # kwargs: user, invoice_id, amount +``` + +#### API Endpoints + +| Endpoint | Método | Permiso | Descripción | +|---|---|---|---| +| `/billing/gateways/` | GET | IsAuthenticated | Pasarelas activas | +| `/billing/plans/` | GET | None | Planes disponibles | +| `/billing/plans//` | GET | None | Detalle plan | +| `/billing/subscriptions/` | GET | IsAuthenticated | Suscripciones del usuario | +| `/billing/subscriptions//` | GET | IsAuthenticated | Detalle suscripción | +| `/billing/subscriptions//cancel/` | POST | IsAuthenticated | Cancelar suscripción | +| `/billing/subscribe//` | POST | IsAuthenticated | Crear checkout (retorna redirect URL) | +| `/billing/invoices/` | GET | IsAuthenticated | Facturas del usuario | +| `/billing/invoices//` | GET | IsAuthenticated | Detalle factura | +| `/billing/invoices//download/` | GET | IsAuthenticated | Descargar PDF | +| `/billing/webhook//` | POST | None (**público**) | Webhook de pasarela | + +#### Configuración Externalizable + +```python +INFRASYNTH_BILLING = { + "INVOICE_NUMBER_PREFIX": "INV-", + "INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html", + "GRACE_PERIOD_DAYS": 5, + "MAX_RETRY_FAILED_PAYMENTS": 3, + "DEFAULT_CURRENCY": "USD", + "TAX_PERCENTAGE": 0, + "TAX_NAME": "", + "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3, + "WEBHOOK_TOLERANCE_SECONDS": 300, + "SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24, +} +``` + +--- + +## 3. Patrones de Acoplamiento + +### 3.1 Regla de Oro + +**Ninguna app de infraestructura importa directamente de otra app de infraestructura.** Solo se permite: +- Importar de `infrasynth.shared` (protocolos, tipos, enums) +- Importar de Django stdlib (`models`, `settings`, `signals`) +- Usar signals, registries, y settings para comunicación cross-app + +### 3.2 Mecanismos de Comunicación entre Apps + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ MECANISMO │ USO PRINCIPAL │ +├─────────────────────────────────────────────────────────────────┤ +│ Settings (dict) │ Configurar backends concretos │ +│ Signals │ Eventos asíncronos entre apps │ +│ Registries │ Apps se auto-registran (eventos, │ +│ │ resolvedores, validadores, flags) │ +│ ABCs/Protocols │ Contratos swappables (canales, │ +│ │ gateways, handlers, validadores) │ +│ ForeignKey │ SET_NULL siempre, related_name="+" │ +│ AUTH_USER_MODEL │ Nunca User directo │ +│ FeatureService │ Control de habilitación cross-cutting │ +└─────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Ejemplo Concreto Cross-App sin Acoplamiento + +**Escenario:** Cuando se paga una factura en `billing`, se debe enviar un email de recibo via `notifications`. + +**Mal (acoplado):** +```python +# billing NO debe hacer esto: +from infrasynth.notifications.services import NotificationService +NotificationService().send(...) +``` + +**Bien (desacoplado via signals):** +```python +# billing emite señal +from infrasynth.billing.signals import payment_succeeded +payment_succeeded.send(sender=PaymentGateway, user=user, invoice_id=inv.id, ...) + +# App B (o el proyecto consumidor) conecta billing con notifications +# en un archivo de receivers propio +from infrasynth.billing.signals import payment_succeeded as billing_payment_ok +from infrasynth.notifications.services import NotificationService + +@receiver(billing_payment_ok) +def send_payment_receipt(sender, user, invoice_id, amount, gateway, **kwargs): + NotificationService().send( + recipients=[user.email], + template_slug="billing.payment_receipt", + context={"invoice_id": invoice_id, "amount": amount}, + ) +``` + +**El proyecto consumidor es el encargado de conectar las apps entre sí** cuando se necesita comunicación directa. Las apps base solo emiten señales y exponen registries. + +### 3.4 Grafo de Dependencias + +``` +infrasynth.shared + ↑ + ├── infrasynth.audit + │ ↑ + │ ├── infrasynth.security + │ ├── infrasynth.files + │ ├── infrasynth.notifications + │ ├── infrasynth.webhooks + │ ├── infrasynth.workflows + │ ├── infrasynth.scheduler + │ ├── infrasynth.features + │ └── infrasynth.billing + │ + │ (Todas las apps de infraestructura dependen solo de shared + audit) + │ + └── infrasynth.features ← es el orquestador transversal + ↑ + (Todas las apps registran sus flags aquí, pero NO importan features) +``` + +Las apps no importan `infrasynth.features` directamente. El `FeatureService` se usa via `import_string` o se inyecta en las views. + +--- + +## 4. Estrategia de Versionado + +### 4.1 Versión Única + +Todo el ecosistema comparte una sola versión en `pyproject.toml`: + +```toml +[project] +name = "infrasynth-base" +version = "1.0.0" +``` + +Esto simplifica la instalación y garantiza compatibilidad entre apps. + +### 4.2 SemVer + +| Bump | Disparador | +|------|-----------| +| **MAJOR** | Cambio de API pública: modelo, endpoint, señal, setting contract | +| **MINOR** | Nueva funcionalidad backward-compatible: nuevo endpoint, nuevo campo nullable, nuevo flag | +| **PATCH** | Bug fix, optimización, seguridad | + +### 4.3 Migraciones + +Cada app Django incluye sus propias migraciones. Para evitar colisiones entre apps, el proyecto consumidor configura: + +```python +MIGRATION_MODULES = { + "infrasynth_audit": "infrasynth.audit.migrations", + "infrasynth_security": "infrasynth.security.migrations", + # ... +} +``` + +### 4.4 Garantías de Compatibilidad + +- **Modelos:** Solo se añaden campos (nunca se remueven). Campos deprecados se marcan con `help_text="[DEPRECATED]"`. +- **Endpoints:** Solo se añaden. Endpoints deprecados retornan header `Deprecation: true`. +- **Señales:** Solo se añaden kwargs, nunca se remueven. +- **Settings:** Solo se añaden keys con defaults. Keys renombradas tienen fallback automático. + +--- + +## 5. Stack Técnico + +### 5.1 Dependencias Core (pyproject.toml) + +```toml +[project] +name = "infrasynth-base" +version = "1.0.0" +requires-python = ">=3.12" + +dependencies = [ + "django>=5.2,<6.0", + "djangorestframework>=3.16,<4.0", + "django-cors-headers>=4.7", + "djangorestframework-simplejwt>=5.5", + "django-filter>=25.1", + "psycopg2-binary>=2.9", + "python-dotenv>=1.0", + "cryptography>=44.0", + "pydantic>=2.0", + "pyotp>=2.10", + "qrcode[pil]>=8.1", + "celery[redis]>=5.4", + "django-celery-results>=2.5", + "django-celery-beat>=2.7", + "boto3>=1.35", + "django-storages>=1.14", + "Pillow>=11.0", + "twilio>=9.0", + "stripe>=10.0", + "mercadopago>=3.0", + "requests>=2.32", + "flower>=2.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-django>=4.8", + "pytest-cov>=5.0", + "factory-boy>=3.3", + "faker>=28.0", + "ruff>=0.6", + "mypy>=1.11", + "pre-commit>=3.8", +] +``` + +### 5.2 Docker Compose de Desarrollo + +```yaml +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: infrasynth + POSTGRES_USER: infrasynth + POSTGRES_PASSWORD: infrasynth + ports: ["5432:5432"] + volumes: [pgdata:/var/lib/postgresql/data] + + redis: + image: redis:7-alpine + ports: ["6379:6379"] + + worker: + build: . + command: celery -A config worker -l info -Q default,webhooks,notifications,billing + depends_on: [redis, db] + volumes: [".:/app"] + + beat: + build: . + command: celery -A config beat -l info + depends_on: [redis, db] + volumes: [".:/app"] + + flower: + image: mher/flower + ports: ["5555:5555"] + environment: + CELERY_BROKER_URL: redis://redis:6379/0 + depends_on: [redis] + +volumes: + pgdata: +``` + +### 5.3 Recomendaciones Production + +- **Web Server:** Gunicorn (sync, `workers = 2*CPU + 1`, `threads = 4`) +- **Reverse Proxy:** Nginx (static files, rate limiting, SSL termination) +- **DB:** PostgreSQL 16 + PgBouncer (connection pooling) +- **Cache:** Redis (caching + sessions + Celery broker) +- **Monitoring:** Sentry (errors) + Prometheus + Grafana (metrics) +- **Logging:** structlog → JSON stdout → Loki + Grafana +- **CI/CD:** GitHub Actions (tests + lint + docker build) + +--- + +## 6. Configuración de Settings para App B + +Ejemplo completo de `settings.py` del proyecto consumidor: + +```python +import os +import dotenv +from pathlib import Path + +dotenv.load_dotenv() +BASE_DIR = Path(__file__).resolve().parent.parent + +SECRET_KEY = os.getenv("SECRET_KEY") +DEBUG = os.getenv("DEBUG", "False") == "True" +ALLOWED_HOSTS = os.getenv("ALLOWED_HOSTS", "").split(",") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + + # InfraSynth Base (TODAS las apps, features controla visibilidad) + "infrasynth.audit", + "infrasynth.security", + "infrasynth.files", + "infrasynth.notifications", + "infrasynth.webhooks", + "infrasynth.workflows", + "infrasynth.scheduler", + "infrasynth.features", + "infrasynth.billing", + + # App B — Apps de dominio + "helpdesk", + "knowledge_base", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", + "infrasynth.security.two_factor.middleware.TwoFactorMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "infrasynth.audit.middleware.AuditAPIMiddleware", +] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "infrasynth.security.auth.cookies.CookieJWTAuthentication", + "infrasynth.security.auth.api_keys.APIKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "infrasynth.shared.pagination.StandardPagination", + "PAGE_SIZE": 25, + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +} + +# =================================================================== +# InfraSynth Configuration +# =================================================================== + +INFRASYNTH_SECURITY = { + "ACCESS_TOKEN_LIFETIME_MINUTES": 30, + "REFRESH_TOKEN_LIFETIME_DAYS": 7, + "COOKIE_SECURE": not DEBUG, + "CRYPTO_KEY": os.getenv("CRYPTO_KEY"), + "TWO_FACTOR_ISSUER_NAME": "HelpDesk Pro", +} + +INFRASYNTH_FILES = { + "DEFAULT_STORAGE_BACKEND": "S3", + "STORAGE_BACKENDS": { + "S3": { + "ACCESS_KEY": os.getenv("AWS_ACCESS_KEY_ID"), + "SECRET_KEY": os.getenv("AWS_SECRET_ACCESS_KEY"), + "BUCKET_NAME": os.getenv("AWS_S3_BUCKET"), + "REGION": os.getenv("AWS_REGION", "us-east-1"), + }, + }, + "MAX_UPLOAD_SIZE_MB": 50, +} + +INFRASYNTH_NOTIFICATIONS = { + "DEFAULT_FROM_EMAIL": "helpdesk@example.com", + "CHANNELS": { + "email": { + "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", + }, + }, +} + +INFRASYNTH_BILLING = { + "DEFAULT_CURRENCY": "COP", + "TAX_PERCENTAGE": 19, + "TAX_NAME": "IVA", +} + +INFRASYNTH_SCHEDULER = { + "CELERY_BROKER_URL": os.getenv("CELERY_BROKER_URL", "redis://localhost:6379/0"), +} + +# =================================================================== +# Database (PostgreSQL) +# =================================================================== +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": os.getenv("DB_NAME"), + "USER": os.getenv("DB_USER"), + "PASSWORD": os.getenv("DB_PASSWORD"), + "HOST": os.getenv("DB_HOST", "localhost"), + "PORT": os.getenv("DB_PORT", "5432"), + }, +} + +# =================================================================== +# Django standard +# =================================================================== +LANGUAGE_CODE = "es" +TIME_ZONE = "America/Bogota" +USE_TZ = True +STATIC_URL = "static/" +MEDIA_URL = "media/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" +``` + +--- + +## 7. Ejemplo Completo: App B (HelpDesk) Integrando InfraSynth + +```python +# ============================================================ +# helpdesk/apps.py +# ============================================================ +class HelpdeskConfig(AppConfig): + name = "helpdesk" + + def ready(self): + # Registrar feature flags de dominio + from infrasynth.features.registry import FeatureRegistry + FeatureRegistry.register("helpdesk.sla_tracking", + name="SLA Tracking", default=True, category="helpdesk") + FeatureRegistry.register("helpdesk.auto_assign", + name="Auto-assign Tickets", default=False) + + # Registrar eventos para webhooks + from infrasynth.webhooks.registry import EventRegistry + EventRegistry.register("helpdesk.ticket.created") + EventRegistry.register("helpdesk.ticket.resolved") + EventRegistry.register("helpdesk.sla.breached") + + # Registrar resolvedores de variables para notificaciones + from infrasynth.notifications.resolvers import VariableResolverRegistry + @VariableResolverRegistry.register("ticket_number", namespace="helpdesk") + def resolve_ticket_number(recipient, context, request): + return f"TK-{context['ticket'].id:06d}" + + @VariableResolverRegistry.register("agent_name", namespace="helpdesk") + def resolve_agent_name(recipient, context, request): + agent = context.get("ticket").assigned_to + return agent.get_full_name() if agent else "Sin asignar" + +# ============================================================ +# helpdesk/models.py +# ============================================================ +from infrasynth.workflows.models import WorkflowAwareModel +from infrasynth.files.models import StoredFile + +class Ticket(WorkflowAwareModel): + subject = models.CharField(max_length=255) + description = models.TextField() + priority = models.CharField(max_length=20, choices=[("low","Low"),("medium","Medium"),("high","High")]) + status = models.CharField(max_length=20, choices=[("open","Open"),("in_progress","In Progress"),("resolved","Resolved"),("closed","Closed")]) + assigned_to = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True, related_name="assigned_tickets") + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="created_tickets") + attachments = models.ManyToManyField(StoredFile, blank=True, related_name="+") + resolution = models.TextField(blank=True) + +# ============================================================ +# helpdesk/views.py +# ============================================================ +from infrasynth.security.permissions import require_permission +from infrasynth.features.services import FeatureService + +class TicketViewSet(ModelViewSet): + permission_classes = [IsAuthenticated, require_permission("helpdesk.manage_tickets")] + + def get_queryset(self): + qs = Ticket.objects.select_related("assigned_to", "created_by") + authz = AuthorizationService() + if not authz.has_permission(self.request.user, "helpdesk.view_all_tickets"): + qs = qs.filter(Q(assigned_to=self.request.user) | Q(created_by=self.request.user)) + return qs + + def perform_create(self, serializer): + ticket = serializer.save(created_by=self.request.user) + + # Disparar evento → webhooks outbound reaccionan + from infrasynth.webhooks.registry import EventRegistry + EventRegistry.emit("helpdesk.ticket.created", { + "ticket_id": ticket.id, + "subject": ticket.subject, + "priority": ticket.priority, + }) + + # Auto-assign si el feature flag está activo + if FeatureService().is_enabled("helpdesk.auto_assign"): + assign_ticket_to_best_agent(ticket) +``` + +--- + +## 8. Resumen de Entregables del Proyecto + +| Entregable | Contenido | +|---|---| +| `pyproject.toml` | Meta-paquete con todas las dependencias | +| `infrasynth/shared/` | Protocolos, enums, crypto, Result monad | +| `infrasynth/audit/` | Django app: auditoría pasiva sin herencia | +| `infrasynth/security/` | Django app: auth JWT cookies + API keys, roles/grants híbridos, 2FA, ALTCHA | +| `infrasynth/files/` | Django app: storage cloud (S3, Cloudinary, GCS), signed URLs, pipelines | +| `infrasynth/notifications/` | Django app: dispatch multi-canal con failover, templates, resolvers | +| `infrasynth/webhooks/` | Django app: inbound/outbound con HMAC, EventRegistry | +| `infrasynth/workflows/` | Django app: máquina de estados con votación, WorkflowAwareModel mixin | +| `infrasynth/scheduler/` | Django app: dashboard y API de jobs Celery | +| `infrasynth/features/` | Django app: feature flags con tenant/user overrides, endpoint central `/api/features/active/` | +| `infrasynth/billing/` | Django app: suscripciones, facturas, Stripe/MercadoPago/Wompi | +| `tests/` | Test suite completa con pytest + factory_boy | +| `AGENTS.md` | Guía completa para agentes de IA | diff --git a/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf b/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf new file mode 100644 index 0000000..2ba3d15 --- /dev/null +++ b/b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 +>> +stream +Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[<87ef345d02f27d9ba4d6b0fa590299a4><87ef345d02f27d9ba4d6b0fa590299a4>] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1657 +%%EOF diff --git a/config/__init__.py b/config/__init__.py new file mode 100644 index 0000000..370372a --- /dev/null +++ b/config/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ["celery_app"] diff --git a/config/__pycache__/__init__.cpython-314.pyc b/config/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62be409795ff1db36de10c292c0f78b1f3a1406f GIT binary patch literal 240 zcmdPqb7DckEw1F$oYbPqcp%$PljRm0gj>W6R8qtOByO?C$0z3G#K*5>_zdJS z+;Y^<$j?pH&&@ANEKAJR4=BpdN=+^)*3ZmKD@rV`%qz*zO-d|I)lW)H&Q8rs(NE6L zOUq2xkB`sH%PfhH*DI*J#bJ}1pHiBWYF7mE3&=Ia{6OLZGb1D8T?W+$+~OU2P3%P+ GKrsNb#XaNz literal 0 HcmV?d00001 diff --git a/config/__pycache__/celery.cpython-314.pyc b/config/__pycache__/celery.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5de0edced9d661ff871d239b973efcbd59f312ed GIT binary patch literal 585 zcmYLF!EVz)5FOjtO(I%QN=-Rch*N}WbBKh5s#ZWv36#`wiv)*aZR}0zB;Jj7H<8j) zIl-sEAMhEes3=-4R8P!KU4WUBSn~3;PR6K zNAL~;tpy^>dP{q*3w;Il!MBR`GW*pM7bq}-YfU|8N~hn2z-($c7v{d{&ljC#4y<4< zs5~rHrhlWnEl~3ctf>9&L8JOB{uwT_ zXUGlrfTq|@DMJ%9aD7HI1$PtDWr$CSI9TlDHZ)E90o4n*^Aq$BwfD7CSUQHKneEITcqj1e%)0*Z%~#7lGQNXx3idJB Wv)ij*mSNpdO^bI;5}wn(zQz1e;PrOj{3Fo*T){npa0Gi!ad! zn?cqcI$|k&ZLqLl5nmP+EauDNf+c)eQm~XSOAD6qWw!*o?aQ_VyW`953YNXe%5fUG z*e-%n?iYXTlH`U^dx-T?z>Q#C(X6)W!V#jqX;|p;G=@f4Mi%YK(;cjL2r?%+?UhYc zu?VJ44o3(B0;uIZek_!mrWp3NQleO#FXt9 zSKS0VvW=rE(aq1`nqlCc$~|MK%0q*o_h=wDh|yOSYRdoPpR|yv$}OY}RJ|>;%nt6# zd|r7paii6t!QVlRq}eq7rI=sX;VLLe(s%IS2YB>53_`Kl&ZnoBm3igzY<}jz;x`zb z?asUQ{)w~w+<_Hd-=B}|!ZRm#?7$PB+K%ll#`fVWr|{Z=b#5pu4(*3k=iaFUYpk|o n`NgqatU0?Z;iaE&vtzcu$k_)KC;!5M)vIhG6Zn`Q{HO32p(-@Q literal 0 HcmV?d00001 diff --git a/config/celery.py b/config/celery.py new file mode 100644 index 0000000..93e1f15 --- /dev/null +++ b/config/celery.py @@ -0,0 +1,9 @@ +import os + +from celery import Celery + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + +app = Celery("config") +app.config_from_object("django.conf:settings", namespace="CELERY") +app.autodiscover_tasks() diff --git a/config/settings/__init__.py b/config/settings/__init__.py new file mode 100644 index 0000000..2a680a3 --- /dev/null +++ b/config/settings/__init__.py @@ -0,0 +1,8 @@ +""" +Settings package. + +Import the appropriate environment module: + from .dev import * # development + from .test import * # testing + from .base import * # common base +""" diff --git a/config/settings/__pycache__/__init__.cpython-314.pyc b/config/settings/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..283ada40f62e886580375502ba076e46ec828251 GIT binary patch literal 369 zcmY+Au};G<5QgJYQDg+qU@23Jh%L|%Coqu=R&v<^N;!osw858oBt+SA=NuDiduOIXoLQtmh%*wKp z4HT5#Yl|+h&;pazIExjjib} GWBChh_H14N literal 0 HcmV?d00001 diff --git a/config/settings/__pycache__/base.cpython-314.pyc b/config/settings/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8fe534828c6d9609e078691bb988a8ac1c8d28e4 GIT binary patch literal 8717 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 F{y$B9Z|VR5 literal 0 HcmV?d00001 diff --git a/config/settings/__pycache__/dev.cpython-314.pyc b/config/settings/__pycache__/dev.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c13c9df1f3cd01c0ecafad0c972fca05e8048a85 GIT binary patch literal 580 zcmZXQKX2MV7{7A0IWX!>h~%f0N+Egspwz0 zZ!wsGcO2wO;54!iV1#fmdcu(ijFBkhZ)0#P^LX9&K~)_6c@ys8*RU55pl7+~8Pba6mv>_Rupb1LR_{j#Js4K;rS)oCWkjfm~l zJAU!h9KEc(&3gte)}}Y*Rx?|xzSnx=O17q%#X8Eqra~Szs;1t&R2y1)i^f`3fGA&3 zb5}(wD&?|XR}0P1$Djf;fT|f@rHO)KDmlea4Vo-z=TI>kntAbOg_7C$2=2oOsVaBz z5XnRATX$ApuKKu#Y!b--z9@x;a?7&&$a7>Hdu_KP2Z#{Y>jW~(>|`{d@mE6@&j7vg zQ*3IOY*1mx&nU-n-#~H|-UDtI9I+<4#1T@uT#x83}q ze)+ocUa~bOe=`11ZW;$CwJWtZtQ?%EdcKYi-qN^)8a2IYl$6FXO-R*hsi+v5BAp_I zu4sA{DMnE>VH#&FQglCq#$>3&9MlwzrfN{r>Qy8g1*jDiq|q%&DJ!U9 z=uj&euv~`?%}`)oL3H~rvv3(TRIR~0)R|IPf>ME<>7sg!pw>|J!rzLDdV{9!IN_;W z)W3n>{&3J^vEvM|$o|^=VqSOl{Ae5X%Q_~G$7ENBq=b=C< zJP(8t{3JF{f=!v5rsXL}-9E@n!PYFZGX>krob_osHxKZBcreaS!t+(|Oyi~?eO>GKK4h-$BORWCi>Iq0sE9 literal 0 HcmV?d00001 diff --git a/config/settings/base.py b/config/settings/base.py new file mode 100644 index 0000000..6d00f2a --- /dev/null +++ b/config/settings/base.py @@ -0,0 +1,289 @@ +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent.parent + +SECRET_KEY = "change-me-in-production" +DEBUG = False +ALLOWED_HOSTS = [] + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "django_filters", + "corsheaders", + "django_celery_results", + "django_celery_beat", + "infrasynth.audit", + "infrasynth.security", + "infrasynth.files", + "infrasynth.notifications", + "infrasynth.webhooks", + "infrasynth.workflows", + "infrasynth.scheduler", + "infrasynth.features", + "infrasynth.billing", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "corsheaders.middleware.CorsMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "infrasynth.security.auth.middleware.JWTAuthenticationMiddleware", + "infrasynth.security.two_factor.middleware.TwoFactorMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", + "infrasynth.audit.middleware.AuditAPIMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.debug", + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +LANGUAGE_CODE = "es" +TIME_ZONE = "America/Bogota" +USE_TZ = True +STATIC_URL = "static/" +MEDIA_URL = "media/" +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +MIGRATION_MODULES = { + "infrasynth_audit": "infrasynth.audit.migrations", + "infrasynth_security": "infrasynth.security.migrations", + "infrasynth_files": "infrasynth.files.migrations", + "infrasynth_notifications": "infrasynth.notifications.migrations", + "infrasynth_webhooks": "infrasynth.webhooks.migrations", + "infrasynth_workflows": "infrasynth.workflows.migrations", + "infrasynth_scheduler": "infrasynth.scheduler.migrations", + "infrasynth_features": "infrasynth.features.migrations", + "infrasynth_billing": "infrasynth.billing.migrations", +} + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": "infrasynth", + "USER": "infrasynth", + "PASSWORD": "infrasynth", + "HOST": "localhost", + "PORT": "5432", + }, +} + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "infrasynth.security.auth.cookies.CookieJWTAuthentication", + "infrasynth.security.auth.api_keys.APIKeyAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": [ + "rest_framework.permissions.IsAuthenticated", + ], + "DEFAULT_PAGINATION_CLASS": "rest_framework.pagination.PageNumberPagination", + "PAGE_SIZE": 25, + "DEFAULT_FILTER_BACKENDS": ["django_filters.rest_framework.DjangoFilterBackend"], +} + +CELERY_BROKER_URL = "redis://localhost:6379/0" +CELERY_RESULT_BACKEND = "redis://localhost:6379/1" +CELERY_RESULT_EXTENDED = True +CELERY_TASK_SOFT_TIME_LIMIT = 300 +CELERY_TASK_TIME_LIMIT = 600 +CELERY_WORKER_PREFETCH_MULTIPLIER = 1 +CELERY_ACCEPT_CONTENT = ["json"] +CELERY_TASK_SERIALIZER = "json" +CELERY_RESULT_SERIALIZER = "json" + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.redis.RedisCache", + "LOCATION": "redis://localhost:6379/1", + }, +} + +INFRASYNTH_AUDIT = { + "EXCLUDED_MODELS": [ + "sessions.Session", + "admin.LogEntry", + "contenttypes.ContentType", + "migrations.Migration", + "infrasynth_audit.ModelChangeLog", + "infrasynth_audit.APIInteractionLog", + "infrasynth_audit.SecurityEvent", + "infrasynth_features.FeatureFlag", + "infrasynth_features.FeatureFlagOverride", + ], + "EXCLUDED_FIELDS": ["password", "token", "secret", "credit_card"], + "SENSITIVE_KEYS": ["password", "token", "secret", "authorization", "api_key"], + "MAX_BODY_SIZE_BYTES": 5000, + "STORE_IN_DB": True, + "RETENTION_DAYS": 365, + "ENABLE_API_LOGGING": True, + "ENABLE_MODEL_CHANGE_TRACKING": True, + "ENABLE_SECURITY_EVENTS": True, +} + +INFRASYNTH_SECURITY = { + "ACCESS_TOKEN_LIFETIME_MINUTES": 30, + "REFRESH_TOKEN_LIFETIME_DAYS": 7, + "ROTATE_REFRESH_TOKENS": True, + "BLACKLIST_AFTER_ROTATION": True, + "ACCESS_COOKIE_NAME": "access_token", + "REFRESH_COOKIE_NAME": "refresh_token", + "COOKIE_SECURE": True, + "COOKIE_HTTPONLY": True, + "COOKIE_SAMESITE": "Lax", + "PRE_AUTH_COOKIE_NAME": "pre_auth_token", + "CRYPTO_KEY": None, + "AUTH_BACKEND_CLASS": "infrasynth.security.auth.backends.EmailOrUsernameBackend", + "LOGIN_RATE_LIMIT": "10/m", + "IP_BLACKLIST_THRESHOLD": 100, + "IP_BLACKLIST_WINDOW_MINUTES": 15, + "TWO_FACTOR_ISSUER_NAME": "InfraSynth", + "TWO_FACTOR_RECOVERY_CODES_COUNT": 8, + "TWO_FACTOR_TOTP_VALIDITY_WINDOW": 1, + "PRE_AUTH_TOKEN_LIFETIME_MINUTES": 5, + "ALTCHA_DIFFICULTY": 10000, + "ALTCHA_CHALLENGE_EXPIRY_SECONDS": 300, + "API_KEY_PREFIX_LENGTH": 8, + "API_KEY_HASH_ALGORITHM": "pbkdf2_sha256", + "API_KEY_DEFAULT_EXPIRY_DAYS": 365, + "PASSWORD_MIN_LENGTH": 8, + "PASSWORD_REQUIRE_UPPERCASE": True, + "PASSWORD_REQUIRE_DIGIT": True, + "PASSWORD_REQUIRE_SPECIAL_CHAR": True, +} + +AUTHENTICATION_BACKENDS = [ + INFRASYNTH_SECURITY["AUTH_BACKEND_CLASS"], +] + +INFRASYNTH_FILES = { + "DEFAULT_STORAGE_BACKEND": "local", + "STORAGE_BACKENDS": { + "S3": { + "ACCESS_KEY": None, + "SECRET_KEY": None, + "BUCKET_NAME": None, + "REGION": "us-east-1", + "ENDPOINT_URL": None, + }, + "cloudinary": { + "CLOUD_NAME": None, + "API_KEY": None, + "API_SECRET": None, + }, + "gcs": { + "PROJECT_ID": None, + "BUCKET_NAME": None, + "CREDENTIALS_PATH": None, + }, + "local": {}, + }, + "SIGNED_URL_EXPIRY_SECONDS": 3600, + "MAX_UPLOAD_SIZE_MB": 100, + "ENABLE_PROCESSING_PIPELINES": True, + "PROCESSING_BACKEND": "celery", + "ENABLE_X_SENDFILE": False, +} + +INFRASYNTH_NOTIFICATIONS = { + "DEFAULT_FROM_EMAIL": "noreply@example.com", + "DEFAULT_FROM_SMS": "+1234567890", + "CHANNELS": { + "email": { + "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", + "fallback": "infrasynth.notifications.channels.email_sendgrid.SendGridChannel", + }, + "sms": { + "primary": "infrasynth.notifications.channels.sms_twilio.TwilioSMSChannel", + }, + }, + "DISPATCH_BACKEND": "celery", + "MAX_RETRIES": 3, + "RETRY_DELAY_SECONDS": [60, 300, 900], + "TEMPLATE_ENGINE": "django", + "RATE_LIMIT_PER_CHANNEL": { + "email": "50/m", + "sms": "10/m", + }, + "STORE_DISPATCH_LOGS": True, + "DISPATCH_LOG_RETENTION_DAYS": 90, +} + +INFRASYNTH_WEBHOOKS = { + "DEFAULT_TIMEOUT_SECONDS": 10, + "MAX_RETRIES": 5, + "RETRY_BACKOFF": "exponential", + "RETRY_INITIAL_DELAY_SECONDS": 60, + "SIGNATURE_ALGORITHM": "sha256", + "SIGNATURE_HEADER": "X-Webhook-Signature", + "DELIVERY_BACKEND": "celery", + "INBOUND_SIGNATURE_TOLERANCE_SECONDS": 300, + "MAX_PAYLOAD_SIZE_BYTES": 1048576, +} + +INFRASYNTH_WORKFLOWS = { + "MAX_INSTANCES_PER_WORKFLOW": 10000, + "DEFAULT_APPROVAL_STRATEGY": "ALL", + "AUTO_CLONE_ASSIGNEES_ON_REENTRY": True, + "ALLOW_SELF_ASSIGNMENT": False, + "ROUTE_MAX_DEPTH": 50, +} + +INFRASYNTH_SCHEDULER = { + "BACKEND": "celery", + "CELERY_BROKER_URL": "redis://localhost:6379/0", + "CELERY_RESULT_BACKEND": "redis://localhost:6379/1", + "CELERY_TASK_SOFT_TIME_LIMIT": 300, + "CELERY_TASK_TIME_LIMIT": 600, + "CELERY_WORKER_PREFETCH_MULTIPLIER": 1, + "DEFAULT_QUEUE": "default", + "MAX_EXECUTION_HISTORY_PER_TASK": 1000, + "AUTO_DISCOVER_TASKS": True, +} + +INFRASYNTH_FEATURES = { + "CACHE_BACKEND": "default", + "CACHE_TTL_SECONDS": 60, + "CACHE_KEY_PREFIX": "features", + "ROLLOUT_HASH_ALGORITHM": "md5", + "AUTO_REGISTER_FROM_SETTINGS": True, + "EXPOSE_PERMISSIONS_IN_ACTIVE_ENDPOINT": True, + "EXPOSE_ROLES_IN_ACTIVE_ENDPOINT": True, +} + +INFRASYNTH_BILLING = { + "INVOICE_NUMBER_PREFIX": "INV-", + "INVOICE_PDF_TEMPLATE": "billing/invoice_pdf.html", + "GRACE_PERIOD_DAYS": 5, + "MAX_RETRY_FAILED_PAYMENTS": 3, + "DEFAULT_CURRENCY": "USD", + "TAX_PERCENTAGE": 0, + "TAX_NAME": "", + "INVOICE_GENERATION_DAYS_BEFORE_RENEWAL": 3, + "WEBHOOK_TOLERANCE_SECONDS": 300, + "SYNC_SUBSCRIPTIONS_EVERY_HOURS": 24, +} diff --git a/config/settings/dev.py b/config/settings/dev.py new file mode 100644 index 0000000..98ef7b1 --- /dev/null +++ b/config/settings/dev.py @@ -0,0 +1,12 @@ +from .base import * # noqa: F403 + +DEBUG = True +ALLOWED_HOSTS = ["*"] + +SECRET_KEY = "dev-secret-key-do-not-use-in-production" + +DATABASES["default"]["PASSWORD"] = "infrasynth" +DATABASES["default"]["HOST"] = "localhost" + +INFRASYNTH_SECURITY["COOKIE_SECURE"] = False +INFRASYNTH_SECURITY["CRYPTO_KEY"] = "sBptcnWgrG5Tp8MJCnSoGQzZLb_4QPwNjuM4QNTGWe4=" diff --git a/config/settings/test.py b/config/settings/test.py new file mode 100644 index 0000000..ec2ea0d --- /dev/null +++ b/config/settings/test.py @@ -0,0 +1,26 @@ +from .base import * # noqa: F403 + +SECRET_KEY = "test-secret-key" + +DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": ":memory:", + }, +} + +PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"] + +CELERY_TASK_ALWAYS_EAGER = True +CELERY_TASK_EAGER_PROPAGATES = True + +CACHES = { + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + }, +} + +INFRASYNTH_SECURITY["CRYPTO_KEY"] = "sBptcnWgrG5Tp8MJCnSoGQzZLb_4QPwNjuM4QNTGWe4=" +INFRASYNTH_SECURITY["COOKIE_SECURE"] = False + +INFRASYNTH_AUDIT["STORE_IN_DB"] = True diff --git a/config/urls.py b/config/urls.py new file mode 100644 index 0000000..12912a7 --- /dev/null +++ b/config/urls.py @@ -0,0 +1,15 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/auth/", include("infrasynth.security.urls")), + path("api/audit/", include("infrasynth.audit.urls")), + path("api/files/", include("infrasynth.files.urls")), + path("api/notifications/", include("infrasynth.notifications.urls")), + path("api/webhooks/", include("infrasynth.webhooks.urls")), + path("api/workflows/", include("infrasynth.workflows.urls")), + path("api/scheduler/", include("infrasynth.scheduler.urls")), + path("api/features/", include("infrasynth.features.urls")), + path("api/billing/", include("infrasynth.billing.urls")), +] diff --git a/config/wsgi.py b/config/wsgi.py new file mode 100644 index 0000000..35afd7a --- /dev/null +++ b/config/wsgi.py @@ -0,0 +1,6 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") +application = get_wsgi_application() diff --git a/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf b/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf new file mode 100644 index 0000000..1f1fe70 --- /dev/null +++ b/d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf @@ -0,0 +1,74 @@ +%PDF-1.4 +%“Œ‹ž ReportLab Generated PDF document (opensource) +1 0 obj +<< +/F1 2 0 R /F2 3 0 R +>> +endobj +2 0 obj +<< +/BaseFont /Helvetica /Encoding /WinAnsiEncoding /Name /F1 /Subtype /Type1 /Type /Font +>> +endobj +3 0 obj +<< +/BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding /Name /F2 /Subtype /Type1 /Type /Font +>> +endobj +4 0 obj +<< +/Contents 8 0 R /MediaBox [ 0 0 612 792 ] /Parent 7 0 R /Resources << +/Font 1 0 R /ProcSet [ /PDF /Text /ImageB /ImageC /ImageI ] +>> /Rotate 0 /Trans << + +>> + /Type /Page +>> +endobj +5 0 obj +<< +/PageMode /UseNone /Pages 7 0 R /Type /Catalog +>> +endobj +6 0 obj +<< +/Author (\(anonymous\)) /CreationDate (D:20260731142506-05'00') /Creator (\(unspecified\)) /Keywords () /ModDate (D:20260731142506-05'00') /Producer (ReportLab PDF Library - \(opensource\)) + /Subject (\(unspecified\)) /Title (\(anonymous\)) /Trapped /False +>> +endobj +7 0 obj +<< +/Count 1 /Kids [ 4 0 R ] /Type /Pages +>> +endobj +8 0 obj +<< +/Filter [ /ASCII85Decode /FlateDecode ] /Length 646 +>> +stream +Gasam_/>bs&A@O6bc.>!+J-CdS6i3B:"?8'RP)^](WncNd/kc?Wr;jU%;]F=h$+7'NM#CIH+$!MgsCK@'&Z+akLTu1O?NZe5XP;^5,O7q$ga!J5`?K&YlFrBKb+g71?(Js,#0C?Nse^9]A/pQjLbV_fXsU1.q'8c9g"uL^q7n_G2$PG+ant7%/e3u$b5:V&YW[aM3ou"Js(gl!_4XiTl@,Z"Rj>BGX!XSDKOhu=0mO*nG25'-UGlkp-CWU-,$,GmS/r_ARVUI5$[!I9mdN^`QZJ+d`UI.9%aq:-pHdgkk<&_l!:Ho&p'4QfWc*%iIP.6fsKLnBm(9%aF!M;/"bE?aQrC2B;Cai$8k$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@endstream +endobj +xref +0 9 +0000000000 65535 f +0000000061 00000 n +0000000102 00000 n +0000000209 00000 n +0000000321 00000 n +0000000514 00000 n +0000000582 00000 n +0000000862 00000 n +0000000921 00000 n +trailer +<< +/ID +[] +% ReportLab generated PDF document -- digest (opensource) + +/Info 6 0 R +/Root 5 0 R +/Size 9 +>> +startxref +1657 +%%EOF diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8542b3d --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + db: + image: postgres:16-alpine + environment: + POSTGRES_DB: infrasynth + POSTGRES_USER: infrasynth + POSTGRES_PASSWORD: infrasynth + ports: + - "5432:5432" + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U infrasynth"] + interval: 5s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + + web: + build: . + command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 120 + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + volumes: + - ".:/app" + + worker: + build: . + command: celery -A config worker -l info -Q default,webhooks,notifications,billing + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + volumes: + - ".:/app" + + beat: + build: . + command: celery -A config beat -l info + depends_on: + db: + condition: service_healthy + redis: + condition: service_healthy + env_file: + - .env + volumes: + - ".:/app" + + flower: + image: mher/flower + ports: + - "5555:5555" + environment: + CELERY_BROKER_URL: redis://redis:6379/0 + depends_on: [redis] + +volumes: + pgdata: diff --git a/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png b/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png b/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt b/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png b/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt b/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/513a00e428e848e8a77d007243a0f88e/test.txt b/docs/513a00e428e848e8a77d007243a0f88e/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/513a00e428e848e8a77d007243a0f88e/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/569c1d2348dd42348e16221e92268ded/img.png b/docs/569c1d2348dd42348e16221e92268ded/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/5a05d7c2edca4d509722b05f57114874/test.txt b/docs/5a05d7c2edca4d509722b05f57114874/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/5a05d7c2edca4d509722b05f57114874/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png b/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png b/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png b/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/98fd1b7317fc46668f945560910d0d77/img.png b/docs/98fd1b7317fc46668f945560910d0d77/img.png new file mode 100644 index 0000000000000000000000000000000000000000..40be78bdfbeb4107e1feead553a6c9334222655d GIT binary patch literal 1061 zcmeAS@N?(olHy`uVBq!ia0y~yV4MKNIvh+uk)*3dQyCbT-*~z>hE&XXd&QBLK|$bv z!GZnH4>Q?sS`xb9`Zt}1w$IiK52hwEO1814NQg~HjBsch<&1{GXgU~@c_F4?19N!R Tl!R-*?9bro>gTe~DWM4fESWA^ literal 0 HcmV?d00001 diff --git a/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt b/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt b/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/cfcb245ce6114826bd0063bef7b58075/test.txt b/docs/cfcb245ce6114826bd0063bef7b58075/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/cfcb245ce6114826bd0063bef7b58075/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt b/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/docs/f454df5e834c4db2a9824a6387d7115d/test.txt b/docs/f454df5e834c4db2a9824a6387d7115d/test.txt new file mode 100644 index 0000000..3213863 --- /dev/null +++ b/docs/f454df5e834c4db2a9824a6387d7115d/test.txt @@ -0,0 +1 @@ +hello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf datahello world pdf data diff --git a/infrasynth/__init__.py b/infrasynth/__init__.py new file mode 100644 index 0000000..2252e67 --- /dev/null +++ b/infrasynth/__init__.py @@ -0,0 +1,3 @@ +""" +InfraSynth Base — reusable Django infrastructure kit. +""" diff --git a/infrasynth/__pycache__/__init__.cpython-314.pyc b/infrasynth/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..beab4b093ffef7e47b1cef6bc42653836ef291aa GIT binary patch literal 245 zcmdPqG=woAT`A$MWx9lrA4U<*_kDJTz;C2x7g$3 zQ}UDJ<5x0#23c~;RX-y?H&s73zbLURFNC_gJTxujShq90_3ZW7QA{iMX??9{vz zBys)t_{_Y_lK6PNg34PQHo5sJr8%i~MQlJ*LGCXGx%~q(BO~KyCMHIfA{HPQ0F;?T A;Q#;t literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__init__.py b/infrasynth/audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/audit/__pycache__/__init__.cpython-314.pyc b/infrasynth/audit/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e4aa24d9533293d2106953e63e7326d5669d4215 GIT binary patch literal 174 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9%Wnvz+fA0MBY omst`YuUAlci^C>2KczG$)vkyYXg0|9Vi4mKGb1Bo5i^hl09V*6%m4rY literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/apps.cpython-314.pyc b/infrasynth/audit/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fcd9ef57bb0ffb8023585ac8fbcc7796be27bf43 GIT binary patch literal 1091 zcmY*Y&1(}u6rW9Y(@oN9tBtK-%@#q79|?${porC~twN88!7-Rz~2h;{R^?5+$TeI`X5jb>%iQCtrb*lu!LW=?rn<_V7{vV-~fARhnd1u7?) z`-pkVp@sFrTCrcKlh@8>*{1)0Xpy?qqa`{;{ks$P>V%eY>WV^kEh}fA2@V4#sOU-! zYG{d4A{25@C~drKqR~u-FnF1EyE5sHR&x^+mvPWZRgfVK!fiw@^vz&pb3Kp=2*pE? zX@VY}M1dCJKuH(|VoxNXUzw5~Y7o7KYutqQgsDbChf4NsjhTu>hYw;fe#k*|Q#IUZ zc;?b6`a1dGx~V3kD@&Fk$}(a6+$&V0Xk0DgP8Q8#+Ahe|ATZBc+i6dGR{cV7$0Bo?jf-mJX|=y!?xl%9SI`CLhU0qhfrEEAEW7(O>44wng1jN#U)X>4h{n72i&YF4i?rh$VD83$0Z zJpiGxc8;+y=2}NGgiypWW4I18r<_=3xAT#jN2%;eWx_>8*HQd=he*d4?|pJFjH^rg ztK;d*`%iznRj+bLP^g9DiVgfM)yc17Syc9$()84MF|FGyd T&H-8eS(dx literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/filters.cpython-314.pyc b/infrasynth/audit/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d67855400b81414f900d0cd850a5b3479ced567a GIT binary patch literal 2051 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 literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/models.cpython-314.pyc b/infrasynth/audit/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1c8f79cbe355489c8cc9e0391cf3433bb446d912 GIT binary patch literal 4125 zcma)7&-6@Yj7|Nk%9vMiah6gQM3GEj}UvGY^Ml49DDEUcuWNi8pyKn~pxFr}pTw1Skv;pg>N!8L*Nb+xK?KElH7<$MEya@a@c- zdGCF1d@&Xc3DEw#uHAba5QOh=&~DyV=jETFQxonAl2{aOh)g_(eOJ+S!_D01gdt%< zkld#P$+O{Jfi-&DCAU4pyq2aHG<_#%`YcU9Xa-Ku^h?1>As6}{x5$Y+U=qeueanQt zZ^x(*%`A7?<7*9G>LMO#LtvsLFxOo#b8mQ7tgGl$7vN}j{~?Kz>$1z9XTI0e^AFSu zrOLfD9`oavmg`=EXH_$-{oce(A5 zIOt@L>%hJu{P8>XB|)$M-TYh%NI^$0Ww|W`ZVO*(-&b0Su#6N1C&hVe2|aRsThX`3 zqOrC1)Wt(P$|G4L`WD;ffl5hM^$K~Eb5V>V?k{f}YMGdy=jYTi%RW@ucJ|ksD$y#k zuI!L2*W(S5AE*8Z@~G%ZKCbUj67nnNzl+`*qAWFJ$hBsU@D?inIY@O zLs_X*Xb^V${OtOAaW1=Q&@3gNRf);6WrI?pDNHr=mPs@Wnq3j)B#8#UbVO8=xh0W# zHoPkl_@Sr44Z9?F-X(e3-W{mt&5Qrn%Db3f^09#Ahx-ihboYJcmk3k%uu`q4Otykw zE?ajkYsMB2+cTO{A{yLC=^iOFS*>8ASXXNDAX9hX*viiCQ(-w5L<+?wpJJ%9JRK&?%U~VRg5H!#7$(JIv?>91hVD_=jR{ z(oqzckM^|$AqnULMl#LdJa}{U_OkUOW&kFO$wLbUCF+)bi|kQMUmm}< zzIH=iU!9larQ37!i#)J8za}rQFD`N~tYc6fodd(xz@_#+r|_##E40D!_QA3+Yn{$M z?0Fm_BrSolZT{$ zWA*6RVS0!sq$968=Bq8d2ng|^W^BA38-Jerq!IhH_A%UcGcs0>jD5Y`9DnDZDZ9QBO|nOV6)<(nx+Mt3EqgL)UBs3S2)UV-}1>!DtP)b1z=i24L<-_tCNItk8#poPBfX)V}#9f*cs zQ!fH~eXwt>=WAny#XxJkXG<7D(MYVIvwIq7V_^3*s~BL^L1SsqdDHH9$_e38YsiK` zgIN1!3*2AMu%!9Q?_)2Zg!sl!~ zHQR<~dZd}2tfwdUGoX>aQd>OCoM~pJ>Y1tiDOe?QwYFqIZ>FA{Ik?nF{;YQMBx)j| zXX>e$f29g7IuBy#GfyM=J}l@^)pJm4B;Q4-ir&Hx0th=E@T6T4?LhSdi0zJuo_+;X zXS-mD?`O=go(4b)^m_w?b{}ZMif-o>i@?M%?`b{wje=I6_b}`m?|5S;K&uZHTj=sR zs7Zm|Aawa1_@*r&1pr7v2S^zy#D=6WWPOB3S4p`_Rkk<(0HV%@k89{G}D=*wR` rzVp?cBLTWcZ^p#J*S^0-{uDV9pnG&aC{BO5@Obg7#UBOeT4w$a&CpNZ literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/receivers.cpython-314.pyc b/infrasynth/audit/__pycache__/receivers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..102b4816afa0eca396d166acc83b0e243d3d522e GIT binary patch literal 5851 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 L9{fc>$?xzlq!z~= literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/signals.cpython-314.pyc b/infrasynth/audit/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..64dc20b7f699e1e1e5a7e6933b4f8f38c16c99b9 GIT binary patch literal 305 zcmdPq*0UP*j@GLT!8nzEANGswhSUiumNxvBcO`9+CkiP`!A zMfq8&$tA`5nR#hNiN%$9B^kO&iN&elIYq;;;eQ uU7C|>R|Eepd0|5QBs!x literal 0 HcmV?d00001 diff --git a/infrasynth/audit/__pycache__/urls.cpython-314.pyc b/infrasynth/audit/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6f7cf18fb0155163038c3db83f67e812a729aa5c GIT binary patch literal 835 zcma)4&x_MQ6rTKOlD3VyF4mrR++3=5W2Pnil)OqHmnMzkWZ&s($DOu?MN#cSyS|Q2i#{b>e_nQA{8zOZ4Q8M!g;~?6|~_!i}i4 z<-@&ANND~}37Qi9MfG<|XT>?Kl{QP7jVW zr|(ySsD($%Gtuulhv%LZ$W8GmKNI{#Q*gJe)2!EFpA=PN6kx=6k7Sl;0YOY2?;_S| zFGAfanofvOia$&Z*iabpzXGW|X`EGN8mb}K@-f>1mANU+vds3yh!DR4hfE6SZKECa zpwWwvGjO^_14SL^5^VS(Yl(*;X;=85#*X85Vdx#>jp-}~cdqt_RJ+=Xyf}c@5Wi7= zgSgMqL=pty6FBz~T=)vM-+}GV>B7t6Sor{yXSpZ2{Y-M|;@f-U+^Qb32}ukjGz1cgp^1T5;X*4=A=n}x#;FJ-b+#LOf|vM%&Q3yu zddRJ}p7{fM;g(~M9Jy4=%7=|cR2+J!dZP*|RnUrM|g&@?MZu6KH&pd$$HcN zREOwD1wK^x0N}V72o-TJ5*3hQMmqd$c-`4WB0Q$ZzO++#@^6 z`Zs#TkikuzJu;Xu;)s|T+0i+TA8WdpVmvzlzp?+ z0xNx9)GGH`Q+s5A3PeFjJD60k+zvIXYj2#3ykYpNq-EhA8nuA z(i3a5)9lDM!$*kMZS4O5f(ES15us#>@T^3HcYst$&X=(n+d7GmOr*Vyt*nv+-r_Gq zoo_2k~b9i0IAwA#NSAzkSPhAZ!av zNIwLo%Kr+D3%$$?p_L!zU4LxjxKsEC_aB3>O}=zj1aCbJu_0t!UN;mVWJzNO^-OK) zZrOcji`o_I`#VjZdN}T%_X5QI9M4t;s;duJUKlcJuo4I=XC-8v3|4u8admY|dbLvN zsUm$aV9mg7osp?Tv07${a+PbF+Ik|xtG1DZURmRsu~iYp@m0-WiB&DX&MJi#eL~wP z=pteBed6Jk4Bux${d$A(EugQ(%w2nCf4jAJA+vEj3VbL*@j#)Hm(&Wx!jecs; zX_HPr`?f~Eu;^Wr-mTH4-QdzbOdwCmT>OX(s%k|mGgXx#RV`Nw8zq#(DtMul+#af` zYdSYXNw2U2S2qMRz{eid8C1~oBF{=CRfVubT9hyi<8ze{ z0`l%Upl8NBz@Oj2sqG!|)V5Q5vBa*gZ|`_~_t?2z-@x9$x$XHkKE=PRydsGFqxuSm z%!b0OFGN%-;+1T6OaG{QT5 z9mU=-!WaT34#&FB;ju#a#|Y>?KZ_7YI0w+mc=VPdV1BkT{&4Oi<1e){e%xXFip}^H zm+`p5AsIjN!7{$DtwjH?*xrR1&c146w1uK-Qd0dX8Jq*w3!~oUHpsbLDU_F=@)Sz0wz~0(+}r9GJT?* z>2ZhYOE%M&T&Cj+bMcsra(LCKP}|GwQI)TAc+3#)1CYntdnTHN&Mkcbx57DDj75** zF5&5mPglda4)7_$@wjAHvw#}TWe~wWJS-WV(G6k05N7e&EPJyIsw3NEExPDUR)G?n zuM^rXg7&>&9~oXuPT;Ua%HS2}0q_~W0!^%)#`gfPyo#c{B(hJy literal 0 HcmV?d00001 diff --git a/infrasynth/audit/apps.py b/infrasynth/audit/apps.py new file mode 100644 index 0000000..edda536 --- /dev/null +++ b/infrasynth/audit/apps.py @@ -0,0 +1,20 @@ +from django.apps import AppConfig + + +class AuditConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.audit" + label = "infrasynth_audit" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + from . import receivers # noqa: F401 + + FeatureRegistry.register( + "audit", + name="Audit Logging", + description="Audit trail for model changes, API interactions, and security events", + default=True, + category="system", + ) diff --git a/infrasynth/audit/filters.py b/infrasynth/audit/filters.py new file mode 100644 index 0000000..3b7a89c --- /dev/null +++ b/infrasynth/audit/filters.py @@ -0,0 +1,40 @@ +import django_filters + +from .models import APIInteractionLog, ModelChangeLog, SecurityEvent + + +class ModelChangeLogFilter(django_filters.FilterSet): + class Meta: + model = ModelChangeLog + fields = { + "model_label": ["exact", "in"], + "action": ["exact"], + "object_id": ["exact"], + "actor": ["exact"], + "request_id": ["exact"], + "timestamp": ["exact", "gte", "lte"], + } + + +class APIInteractionLogFilter(django_filters.FilterSet): + class Meta: + model = APIInteractionLog + fields = { + "method": ["exact"], + "status_code": ["exact"], + "path": ["exact", "contains"], + "actor": ["exact"], + "request_id": ["exact"], + "timestamp": ["exact", "gte", "lte"], + } + + +class SecurityEventFilter(django_filters.FilterSet): + class Meta: + model = SecurityEvent + fields = { + "event_type": ["exact"], + "actor": ["exact"], + "ip_address": ["exact"], + "timestamp": ["exact", "gte", "lte"], + } diff --git a/infrasynth/audit/middleware.py b/infrasynth/audit/middleware.py new file mode 100644 index 0000000..523745a --- /dev/null +++ b/infrasynth/audit/middleware.py @@ -0,0 +1,80 @@ +import time +import uuid + +from django.conf import settings +from django.utils.deprecation import MiddlewareMixin + +from .models import APIInteractionLog + + +class AuditAPIMiddleware(MiddlewareMixin): + def process_request(self, request): + request.request_id = str(uuid.uuid4()) + request._audit_start_time = time.time() + + def process_response(self, request, response): + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + if not config.get("ENABLE_API_LOGGING", True): + return response + + path = request.path + if path.startswith("/admin/"): + return response + + if hasattr(request, "_audit_start_time"): + duration_ms = int((time.time() - request._audit_start_time) * 1000) + else: + duration_ms = 0 + + max_body = config.get("MAX_BODY_SIZE_BYTES", 5000) + request_body = None + response_body = None + sensitive_keys = config.get("SENSITIVE_KEYS", []) + + try: + raw_body = getattr(request, "body", b"") + if raw_body and len(raw_body) <= max_body: + body = raw_body.decode("utf-8", errors="replace") + if not any(k in body.lower() for k in sensitive_keys): + import json + + try: + request_body = json.loads(body) + except (json.JSONDecodeError, ValueError): + request_body = {"_truncated": True} + except Exception: + pass + + try: + if hasattr(response, "data") and response.data: + import json as _json + + try: + raw = _json.dumps(response.data) + if len(raw) <= max_body: + response_body = response.data + except (TypeError, ValueError): + pass + except Exception: + pass + + actor = getattr(request, "user", None) + if actor and not actor.is_authenticated: + actor = None + if actor is not None and not hasattr(actor, "_meta"): + actor = None + + APIInteractionLog.objects.create( + method=request.method, + path=path, + status_code=response.status_code, + request_body=request_body, + response_body=response_body, + ip_address=request.META.get("REMOTE_ADDR"), + actor=actor, + duration_ms=duration_ms, + request_id=getattr(request, "request_id", ""), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + + return response diff --git a/infrasynth/audit/migrations/0001_initial.py b/infrasynth/audit/migrations/0001_initial.py new file mode 100644 index 0000000..fa26b2b --- /dev/null +++ b/infrasynth/audit/migrations/0001_initial.py @@ -0,0 +1,91 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +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="APIInteractionLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("method", models.CharField(db_index=True, max_length=10)), + ("path", models.CharField(db_index=True, max_length=500)), + ("status_code", models.PositiveSmallIntegerField(db_index=True)), + ("request_body", models.JSONField(blank=True, null=True)), + ("response_body", models.JSONField(blank=True, null=True)), + ("ip_address", models.GenericIPAddressField(null=True)), + ("duration_ms", models.PositiveIntegerField()), + ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), + ("request_id", models.CharField(max_length=64, unique=True)), + ("user_agent", models.TextField(blank=True, default="")), + ( + "actor", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "audit_api_interaction_log", + }, + ), + migrations.CreateModel( + name="SecurityEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_type", models.CharField(db_index=True, max_length=50)), + ("ip_address", models.GenericIPAddressField(null=True)), + ("metadata", models.JSONField(default=dict)), + ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), + ("request_id", models.CharField(max_length=64)), + ( + "actor", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "audit_security_event", + }, + ), + migrations.CreateModel( + name="ModelChangeLog", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("model_label", models.CharField(db_index=True, max_length=200)), + ("object_id", models.CharField(db_index=True, max_length=200)), + ( + "action", + models.CharField( + choices=[("create", "create"), ("update", "update"), ("delete", "delete")], max_length=10 + ), + ), + ("changes", models.JSONField(help_text="Dict with {field_name: [old_value, new_value]}")), + ("timestamp", models.DateTimeField(auto_now_add=True, db_index=True)), + ("request_id", models.CharField(help_text="UUID for request correlation", max_length=64)), + ( + "actor", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "audit_model_change_log", + "indexes": [ + models.Index(fields=["model_label", "object_id"], name="audit_model_model_l_923061_idx"), + models.Index(fields=["timestamp"], name="audit_model_timesta_4429ca_idx"), + ], + }, + ), + ] diff --git a/infrasynth/audit/migrations/__init__.py b/infrasynth/audit/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..62bca9473a04d587f21bf6429d384a34641a3f03 GIT binary patch literal 4314 zcmd5yzfk-Wa^g!QdseK{0fO!ZdtaGL{7^wubdJYsR)<$MyvW zc7&;ZDoQc7L5i`j+p|4QL8sr7R&$Iy82U3R?)Z;BU5?YlsUnO8si?*+#JwXF6%Grs zq!P!D%p*}PoQj}8iTQb_IrMw2!<`rkTQ;27%Fx&bn!8%z<~T~NcVvO#4?mN%xss%@ z3r_lexKHVfER1!%GkdY^9?j5j1CCKFMpBf2P;2e8g)+ut>9jwIn(8g)7WR(!yM4(oGKMBKwjwGjWPbT2hwNoKISDfSJTW z=6BE&r-*B*+5d9HJDbg9B#acUpf|)txkxMmKTj-~xwwsVab+yC1qE>!@uah)2s@mz z$8MuN;!=?!aH8-6k*-~&V|NX2Yk=J!B|Juh<7HnB-%Q6$|8QcKXbk+DnM zLW$*g9u`$Nt`}0$$(PO6&hDthg@qju#qE?pkjX`opBYCCUGAnX0j{ff_4AIhB!GV6 zE(1T7Dr+##~-R#Lty^0kd35 z06ToJF)PZ&xD^-~*4h-1$g8A7E}0LK?p0JMD+1oTzpLwZBmIlTdnH7iU|EjmFb8BK za3A9Fg*_z>L_f< zLIJ6!RG8^U)fdS*p@1XL1iTgbvvFI)&$l99$s*70a$*^cMI`jh9Ik%@k1f|05=$&b z&+&`k+FE8VvMDQ(hOHw7Sy7N^JioxM6TrjNH(;_bZ)xh*Ek!zv5JD;mwE`Ms*qiBz z@f+8H$8!)iq6^de^jT~p3fRfX^vwdNW50tpp%oZA%+OYD>ehs5xdi4#%B{xKbgD9~ zPlt$;HHfn;akDJwFN+AqT`c>w%!#@)VRnIKHw8t-q97qjh6T92fFVy5{qaV8>NBoX z(qjVxri36jq{5z}cWZKCb}hHSuC3l@*rk=Z`-`OO1401uo%f6+T+)52=nrSo^|?@- zh4h@)1Bkf5(TX|rXJr+1?xNKlP89XzE~3Vk>*4CkvWe<{fF#J^LMA(FW{|n;YaTvx zu6qv5l7nPwD9eFs&7pf!X(4487hU(tfTW|ljyS2fq zmBFhAwxgK#C96qFbx_qjxbF4u->3zyRRY)EK!`p|ZStD&6cS*v@E zgQ-e5b&x*ftKmnQXR&4R4S)D~HT*#HEWGsv>b^@g-{p$$a(yWJ`td)$`0k5Z;?8m6 zPAxHioS3gAR*n-ZTJGywPCU+u@2!^pffKvk+k4{g_4S;LQr>_ToB{*(_q@M$(d9l# zx7aZ9Q5&A#(RVal4KHb)<;ES)G7juNt_>wBLy0$hb!b}i-2Q3K)$m=-^M_wR?m{<^ z3mN3nCb?)IkQ;3w7yT$X!|mgh$oN6*Xrk$O!&S*jC4fdYc9S{qNAu!iEy-4>u{_;?H+|0+g zllaYwlH~JAqu{H_W?4~|^}5j6*e}RX2#hl7`f$RixJIcoQ(Uhmrtm-NN1eCL5`I_t u3a0wcTUGzncw(h#`kjNKqyMBL|Ds0UJ637h@vXb&i5+`l|E2VqA?5${)bEM_ literal 0 HcmV?d00001 diff --git a/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07a141d76b3aa6c4bf3c8ebccb1af63a48189129 GIT binary patch literal 185 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9%Wnvz+fpPQLp zlvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A4agzIAjT(VMn=XWW*`dyt)?(T literal 0 HcmV?d00001 diff --git a/infrasynth/audit/mixins.py b/infrasynth/audit/mixins.py new file mode 100644 index 0000000..4477dee --- /dev/null +++ b/infrasynth/audit/mixins.py @@ -0,0 +1,26 @@ +from django.conf import settings +from django.db import models + + +class OptionalAuditableMixin(models.Model): + """Mixin for models that want explicit auditable fields.""" + + usuario_creacion = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + fecha_creacion = models.DateTimeField(auto_now_add=True, null=True, blank=True) + usuario_actualizacion = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + fecha_actualizacion = models.DateTimeField(auto_now=True, null=True, blank=True) + + class Meta: + abstract = True diff --git a/infrasynth/audit/models.py b/infrasynth/audit/models.py new file mode 100644 index 0000000..1165509 --- /dev/null +++ b/infrasynth/audit/models.py @@ -0,0 +1,51 @@ +from django.conf import settings +from django.db import models + + +class ModelChangeLog(models.Model): + model_label = models.CharField(max_length=200, db_index=True) + object_id = models.CharField(max_length=200, db_index=True) + action = models.CharField( + max_length=10, + choices=[("create", "create"), ("update", "update"), ("delete", "delete")], + ) + changes = models.JSONField(help_text="Dict with {field_name: [old_value, new_value]}") + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64, help_text="UUID for request correlation") + + class Meta: + db_table = "audit_model_change_log" + indexes = [ + models.Index(fields=["model_label", "object_id"]), + models.Index(fields=["timestamp"]), + ] + + +class APIInteractionLog(models.Model): + method = models.CharField(max_length=10, db_index=True) + path = models.CharField(max_length=500, db_index=True) + status_code = models.PositiveSmallIntegerField(db_index=True) + request_body = models.JSONField(null=True, blank=True) + response_body = models.JSONField(null=True, blank=True) + ip_address = models.GenericIPAddressField(null=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + duration_ms = models.PositiveIntegerField() + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64, unique=True) + user_agent = models.TextField(blank=True, default="") + + class Meta: + db_table = "audit_api_interaction_log" + + +class SecurityEvent(models.Model): + event_type = models.CharField(max_length=50, db_index=True) + actor = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + ip_address = models.GenericIPAddressField(null=True) + metadata = models.JSONField(default=dict) + timestamp = models.DateTimeField(auto_now_add=True, db_index=True) + request_id = models.CharField(max_length=64) + + class Meta: + db_table = "audit_security_event" diff --git a/infrasynth/audit/receivers.py b/infrasynth/audit/receivers.py new file mode 100644 index 0000000..9e44255 --- /dev/null +++ b/infrasynth/audit/receivers.py @@ -0,0 +1,130 @@ +import uuid + +from django.conf import settings +from django.db.models.signals import post_delete, post_save +from django.dispatch import receiver + +from .models import ModelChangeLog, SecurityEvent +from .signals import security_event_occurred + + +def _get_excluded_models(): + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + return set(config.get("EXCLUDED_MODELS", [])) + + +def _get_excluded_fields(): + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + return set(config.get("EXCLUDED_FIELDS", [])) + + +def _get_request_id(request=None): + if request: + return getattr(request, "request_id", "") or str(uuid.uuid4())[:8] + return str(uuid.uuid4())[:8] + + +@receiver(post_save) +def track_model_change(sender, instance, created, raw, **kwargs): + if raw: + return + label = sender._meta.label + if label in _get_excluded_models(): + return + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): + return + + if created: + ModelChangeLog.objects.create( + model_label=label, + object_id=str(instance.pk), + action="create", + changes=_get_created_changes(instance), + actor=_get_actor_from_instance(instance), + request_id=_get_request_id(), + ) + else: + if hasattr(instance, "_previous_state"): + changes = _compute_changes(instance._previous_state, instance) + if changes: + ModelChangeLog.objects.create( + model_label=label, + object_id=str(instance.pk), + action="update", + changes=changes, + actor=_get_actor_from_instance(instance), + request_id=_get_request_id(), + ) + + +@receiver(post_delete) +def track_model_delete(sender, instance, **kwargs): + label = sender._meta.label + if label in _get_excluded_models(): + return + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + if not config.get("ENABLE_MODEL_CHANGE_TRACKING", True): + return + + ModelChangeLog.objects.create( + model_label=label, + object_id=str(instance.pk), + action="delete", + changes={}, + actor=_get_actor_from_instance(instance), + request_id=_get_request_id(), + ) + + +def _get_actor_from_instance(instance): + for field in ["actor", "user", "usuario_creacion", "created_by", "uploaded_by"]: + val = getattr(instance, field, None) + if val is not None: + return val + return None + + +def _get_created_changes(instance): + excluded = _get_excluded_fields() + changes = {} + for field in instance._meta.get_fields(): + if field.name in excluded: + continue + if hasattr(field, "serialize") and field.serialize: + val = getattr(instance, field.name, None) + if val is not None: + changes[field.name] = [None, str(val)] + return changes + + +def _compute_changes(old, new): + excluded = _get_excluded_fields() + changes = {} + for field in new._meta.get_fields(): + if field.name in excluded: + continue + if not hasattr(field, "column") or field.column is None: + continue + old_val = getattr(old, field.name, None) + new_val = getattr(new, field.name, None) + if old_val != new_val: + changes[field.name] = [ + str(old_val) if old_val is not None else None, + str(new_val) if new_val is not None else None, + ] + return changes + + +@receiver(security_event_occurred) +def log_security_event(sender, **kwargs): + config = getattr(settings, "INFRASYNTH_AUDIT", {}) + if not config.get("ENABLE_SECURITY_EVENTS", True): + return + SecurityEvent.objects.create( + event_type=kwargs.get("event_type", "unknown"), + actor=kwargs.get("actor"), + ip_address=kwargs.get("ip_address"), + metadata=kwargs.get("metadata", {}), + request_id=_get_request_id(), + ) diff --git a/infrasynth/audit/serializers.py b/infrasynth/audit/serializers.py new file mode 100644 index 0000000..2c0f7e2 --- /dev/null +++ b/infrasynth/audit/serializers.py @@ -0,0 +1,54 @@ +from rest_framework import serializers + +from .models import APIInteractionLog, ModelChangeLog, SecurityEvent + + +class ModelChangeLogSerializer(serializers.ModelSerializer): + class Meta: + model = ModelChangeLog + fields = "__all__" + read_only_fields = [ + "id", + "model_label", + "object_id", + "action", + "changes", + "actor", + "timestamp", + "request_id", + ] + + +class APIInteractionLogSerializer(serializers.ModelSerializer): + class Meta: + model = APIInteractionLog + fields = "__all__" + read_only_fields = [ + "id", + "method", + "path", + "status_code", + "request_body", + "response_body", + "ip_address", + "actor", + "duration_ms", + "timestamp", + "request_id", + "user_agent", + ] + + +class SecurityEventSerializer(serializers.ModelSerializer): + class Meta: + model = SecurityEvent + fields = "__all__" + read_only_fields = [ + "id", + "event_type", + "actor", + "ip_address", + "metadata", + "timestamp", + "request_id", + ] diff --git a/infrasynth/audit/signals.py b/infrasynth/audit/signals.py new file mode 100644 index 0000000..0c47f93 --- /dev/null +++ b/infrasynth/audit/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +model_changed = Signal() +security_event_occurred = Signal() diff --git a/infrasynth/audit/urls.py b/infrasynth/audit/urls.py new file mode 100644 index 0000000..5c692aa --- /dev/null +++ b/infrasynth/audit/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import APIInteractionLogViewSet, ModelChangeLogViewSet, SecurityEventViewSet + +router = DefaultRouter() +router.register(r"changes", ModelChangeLogViewSet, basename="audit-changes") +router.register(r"api-logs", APIInteractionLogViewSet, basename="audit-api-logs") +router.register(r"security-events", SecurityEventViewSet, basename="audit-security-events") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/audit/views.py b/infrasynth/audit/views.py new file mode 100644 index 0000000..b1b8089 --- /dev/null +++ b/infrasynth/audit/views.py @@ -0,0 +1,40 @@ +from rest_framework import mixins, viewsets +from rest_framework.permissions import IsAuthenticated + +from .filters import APIInteractionLogFilter, ModelChangeLogFilter, SecurityEventFilter +from .models import APIInteractionLog, ModelChangeLog, SecurityEvent +from .serializers import ( + APIInteractionLogSerializer, + ModelChangeLogSerializer, + SecurityEventSerializer, +) + + +class ModelChangeLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() + serializer_class = ModelChangeLogSerializer + permission_classes = [IsAuthenticated] + filterset_class = ModelChangeLogFilter + + def get_queryset(self): + return ModelChangeLog.objects.select_related("actor").order_by("-timestamp").all() + + +class APIInteractionLogViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() + serializer_class = APIInteractionLogSerializer + permission_classes = [IsAuthenticated] + filterset_class = APIInteractionLogFilter + + def get_queryset(self): + return APIInteractionLog.objects.select_related("actor").order_by("-timestamp").all() + + +class SecurityEventViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() + serializer_class = SecurityEventSerializer + permission_classes = [IsAuthenticated] + filterset_class = SecurityEventFilter + + def get_queryset(self): + return SecurityEvent.objects.select_related("actor").order_by("-timestamp").all() diff --git a/infrasynth/billing/__init__.py b/infrasynth/billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/billing/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6ec94ae229ce7c168d0b8b707b4a44b20cf1c919 GIT binary patch literal 176 zcmdPq_I|p@<2{{|u76<*T2OpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&7nUj;5m#!Zl qpP83g5+AQuP0|Baqp1`2{FZ-q$prr%^YHUsw%*#t(rR zp(BKiUG&C~Mg!H*8p63c!udnvX+HInIc(lTE$gQiTZSra#qrB1?D?LPI;7Odgz*z# zDC9jv(!faOj!cX*#d% z$_#0rsfx?~N2sF9dUOy-Wjj#Bi}-M_l~=cCQ0k?9iZ;(W)oU+iJ(RVT!Iy8<&J02>FGwKf2 zQie);&Cm=eOHfQa05U=s_U3oH{>^TFwVO$`mMq*#ZoW&FSCiV>Wd)Viu27*=9)m0T zjP_b(WfP*oAVif3fkhbvny(V_en5lFGEYd)=R(4Eu`mMjVg?qWuuouvh2m96sjPr@ zS-_VyUn4|FxFd&9DY@SrNG2e5feOC?QNQ-6mfG%JtSvhzX2n z0QvI~#!Ob9e=%2*N@MgYsWwNif14$%e2IYYm1%^&z)JQCG>BZk$TVSHa}!5hMA6vt;#dNs~qiO=6rlRW4NyJv3NqEo)OLCwsNvwX@E$xHE$j zE>%uZ4?S??+*|$}PFT}RrJj1T1WhhI^u5_71{oK;(mv+Lci-;3&zm>g&!m$CuCC%A z{;m`97j{OM5j6(?LgO9TC6=;DIN4P!byKOTT&-$ctLj{@8r-Ow+$@ni`I1;#fmr$u z+0Kj{o&7%1!Zwdfr2$HFhB6_QF;K>5D3el|0A+H9azQE=K$)7MOj*ipQcnMkpS@K! z#KL!;?|Z?)w(kTYwiz5Zy}Bzh+fKXT2K>3h-8W8KWTXC1)CrhV=Uy`qsTZvpt5fd? zd-b!B5CUJv2-aoy6mVd?NyhR996_j}LhE}ifl zJw*mzP5MrAZAS+`z;aEgDBJ|EvB+z2n%A`-UG9j4$822Bu`3MsEwnyBRN^9T+kw+? zZCfO5yV2Zh`Pfd`_RlTHk7lme_P$3Mhv>OM)3zz5moPx>`oi4zTz`*=ylpe?aIbDV zoKvsX;w}RzX3_xHFMg=JYBtEslRrE zy|M91EhJajmr-JCM{S|wqUZuRPQ%UeVBV9H#noSpKU0P7-EQ$zQ?tq6Ou7U!bQukP z%GL*yUct^HnyYBCXrut0NLBby0lSHjNkN05c~3s6x{>^cfC+`DnLu|@TI6P?#nrJS zosttKya}(tI+(G1%a7-TrD0}Ry37v^^231qFd^$=V(FawFgbMQw+<*qQpCLgGV$2) zDK0h)S~)&7Xjl>GH8eRi)50g?dIM&0lHCS_lva>Zw)3*Hd6HV{7P^U(h*?aZ#*7xx zJ>nY!vw~eT`0l|7jEP{tghHI23(V?FFxl}Ma0xP{Imr~x`;XAHm`g)6`Vp2c6SrB5 z{V$mq24@a3SXxSOe^p@J07!7dFbJIiN#| z<{ma~qLCbMXf6(qXL49Re_BpKa#1FLcP+VUKvoYOmX8P|KZ+QxWz>xjP?tchX<7`igN!+`0~{md_;oB15L!g4Nv)J z^c(0Za5Epwsir8(A0+=tHx(61sG<(DhLY?)>#uC|RyKwNI>W^njAZ)D>%HamA%V^? nha;=~;-g;i(b-66tDk$&%RPujOwjz&FFoy*o_+++;hO&gj@cIJ literal 0 HcmV?d00001 diff --git a/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc b/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..20b41ba7e2a70f0611411ae47e009e28d93abfee GIT binary patch literal 6289 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 a2~^fc6svN4p*P!fc&X{|e+X2>6#oa|i~DN; literal 0 HcmV?d00001 diff --git a/infrasynth/billing/__pycache__/services.cpython-314.pyc b/infrasynth/billing/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f76e46b831e2a3650ca947fbe49c4a71fe34a218 GIT binary patch literal 9564 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 literal 0 HcmV?d00001 diff --git a/infrasynth/billing/__pycache__/signals.cpython-314.pyc b/infrasynth/billing/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f503896d0d6f583f47c7bb794c83c9998f2e8778 GIT binary patch literal 512 zcmY+9Pfx-y6u?`ih=xB|f`BH5s3&4%7mtQBCdL>%E9ts23R}0d9m0)x^~ljrpr3~8 zmUu9pxEc8Zc7!GNC2imD*Y>^kqFg!vDn3@@(XU#+Q^+E>P1aH+bMORu@CJ0DgiY`W zPr=o8Tjn$}G}_T}qziYgA(5FIuB7!blwaQ0O{SxlFp8Xq;^8cR@% cGp-&lx=UcjW_{6^m*Wg8hp*F@A}^^De`xHG>i_@% literal 0 HcmV?d00001 diff --git a/infrasynth/billing/__pycache__/urls.cpython-314.pyc b/infrasynth/billing/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..413e011b564705738a88a3a863c23207c01e085c GIT binary patch literal 1267 zcma)5&ubGw6rSB~b|=}UNq=M2##9u7HY>F%h>D2f=AvOMtx&gRlTMRulHD>hNog)t z5mda?lRbLWn}30S!(s*FfOzoat))V*&NNw@f``uG&Ajh@@4at+UFeI)03TaYb*0e* zz%Sv119ldBFC+jqzyh$O955v+nKG4A!k2SBra~3ImaLv!#8jzjYE&~Jg=UmS%@~cD zI@MFaR?=X)Q;zo9z4?=2lf&6kY-mL+^%~b92G9CGPqS4E9)lOPV^-`KJl)o;-ed6M zcGT)S!jsd$O4w3bC_U9y-EF7&xrs3?w`W`MleV58Z~NVrNBXS+Lke@OL2D>HiL6YM z*6>-78TlQ!p_b&7CCWdnEIYSr~h`Ksg1x@c_% zQP!998opaZp+B5=)@#V4_Z^DXob?bs(SaXf$0JUWy8gckgERF4DPniE8|<4yg|hEg z!hBe2*CWkjp)F!EM7UB|a0v388e)-Eu8lO~A?d(nanjX-G>60J5tu=@y_K#WA;Nm; zp>Wb13JY|j3^bE#ZPf-wMdUV+!OzkslxZEmIT|K*h?AWF--9CbKgjemeQCwdfljMEC z&F%n*jlM~3s4bwrOgu|$>cQ!$;O4`P#8;3HF&K$%O@ck^%Ox8vz;W#YR6TX3ueN4~-FFL3<5 d`XL&mrncebR{Z=QO^K*G5iKD<*Kvw(;V+{uPjLVM literal 0 HcmV?d00001 diff --git a/infrasynth/billing/__pycache__/views.cpython-314.pyc b/infrasynth/billing/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9802bc59acc65289cd40d9b15a2d3633f0a8b9e8 GIT binary patch literal 10362 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 Keoe5%CiY)Ts31=O literal 0 HcmV?d00001 diff --git a/infrasynth/billing/apps.py b/infrasynth/billing/apps.py new file mode 100644 index 0000000..c9868d2 --- /dev/null +++ b/infrasynth/billing/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class BillingConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.billing" + label = "infrasynth_billing" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "billing", + name="Billing", + description="Payment gateway integration, plans, subscriptions, and invoicing", + default=False, + category="operations", + ) diff --git a/infrasynth/billing/filters.py b/infrasynth/billing/filters.py new file mode 100644 index 0000000..3708539 --- /dev/null +++ b/infrasynth/billing/filters.py @@ -0,0 +1,56 @@ +import django_filters + +from .models import ( + BillingPlan, + Invoice, + PaymentGateway, + PaymentTransaction, + Subscription, +) + + +class PaymentGatewayFilter(django_filters.FilterSet): + class Meta: + model = PaymentGateway + fields = { + "is_active": ["exact"], + } + + +class BillingPlanFilter(django_filters.FilterSet): + class Meta: + model = BillingPlan + fields = { + "slug": ["exact"], + "interval": ["exact"], + "is_active": ["exact"], + } + + +class SubscriptionFilter(django_filters.FilterSet): + class Meta: + model = Subscription + fields = { + "user": ["exact"], + "plan": ["exact"], + "status": ["exact"], + } + + +class InvoiceFilter(django_filters.FilterSet): + class Meta: + model = Invoice + fields = { + "user": ["exact"], + "subscription": ["exact"], + "status": ["exact"], + } + + +class PaymentTransactionFilter(django_filters.FilterSet): + class Meta: + model = PaymentTransaction + fields = { + "invoice": ["exact"], + "status": ["exact"], + } diff --git a/infrasynth/billing/gateways/__init__.py b/infrasynth/billing/gateways/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..00f925f679c7ccabedfe3133e2c347234a25d652 GIT binary patch literal 185 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 C$!s CheckoutSessionResult: ... + + @abstractmethod + def handle_webhook(self, payload, headers) -> WebhookResult: ... + + @abstractmethod + def cancel_subscription(self, subscription) -> bool: ... + + @abstractmethod + def sync_subscription(self, subscription) -> dict: ... + + @abstractmethod + def get_invoice(self, invoice) -> dict: ... + + @abstractmethod + def health_check(self) -> bool: ... diff --git a/infrasynth/billing/gateways/mercadopago.py b/infrasynth/billing/gateways/mercadopago.py new file mode 100644 index 0000000..e51e918 --- /dev/null +++ b/infrasynth/billing/gateways/mercadopago.py @@ -0,0 +1,134 @@ +import logging + +from infrasynth.shared.enums import SubscriptionStatus + +from .base import BasePaymentGateway, CheckoutSessionResult, WebhookResult + +logger = logging.getLogger(__name__) + + +class MercadoPagoGateway(BasePaymentGateway): + """MercadoPago payment gateway. + + Configuration keys (read from ``PaymentGateway.config``): + - ``access_token`` (required for most operations) + """ + + gateway_slug = "mercadopago" + + STATUS_MAP = { + "authorized": SubscriptionStatus.ACTIVE, + "pending": SubscriptionStatus.PAST_DUE, + "paused": SubscriptionStatus.PAST_DUE, + "cancelled": SubscriptionStatus.CANCELLED, + } + + def __init__(self, config: dict | None = None): + config = {str(key).lower(): value for key, value in (config or {}).items()} + self.access_token = config.get("access_token") + self._sdk = None + + @property + def sdk(self): + if self._sdk is None: + import mercadopago + + self._sdk = mercadopago.SDK(self.access_token) + return self._sdk + + def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + self._require_credentials() + preference = { + "items": [ + { + "title": plan.name, + "quantity": 1, + "currency_id": plan.price_currency, + "unit_price": float(plan.price_amount), + } + ], + "back_urls": { + "success": kwargs.get("success_url") or "https://example.com/success", + "failure": kwargs.get("cancel_url") or "https://example.com/cancel", + "pending": kwargs.get("cancel_url") or "https://example.com/cancel", + }, + "auto_return": "approved", + "notification_url": kwargs.get("notification_url") or "", + "metadata": {"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, + } + result = self.sdk.preference().create(preference) + if result.get("status") != 201: + raise ValueError(f"MercadoPago error: {result.get('response')}") + response = result["response"] + return CheckoutSessionResult( + session_id=response["id"], + checkout_url=response.get("init_point") or "", + client_secret="", + ) + + def handle_webhook(self, payload, headers) -> WebhookResult: + event_type = payload.get("type") or "payment" + data = payload.get("data") or payload + return WebhookResult( + event_type=event_type, + is_handled=True, + data=data, + ) + + def cancel_subscription(self, subscription) -> bool: + self._require_credentials() + if not subscription.external_id: + return False + result = self.sdk.preapproval().update(subscription.external_id, {"status": "cancelled"}) + return result.get("status") in (200, 201) + + def sync_subscription(self, subscription) -> dict: + self._require_credentials() + if not subscription.external_id: + return {} + result = self.sdk.preapproval().get(subscription.external_id) + if result.get("status") != 200: + return {} + data = result.get("response") or {} + raw_status: str | None = data.get("status") if isinstance(data, dict) else None + return { + "status": self.STATUS_MAP.get(raw_status or "", raw_status), + "current_period_start": self._parse_datetime(data.get("date_created")), + "current_period_end": self._parse_datetime(data.get("next_payment_date")), + "trial_end": self._parse_datetime(data.get("trial_end_date")), + "cancel_at_period_end": data.get("auto_recurring", {}).get("end_date") is None + and data.get("status") == "cancelled", + "metadata": data.get("metadata") or {}, + } + + def get_invoice(self, invoice) -> dict: + self._require_credentials() + if not invoice.external_id: + return {} + result = self.sdk.payment().get(invoice.external_id) + if result.get("status") != 200: + return {} + data = result.get("response") or {} + return { + "external_id": data.get("id"), + "status": data.get("status"), + "amount": data.get("transaction_amount"), + "currency": (data.get("currency_id") or "USD").upper(), + "paid_at": self._parse_datetime(data.get("date_approved")), + "payment_method": data.get("payment_method_id") or "", + } + + def health_check(self) -> bool: + return bool(self.access_token) + + def _require_credentials(self) -> None: + if not self.access_token: + raise ValueError("MercadoPago access token not configured") + + @staticmethod + def _parse_datetime(value): + if not value: + return None + from django.utils.dateparse import parse_datetime + + return parse_datetime(value) diff --git a/infrasynth/billing/gateways/stripe.py b/infrasynth/billing/gateways/stripe.py new file mode 100644 index 0000000..3a26662 --- /dev/null +++ b/infrasynth/billing/gateways/stripe.py @@ -0,0 +1,125 @@ +import json +import logging +from datetime import UTC, datetime + +import stripe + +from infrasynth.shared.enums import SubscriptionStatus + +from .base import BasePaymentGateway, CheckoutSessionResult, WebhookResult + +logger = logging.getLogger(__name__) + + +class StripeGateway(BasePaymentGateway): + """Stripe payment gateway. + + Configuration keys (read from ``PaymentGateway.config``): + - ``api_key`` (required for most operations) + - ``webhook_secret`` (required to verify inbound webhooks) + """ + + gateway_slug = "stripe" + + STATUS_MAP = { + "active": SubscriptionStatus.ACTIVE, + "trialing": SubscriptionStatus.TRIALING, + "past_due": SubscriptionStatus.PAST_DUE, + "unpaid": SubscriptionStatus.PAST_DUE, + "canceled": SubscriptionStatus.CANCELLED, + "incomplete": SubscriptionStatus.PAST_DUE, + "incomplete_expired": SubscriptionStatus.EXPIRED, + } + + def __init__(self, config: dict | None = None): + config = {str(key).lower(): value for key, value in (config or {}).items()} + self.api_key = config.get("api_key") + self.webhook_secret = config.get("webhook_secret") + if self.api_key: + stripe.api_key = self.api_key + + def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + self._require_credentials() + if not plan.external_id: + raise ValueError("Plan has no external price ID configured for Stripe") + session = stripe.checkout.Session.create( + mode="subscription", + line_items=[{"price": plan.external_id, "quantity": 1}], + success_url=kwargs.get("success_url") or "https://example.com/success", + cancel_url=kwargs.get("cancel_url") or "https://example.com/cancel", + customer_email=str(getattr(user, "email", "") or ""), + metadata={"plan_slug": plan.slug, "user_id": str(getattr(user, "pk", ""))}, + ) + return CheckoutSessionResult( + session_id=session.id, + checkout_url=session.url or "", + client_secret=session.client_secret or "", + ) + + def handle_webhook(self, payload, headers) -> WebhookResult: + if not self.webhook_secret: + raise ValueError("Stripe webhook secret not configured") + signature_header = headers.get("Stripe-Signature", "") + raw_payload = json.dumps(payload) if isinstance(payload, dict) else payload + event = stripe.Webhook.construct_event(raw_payload, signature_header, self.webhook_secret) + return WebhookResult( + event_type=event["type"], + is_handled=True, + data=event["data"]["object"], + ) + + def cancel_subscription(self, subscription) -> bool: + self._require_credentials() + if not subscription.external_id: + return False + stripe.Subscription.cancel(subscription.external_id) + return True + + def sync_subscription(self, subscription) -> dict: + self._require_credentials() + if not subscription.external_id: + return {} + data = stripe.Subscription.retrieve(subscription.external_id) + return { + "status": self.STATUS_MAP.get(data.get("status"), data.get("status")), + "current_period_start": self._to_datetime(data.get("current_period_start")), + "current_period_end": self._to_datetime(data.get("current_period_end")), + "cancel_at_period_end": data.get("cancel_at_period_end", False), + "cancelled_at": self._to_datetime(data.get("canceled_at")), + "trial_end": self._to_datetime(data.get("trial_end")), + "metadata": data.get("metadata") or {}, + } + + def get_invoice(self, invoice) -> dict: + self._require_credentials() + if not invoice.external_id: + return {} + data = stripe.Invoice.retrieve(invoice.external_id) + return { + "external_id": data.get("id"), + "status": data.get("status"), + "amount": (data.get("amount_due") or 0) / 100, + "currency": (data.get("currency") or "usd").upper(), + "paid_at": self._to_datetime(data.get("paid_at")), + "line_items": [ + { + "description": item.get("description"), + "amount": (item.get("amount") or 0) / 100, + "quantity": item.get("quantity"), + } + for item in data.get("lines", {}).get("data", []) + ], + } + + def health_check(self) -> bool: + return bool(self.api_key) + + def _require_credentials(self) -> None: + if not self.api_key: + raise ValueError("Stripe API key not configured") + + @staticmethod + def _to_datetime(timestamp) -> datetime | None: + if not timestamp: + return None + return datetime.fromtimestamp(timestamp, tz=UTC) diff --git a/infrasynth/billing/gateways/wompi.py b/infrasynth/billing/gateways/wompi.py new file mode 100644 index 0000000..f69f952 --- /dev/null +++ b/infrasynth/billing/gateways/wompi.py @@ -0,0 +1,149 @@ +import hashlib +import hmac +import json +import logging + +import requests + +from infrasynth.shared.enums import SubscriptionStatus + +from .base import BasePaymentGateway, CheckoutSessionResult, WebhookResult + +logger = logging.getLogger(__name__) + + +class WompiGateway(BasePaymentGateway): + """Wompi (Colombia) payment gateway. + + Configuration keys (read from ``PaymentGateway.config``): + - ``public_key`` (required for checkout sessions) + - ``secret_key`` (required for voiding transactions) + - ``webhook_secret`` (used to verify inbound webhook signatures) + - ``environment`` ("sandbox" | "production", default "sandbox") + - ``base_url`` (optional override) + """ + + gateway_slug = "wompi" + + BASE_URLS = { + "production": "https://production.wompi.co/v1", + "sandbox": "https://sandbox.wompi.co/v1", + } + + STATUS_MAP = { + "APPROVED": SubscriptionStatus.ACTIVE, + "PENDING": SubscriptionStatus.PAST_DUE, + "VOIDED": SubscriptionStatus.CANCELLED, + "DECLINED": SubscriptionStatus.PAST_DUE, + "ERROR": SubscriptionStatus.PAST_DUE, + } + + def __init__(self, config: dict | None = None): + config = {str(key).lower(): value for key, value in (config or {}).items()} + self.public_key = config.get("public_key") + self.secret_key = config.get("secret_key") + self.webhook_secret = config.get("webhook_secret") + environment = config.get("environment", "sandbox") + self.base_url = config.get("base_url") or self.BASE_URLS.get(environment, self.BASE_URLS["sandbox"]) + self.timeout = config.get("timeout") or 30 + + def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + if not self.public_key: + raise ValueError("Wompi public key not configured") + payload = { + "name": plan.name, + "amount_in_cents": int(round(float(plan.price_amount) * 100)), + "currency": plan.price_currency.lower(), + "single_use": True, + "redirect_url": kwargs.get("success_url") or "https://example.com/success", + "customer_email": getattr(user, "email", None) or None, + } + try: + response = requests.post( + f"{self.base_url}/payment_links", + headers={"Authorization": f"Bearer {self.public_key}"}, + json=payload, + timeout=self.timeout, + ) + except requests.RequestException as exc: + raise ValueError(f"Wompi request failed: {exc}") from exc + if response.status_code >= 400: + raise ValueError(f"Wompi error {response.status_code}: {response.text[:500]}") + data = response.json().get("data") or {} + return CheckoutSessionResult( + session_id=data.get("id", ""), + checkout_url=data.get("url", ""), + client_secret="", + ) + + def handle_webhook(self, payload, headers) -> WebhookResult: + event_type = payload.get("event") or "transaction.updated" + data = payload.get("data") or payload + is_handled = True + if self.webhook_secret: + signature = headers.get("x-signature") or headers.get("X-Signature") or "" + raw_body = json.dumps(payload, separators=(",", ":")) + expected = hmac.new(self.webhook_secret.encode(), raw_body.encode(), hashlib.sha256).hexdigest() + is_handled = hmac.compare_digest(signature, expected) + return WebhookResult(event_type=event_type, is_handled=is_handled, data=data) + + def cancel_subscription(self, subscription) -> bool: + if not self.secret_key or not subscription.external_id: + return False + try: + response = requests.post( + f"{self.base_url}/transactions/{subscription.external_id}/void", + headers={"Authorization": f"Bearer {self.secret_key}"}, + timeout=self.timeout, + ) + except requests.RequestException: + logger.exception("Wompi void request failed for %s", subscription.external_id) + return False + return response.status_code == 200 + + def sync_subscription(self, subscription) -> dict: + if not self.public_key or not subscription.external_id: + return {} + try: + response = requests.get( + f"{self.base_url}/transactions/{subscription.external_id}", + headers={"Authorization": f"Bearer {self.public_key}"}, + timeout=self.timeout, + ) + except requests.RequestException: + logger.exception("Wompi transaction request failed for %s", subscription.external_id) + return {} + if response.status_code != 200: + return {} + data: dict = response.json().get("data") or {} + status: str | None = data.get("status") + return { + "status": self.STATUS_MAP.get(status or "", status), + "metadata": data.get("metadata") or {}, + } + + def get_invoice(self, invoice) -> dict: + if not self.public_key or not invoice.external_id: + return {} + try: + response = requests.get( + f"{self.base_url}/transactions/{invoice.external_id}", + headers={"Authorization": f"Bearer {self.public_key}"}, + timeout=self.timeout, + ) + except requests.RequestException: + logger.exception("Wompi transaction request failed for %s", invoice.external_id) + return {} + if response.status_code != 200: + return {} + data = response.json().get("data") or {} + return { + "external_id": data.get("id"), + "status": data.get("status"), + "amount": (data.get("amount_in_cents") or 0) / 100, + "currency": (data.get("currency") or "cop").upper(), + "payment_method": data.get("payment_method", {}).get("type", ""), + } + + def health_check(self) -> bool: + return bool(self.public_key) diff --git a/infrasynth/billing/invoice_generator.py b/infrasynth/billing/invoice_generator.py new file mode 100644 index 0000000..4880bf5 --- /dev/null +++ b/infrasynth/billing/invoice_generator.py @@ -0,0 +1,138 @@ +import io +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task( + name="infrasynth.billing.generate_invoice_pdf", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def generate_invoice_pdf(self, invoice_id): + """Generates a PDF for an invoice and stores it via the files service.""" + from .models import Invoice + + try: + invoice = Invoice.objects.select_related("user", "subscription", "subscription__plan", "gateway").get( + pk=invoice_id + ) + except Invoice.DoesNotExist: + logger.warning("Invoice %s not found", invoice_id) + return None + + try: + pdf_bytes = _build_invoice_pdf(invoice) + except Exception as exc: # noqa: BLE001 + logger.exception("PDF generation failed for invoice %s", invoice_id) + raise self.retry(exc=exc) from exc + + from django.core.files.base import ContentFile + + from infrasynth.files.services import FileService + + filename = f"invoice_{invoice.invoice_number}.pdf" + content = ContentFile(pdf_bytes, name=filename) + content.content_type = "application/pdf" + stored = FileService().upload( + content, + filename=filename, + user=invoice.user, + metadata={"invoice_id": invoice.id, "invoice_number": invoice.invoice_number}, + ) + + invoice.pdf_file = stored + invoice.save(update_fields=["pdf_file"]) + return invoice.id + + +def _build_invoice_pdf(invoice) -> bytes: + from reportlab.lib import colors + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet + from reportlab.lib.units import inch + from reportlab.platypus import ( + Paragraph, + SimpleDocTemplate, + Spacer, + Table, + TableStyle, + ) + + styles = getSampleStyleSheet() + title_style = ParagraphStyle("InvoiceTitle", parent=styles["Title"], textColor=colors.HexColor("#1a3a5c")) + body_style = ParagraphStyle("InvoiceBody", parent=styles["BodyText"], fontSize=10, leading=14) + + buffer = io.BytesIO() + doc = SimpleDocTemplate( + buffer, + pagesize=letter, + rightMargin=0.75 * inch, + leftMargin=0.75 * inch, + topMargin=0.75 * inch, + bottomMargin=0.75 * inch, + ) + + story = [ + Paragraph("Factura", title_style), + Paragraph( + f"Nº: {invoice.invoice_number}
" + f"Vencimiento: {invoice.due_date.date() if invoice.due_date else '-'}", + body_style, + ), + Spacer(1, 12), + Paragraph("Cliente", body_style), + Paragraph( + f"{invoice.user.get_full_name() or invoice.user.username}
{invoice.user.email}", + body_style, + ), + Spacer(1, 12), + ] + + table_data = [["Descripción", "Cantidad", "Monto"]] + for item in invoice.line_items: + table_data.append( + [ + item.get("description", ""), + str(item.get("quantity", 1)), + f"{item.get('amount', 0)} {invoice.currency}", + ] + ) + + items_table = Table(table_data, colWidths=[3.5 * inch, 1 * inch, 1.5 * inch]) + items_table.setStyle( + TableStyle( + [ + ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a3a5c")), + ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), + ("GRID", (0, 0), (-1, -1), 0.5, colors.grey), + ("FONTSIZE", (0, 0), (-1, -1), 9), + ("ALIGN", (1, 0), (-1, -1), "RIGHT"), + ] + ) + ) + story.append(items_table) + story.append(Spacer(1, 12)) + + totals = [ + f"Subtotal: {invoice.amount} {invoice.currency}", + ] + if invoice.tax_name: + totals.append(f"{invoice.tax_name}: {invoice.tax_amount} {invoice.currency}") + total = invoice.amount + invoice.tax_amount + totals.append(f"Total: {total} {invoice.currency}") + + totals_style = ParagraphStyle( + "InvoiceTotals", + parent=body_style, + alignment=2, + spaceAfter=4, + ) + for line in totals: + story.append(Paragraph(line, totals_style)) + + doc.build(story) + return buffer.getvalue() diff --git a/infrasynth/billing/migrations/0001_initial.py b/infrasynth/billing/migrations/0001_initial.py new file mode 100644 index 0000000..2092b0b --- /dev/null +++ b/infrasynth/billing/migrations/0001_initial.py @@ -0,0 +1,199 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import infrasynth.shared.enums + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + ("infrasynth_files", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="PaymentGateway", + fields=[ + ("slug", models.SlugField(max_length=100, primary_key=True, serialize=False)), + ("display_name", models.CharField(max_length=200)), + ("gateway_class", models.CharField(max_length=500)), + ("config", models.JSONField(default=dict)), + ("is_active", models.BooleanField(default=True)), + ("supported_currencies", models.JSONField(default=list)), + ("webhook_secret", models.CharField(blank=True, max_length=500)), + ], + options={ + "db_table": "billing_gateway", + }, + ), + migrations.CreateModel( + name="Invoice", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(blank=True, max_length=255)), + ("invoice_number", models.CharField(max_length=100, unique=True)), + ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("currency", models.CharField(default="USD", max_length=3)), + ("tax_amount", models.DecimalField(decimal_places=2, default=0, max_digits=12)), + ("tax_name", models.CharField(blank=True, max_length=100)), + ("status", models.CharField(choices=infrasynth.shared.enums.InvoiceStatus.choices, max_length=20)), + ("due_date", models.DateTimeField(blank=True, null=True)), + ("paid_at", models.DateTimeField(blank=True, null=True)), + ("line_items", models.JSONField(default=list)), + ("metadata", models.JSONField(default=dict)), + ( + "pdf_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_files.storedfile", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ], + options={ + "db_table": "billing_invoice", + }, + ), + migrations.CreateModel( + name="BillingPlan", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100, unique=True)), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("price_amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("price_currency", models.CharField(default="USD", max_length=3)), + ("interval", models.CharField(choices=infrasynth.shared.enums.BillingInterval.choices, max_length=20)), + ("trial_days", models.IntegerField(default=0)), + ("features", models.JSONField(default=list)), + ("is_active", models.BooleanField(default=True)), + ("external_id", models.CharField(blank=True, max_length=255)), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ], + options={ + "db_table": "billing_plan", + }, + ), + migrations.CreateModel( + name="PaymentTransaction", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(blank=True, max_length=255)), + ("amount", models.DecimalField(decimal_places=2, max_digits=12)), + ("currency", models.CharField(default="USD", max_length=3)), + ("status", models.CharField(blank=True, max_length=50)), + ("payment_method", models.CharField(blank=True, max_length=100)), + ("metadata", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ( + "invoice", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.invoice", + ), + ), + ], + options={ + "db_table": "billing_transaction", + }, + ), + migrations.CreateModel( + name="Subscription", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("external_id", models.CharField(blank=True, max_length=255)), + ("status", models.CharField(choices=infrasynth.shared.enums.SubscriptionStatus.choices, max_length=20)), + ("current_period_start", models.DateTimeField(blank=True, null=True)), + ("current_period_end", models.DateTimeField(blank=True, null=True)), + ("cancel_at_period_end", models.BooleanField(default=False)), + ("cancelled_at", models.DateTimeField(blank=True, null=True)), + ("trial_end", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(default=dict)), + ( + "gateway", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.paymentgateway", + ), + ), + ( + "plan", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.billingplan", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "billing_subscription", + }, + ), + migrations.AddField( + model_name="invoice", + name="subscription", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_billing.subscription", + ), + ), + ] diff --git a/infrasynth/billing/migrations/__init__.py b/infrasynth/billing/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7a5f2da4c55d55ea7e7cb4aec8d00d68ce5fc6a5 GIT binary patch literal 7962 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 X=la@ab$#noiGd<9@NWxlX^Q>}R+7Pz literal 0 HcmV?d00001 diff --git a/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9332614103d60984f56aeac54dd975498e6f15f1 GIT binary patch literal 187 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u766``MzpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&7nUj;5m#&|i znO>Awl9``ZtREkrnU`4-AFo$Xd5gm)H$SB`C)KWq6=)I2Da9bhCuT-Q#v*1Q3jibI BF%|#- literal 0 HcmV?d00001 diff --git a/infrasynth/billing/models.py b/infrasynth/billing/models.py new file mode 100644 index 0000000..90d0b3d --- /dev/null +++ b/infrasynth/billing/models.py @@ -0,0 +1,158 @@ +from django.conf import settings +from django.db import models + +from infrasynth.shared.enums import BillingInterval, InvoiceStatus, SubscriptionStatus + + +class PaymentGateway(models.Model): + slug = models.SlugField(max_length=100, primary_key=True) + display_name = models.CharField(max_length=200) + gateway_class = models.CharField(max_length=500) + config = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + supported_currencies = models.JSONField(default=list) + webhook_secret = models.CharField(max_length=500, blank=True) + + class Meta: + db_table = "billing_gateway" + + def __str__(self): + return self.display_name + + +class BillingPlan(models.Model): + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + price_amount = models.DecimalField(max_digits=12, decimal_places=2) + price_currency = models.CharField(max_length=3, default="USD") + interval = models.CharField(max_length=20, choices=BillingInterval.choices) + trial_days = models.IntegerField(default=0) + features = models.JSONField(default=list) + is_active = models.BooleanField(default=True) + gateway = models.ForeignKey( + PaymentGateway, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + external_id = models.CharField(max_length=255, blank=True) + + class Meta: + db_table = "billing_plan" + + def __str__(self): + return self.name + + +class Subscription(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", + ) + plan = models.ForeignKey( + BillingPlan, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + gateway = models.ForeignKey( + PaymentGateway, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + external_id = models.CharField(max_length=255, blank=True) + status = models.CharField(max_length=20, choices=SubscriptionStatus.choices) + current_period_start = models.DateTimeField(null=True, blank=True) + current_period_end = models.DateTimeField(null=True, blank=True) + cancel_at_period_end = models.BooleanField(default=False) + cancelled_at = models.DateTimeField(null=True, blank=True) + trial_end = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_subscription" + + def __str__(self): + return f"{self.user}#{self.plan}" + + +class Invoice(models.Model): + subscription = models.ForeignKey( + Subscription, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="+", + ) + gateway = models.ForeignKey( + PaymentGateway, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + external_id = models.CharField(max_length=255, blank=True) + invoice_number = models.CharField(max_length=100, unique=True) + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, default="USD") + tax_amount = models.DecimalField(max_digits=12, decimal_places=2, default=0) + tax_name = models.CharField(max_length=100, blank=True) + status = models.CharField(max_length=20, choices=InvoiceStatus.choices) + due_date = models.DateTimeField(null=True, blank=True) + paid_at = models.DateTimeField(null=True, blank=True) + line_items = models.JSONField(default=list) + pdf_file = models.ForeignKey( + "infrasynth_files.StoredFile", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "billing_invoice" + + def __str__(self): + return self.invoice_number + + +class PaymentTransaction(models.Model): + invoice = models.ForeignKey( + Invoice, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + gateway = models.ForeignKey( + PaymentGateway, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + external_id = models.CharField(max_length=255, blank=True) + amount = models.DecimalField(max_digits=12, decimal_places=2) + currency = models.CharField(max_length=3, default="USD") + status = models.CharField(max_length=50, blank=True) + payment_method = models.CharField(max_length=100, blank=True) + metadata = models.JSONField(default=dict) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "billing_transaction" + + def __str__(self): + return f"txn_{self.pk}" diff --git a/infrasynth/billing/serializers.py b/infrasynth/billing/serializers.py new file mode 100644 index 0000000..e96f3da --- /dev/null +++ b/infrasynth/billing/serializers.py @@ -0,0 +1,185 @@ +from rest_framework import serializers + +from .models import ( + BillingPlan, + Invoice, + PaymentGateway, + PaymentTransaction, + Subscription, +) + + +class PaymentGatewaySerializer(serializers.ModelSerializer): + class Meta: + model = PaymentGateway + fields = [ + "slug", + "display_name", + "gateway_class", + "config", + "is_active", + "supported_currencies", + "webhook_secret", + ] + read_only_fields = ["slug"] + + +class BillingPlanSerializer(serializers.ModelSerializer): + gateway_info = serializers.SerializerMethodField() + + class Meta: + model = BillingPlan + fields = [ + "id", + "slug", + "name", + "description", + "price_amount", + "price_currency", + "interval", + "trial_days", + "features", + "is_active", + "gateway", + "gateway_info", + "external_id", + ] + read_only_fields = ["id"] + + def get_gateway_info(self, obj): + if obj.gateway_id: + return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} + return None + + +class SubscriptionSerializer(serializers.ModelSerializer): + plan_info = serializers.SerializerMethodField() + gateway_info = serializers.SerializerMethodField() + user_info = serializers.SerializerMethodField() + + class Meta: + model = Subscription + fields = [ + "id", + "user", + "user_info", + "plan", + "plan_info", + "gateway", + "gateway_info", + "external_id", + "status", + "current_period_start", + "current_period_end", + "cancel_at_period_end", + "cancelled_at", + "trial_end", + "metadata", + ] + read_only_fields = ["id"] + + def get_plan_info(self, obj): + if obj.plan_id: + return {"id": obj.plan_id, "name": obj.plan.name, "slug": obj.plan.slug} + return None + + def get_gateway_info(self, obj): + if obj.gateway_id: + return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} + return None + + def get_user_info(self, obj): + return {"id": obj.user_id, "display": str(obj.user)} + + +class InvoiceSerializer(serializers.ModelSerializer): + subscription_info = serializers.SerializerMethodField() + gateway_info = serializers.SerializerMethodField() + user_info = serializers.SerializerMethodField() + pdf_file_info = serializers.SerializerMethodField() + + class Meta: + model = Invoice + fields = [ + "id", + "subscription", + "subscription_info", + "user", + "user_info", + "gateway", + "gateway_info", + "external_id", + "invoice_number", + "amount", + "currency", + "tax_amount", + "tax_name", + "status", + "due_date", + "paid_at", + "line_items", + "pdf_file", + "pdf_file_info", + "metadata", + ] + read_only_fields = ["id"] + + def get_subscription_info(self, obj): + if obj.subscription_id: + return {"id": obj.subscription_id} + return None + + def get_gateway_info(self, obj): + if obj.gateway_id: + return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} + return None + + def get_user_info(self, obj): + return {"id": obj.user_id, "display": str(obj.user)} + + def get_pdf_file_info(self, obj): + if obj.pdf_file_id: + return {"id": obj.pdf_file_id, "filename": obj.pdf_file.original_filename} + return None + + +class PaymentTransactionSerializer(serializers.ModelSerializer): + invoice_info = serializers.SerializerMethodField() + gateway_info = serializers.SerializerMethodField() + + class Meta: + model = PaymentTransaction + fields = [ + "id", + "invoice", + "invoice_info", + "gateway", + "gateway_info", + "external_id", + "amount", + "currency", + "status", + "payment_method", + "metadata", + "created_at", + ] + read_only_fields = ["id", "created_at"] + + def get_invoice_info(self, obj): + if obj.invoice_id: + return { + "id": obj.invoice_id, + "invoice_number": obj.invoice.invoice_number, + } + return None + + def get_gateway_info(self, obj): + if obj.gateway_id: + return {"slug": obj.gateway_id, "display_name": obj.gateway.display_name} + return None + + +class SubscribeSerializer(serializers.Serializer): + plan_slug = serializers.SlugField() + success_url = serializers.URLField(required=False) + cancel_url = serializers.URLField(required=False) diff --git a/infrasynth/billing/services.py b/infrasynth/billing/services.py new file mode 100644 index 0000000..f6d13e2 --- /dev/null +++ b/infrasynth/billing/services.py @@ -0,0 +1,181 @@ +import logging +from datetime import timedelta +from decimal import Decimal + +from django.utils import timezone +from django.utils.module_loading import import_string + +from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus +from infrasynth.shared.settings_utils import get_setting + +from .models import BillingPlan, Invoice, PaymentGateway, Subscription +from .signals import ( + invoice_generated, + subscription_cancelled, + subscription_created, +) + +logger = logging.getLogger(__name__) + + +class BillingService: + """Public API for subscriptions, checkouts, and invoices.""" + + def create_checkout_session( + self, + plan_slug: str, + user, + success_url: str | None = None, + cancel_url: str | None = None, + ): + """Creates a checkout session in the plan's payment gateway.""" + plan = BillingPlan.objects.select_related("gateway").get(slug=plan_slug, is_active=True) + gateway = plan.gateway or PaymentGateway.objects.filter(is_active=True).first() + if gateway is None: + raise ValueError("No active payment gateway configured for this plan.") + + gateway_instance = self._get_gateway(gateway) + result = gateway_instance.create_checkout_session( + plan, + user, + success_url=success_url, + cancel_url=cancel_url, + ) + return result, gateway + + def create_subscription( + self, + plan: BillingPlan, + user, + gateway: PaymentGateway, + external_id: str = "", + ) -> Subscription: + """Creates a subscription record after checkout confirmation.""" + now = timezone.now() + interval_days = 365 if plan.interval == "yearly" else 30 + trial_end = now + timedelta(days=plan.trial_days) if plan.trial_days else None + + subscription = Subscription.objects.create( + user=user, + plan=plan, + gateway=gateway, + external_id=external_id, + status=(SubscriptionStatus.TRIALING if trial_end and trial_end > now else SubscriptionStatus.ACTIVE), + current_period_start=now, + current_period_end=now + timedelta(days=interval_days), + trial_end=trial_end, + ) + + subscription_created.send( + sender=Subscription, + user=user, + plan_slug=plan.slug, + gateway=gateway.slug if gateway else "", + external_id=external_id, + ) + return subscription + + def cancel_subscription(self, subscription: Subscription) -> bool: + """Cancels a subscription in the gateway and locally.""" + gateway = subscription.gateway + if gateway: + try: + gateway_instance = self._get_gateway(gateway) + gateway_instance.cancel_subscription(subscription) + except Exception: # noqa: BLE001 + logger.exception("Gateway cancellation failed for subscription %s", subscription.pk) + + subscription.status = SubscriptionStatus.CANCELLED + subscription.cancelled_at = timezone.now() + subscription.cancel_at_period_end = False + subscription.save() + + subscription_cancelled.send( + sender=Subscription, + user=subscription.user, + plan_slug=subscription.plan.slug if subscription.plan else "", + reason="user_requested", + ) + return True + + def sync_subscription(self, subscription: Subscription) -> dict: + """Syncs subscription state from the gateway into the local record.""" + gateway = subscription.gateway + if gateway is None: + return {} + gateway_instance = self._get_gateway(gateway) + data = gateway_instance.sync_subscription(subscription) + for field in ( + "status", + "current_period_start", + "current_period_end", + "cancel_at_period_end", + "cancelled_at", + "trial_end", + "metadata", + ): + if field in data and data[field] is not None: + setattr(subscription, field, data[field]) + subscription.save() + return data + + def generate_invoice( + self, + subscription: Subscription, + amount=None, + line_items: list | None = None, + ) -> Invoice: + """Creates an invoice record and triggers PDF generation.""" + plan = subscription.plan + currency = plan.price_currency if plan else get_setting("INFRASYNTH_BILLING", "DEFAULT_CURRENCY", "USD") + if amount is None: + amount = plan.price_amount if plan else 0 + amount = Decimal(str(amount)) + + tax_percentage = get_setting("INFRASYNTH_BILLING", "TAX_PERCENTAGE", 0) + tax_name = get_setting("INFRASYNTH_BILLING", "TAX_NAME", "") + tax_amount = amount * (Decimal(tax_percentage) / Decimal(100)) + + invoice = Invoice.objects.create( + subscription=subscription, + user=subscription.user, + gateway=subscription.gateway, + invoice_number=self._next_invoice_number(), + amount=amount, + currency=currency, + tax_amount=tax_amount, + tax_name=tax_name, + status=InvoiceStatus.OPEN, + due_date=timezone.now() + timedelta(days=get_setting("INFRASYNTH_BILLING", "GRACE_PERIOD_DAYS", 5)), + line_items=line_items + or [ + { + "description": plan.name if plan else "Subscription", + "amount": float(amount), + "quantity": 1, + } + ], + ) + + invoice_generated.send( + sender=Invoice, + user=subscription.user, + invoice_id=invoice.id, + amount=float(invoice.amount), + ) + + from .invoice_generator import generate_invoice_pdf + + generate_invoice_pdf.delay(invoice.id) + return invoice + + def _next_invoice_number(self) -> str: + prefix = get_setting("INFRASYNTH_BILLING", "INVOICE_NUMBER_PREFIX", "INV-") + year = timezone.now().year + full_prefix = f"{prefix}{year}-" + count = Invoice.objects.filter(invoice_number__startswith=full_prefix).count() + return f"{full_prefix}{count + 1:06d}" + + def _get_gateway(self, gateway: PaymentGateway): + gateway_cls = import_string(gateway.gateway_class) + return gateway_cls(gateway.config) diff --git a/infrasynth/billing/signals.py b/infrasynth/billing/signals.py new file mode 100644 index 0000000..27a22bc --- /dev/null +++ b/infrasynth/billing/signals.py @@ -0,0 +1,9 @@ +from django.dispatch import Signal + +subscription_created = Signal() +subscription_cancelled = Signal() +subscription_renewed = Signal() +payment_succeeded = Signal() +payment_failed = Signal() +invoice_generated = Signal() +invoice_paid = Signal() diff --git a/infrasynth/billing/urls.py b/infrasynth/billing/urls.py new file mode 100644 index 0000000..282cfc0 --- /dev/null +++ b/infrasynth/billing/urls.py @@ -0,0 +1,27 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + BillingPlanViewSet, + InvoiceViewSet, + PaymentGatewayViewSet, + PaymentTransactionViewSet, + SubscriptionViewSet, + WebhookViewSet, +) + +router = DefaultRouter() +router.register(r"gateways", PaymentGatewayViewSet, basename="billing-gateways") +router.register(r"plans", BillingPlanViewSet, basename="billing-plans") +router.register(r"subscriptions", SubscriptionViewSet, basename="billing-subscriptions") +router.register(r"invoices", InvoiceViewSet, basename="billing-invoices") +router.register(r"transactions", PaymentTransactionViewSet, basename="billing-transactions") + +urlpatterns = [ + path("", include(router.urls)), + path( + "webhook/receive/", + WebhookViewSet.as_view({"post": "receive"}), + name="billing-webhook-receive", + ), +] diff --git a/infrasynth/billing/views.py b/infrasynth/billing/views.py new file mode 100644 index 0000000..be64203 --- /dev/null +++ b/infrasynth/billing/views.py @@ -0,0 +1,188 @@ +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from .filters import ( + BillingPlanFilter, + InvoiceFilter, + PaymentGatewayFilter, + PaymentTransactionFilter, + SubscriptionFilter, +) +from .models import ( + BillingPlan, + Invoice, + PaymentGateway, + PaymentTransaction, + Subscription, +) +from .serializers import ( + BillingPlanSerializer, + InvoiceSerializer, + PaymentGatewaySerializer, + PaymentTransactionSerializer, + SubscribeSerializer, + SubscriptionSerializer, +) + + +class PaymentGatewayViewSet(viewsets.ModelViewSet): + queryset = PaymentGateway.objects.all() + serializer_class = PaymentGatewaySerializer + permission_classes = [IsAuthenticated] + filterset_class = PaymentGatewayFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("billing", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return PaymentGateway.objects.all() + + +class BillingPlanViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet): + queryset = BillingPlan.objects.filter(is_active=True) + serializer_class = BillingPlanSerializer + filterset_class = BillingPlanFilter + + def get_permissions(self): + if self.action in ("list", "retrieve"): + return [AllowAny()] + return [IsAuthenticated()] + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("billing", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return BillingPlan.objects.filter(is_active=True).select_related("gateway") + + +class SubscriptionViewSet(viewsets.ModelViewSet): + queryset = Subscription.objects.all() + serializer_class = SubscriptionSerializer + permission_classes = [IsAuthenticated] + filterset_class = SubscriptionFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("billing", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return Subscription.objects.select_related("plan", "gateway", "user").all() + + @action(detail=False, methods=["post"]) + def subscribe(self, request): + ser = SubscribeSerializer(data=request.data) + ser.is_valid(raise_exception=True) + from .services import BillingService + + try: + result, gateway = BillingService().create_checkout_session( + ser.validated_data["plan_slug"], + request.user, + success_url=ser.validated_data.get("success_url"), + cancel_url=ser.validated_data.get("cancel_url"), + ) + except ValueError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST) + return Response( + { + "checkout_url": result.checkout_url, + "session_id": result.session_id, + "client_secret": result.client_secret, + "gateway": gateway.slug, + "plan_slug": ser.validated_data["plan_slug"], + } + ) + + +class InvoiceViewSet(viewsets.ModelViewSet): + queryset = Invoice.objects.all() + serializer_class = InvoiceSerializer + permission_classes = [IsAuthenticated] + filterset_class = InvoiceFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("billing", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return Invoice.objects.select_related("subscription", "gateway", "user", "pdf_file").all() + + +class PaymentTransactionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = PaymentTransaction.objects.all() + serializer_class = PaymentTransactionSerializer + permission_classes = [IsAuthenticated] + filterset_class = PaymentTransactionFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("billing", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return PaymentTransaction.objects.select_related("invoice", "gateway").all() + + +class WebhookViewSet(viewsets.GenericViewSet): + permission_classes = [AllowAny] + + @action(detail=False, methods=["post"]) + def receive(self, request): + gateway_slug = request.data.get("gateway_slug") or request.query_params.get("gateway_slug") + gateway = ( + PaymentGateway.objects.filter(slug=gateway_slug, is_active=True).first() + if gateway_slug + else PaymentGateway.objects.filter(is_active=True).first() + ) + if gateway is None: + return Response( + {"detail": "No active payment gateway found."}, + status=status.HTTP_404_NOT_FOUND, + ) + + from django.utils.module_loading import import_string + + try: + gateway_cls = import_string(gateway.gateway_class) + gateway_instance = gateway_cls(gateway.config) + result = gateway_instance.handle_webhook(payload=request.data, headers=dict(request.headers)) + except Exception as exc: # noqa: BLE001 + return Response( + {"detail": f"Webhook processing failed: {exc}"}, + status=status.HTTP_400_BAD_REQUEST, + ) + + if not result.is_handled: + return Response( + {"status": "ignored", "event_type": result.event_type}, + status=status.HTTP_200_OK, + ) + return Response({"status": "processed", "event_type": result.event_type, "data": result.data}) diff --git a/infrasynth/features/__init__.py b/infrasynth/features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/features/__pycache__/__init__.cpython-314.pyc b/infrasynth/features/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..723ebcefdafc249b4042ff3fdb9d4bb4b91f58e9 GIT binary patch literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9npjd=lv=DG rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{UI$J literal 0 HcmV?d00001 diff --git a/infrasynth/features/__pycache__/apps.cpython-314.pyc b/infrasynth/features/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..771a9f37fc3ffcde4a6cf37eb3cb5698488dd6e0 GIT binary patch literal 1028 zcmYjQ&1(}u6rW9Ylg&q4YTKk9N;e8>kR~949;8@5>c^p!K?M)(Fxj0=x83Zu9c|AhaIo?HeBqNiS@5UJkuO?DSMuy5YH@85fGZ_m$F5uCrT!^58jLO=8)KUT&} zZvitw`v@B^&~rl?>u3eF5H2(kF76q3i>aL~VDma^TW9*&HdJM6ID8yOe$aPQgOoy+ z*_qyfi$dHzBn^yYVc(R+28tnzG;u*%xF}25Y$934mbBj>S-~ZUUsm(mjLH#bB8`5s z<{i?gA9uW7XApZV6rD#we`_S;?SO^e$x;ycoQiQI4?2G4sV)ggE_CzKbbU5_0L-3l z0t4WU6#NjtzcF_6)@aM@-~ujU6I-~1%l9(I)TTshN`p(P7h<(TyYA^BL@dG3?B zOyz8cv@b>*;E+pvkT_y2BpWz^6fE?e?~{*C#Euzvyg<-i$h=pol$x|nWkz(sRLx_; z<-t$}aiq!~^XVv*%66$_{g{t;+C^patPoy-eEE~{c0rjs&rB=|pu6Fym)(PSz`BE& z(_?zn-R1EibEWA1Z|_Er3fApW_lQN_C3!bT*`>pw=nTio;*5G@wVXlBCfS)ubt&Q| zkO?}qH@?};ueS5Wc9MD{S#*-sXUXzvva)twLzUJADpjgeFh!rxPP?jXLNs(jRFx3e zg;A*aIw9{yG|V&$g!lm$G7KUX#h|{DwT4hw8PLJb@H&K4R!Dm+W~h@W`$39)lV5|U&Mq{B^P*uG Tr>OO-G;dTtyuCnh&Ak2rmNFe~ literal 0 HcmV?d00001 diff --git a/infrasynth/features/__pycache__/decorators.cpython-314.pyc b/infrasynth/features/__pycache__/decorators.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bfb62bdb4a62454b891135090ffd1167a6710f4b GIT binary patch literal 1330 zcma)6&2Jk;6o0e3UfaR3T$~W4M9qd&Mec|9kl-SbL(jubhCa&Q{ zWCN(eJ>%U^0UD^Afe%@md29<$PRV5$%XmsQk)`~83!R%vOWf>cD7#!M^7~e_;kLuB zaJ!-8dwkd3kl~J~Yvp!=rsV1%(Cus6TnTrZ*LOwGctP(rPjgjnL)6h+hfFZI@VL?itPL^D^jI~n z>ywq6qmT|(Euen{B4%a5~C})d%by+l8uegIR#{U`AG73AtWk z@^v&TS<=QW8tbT9f=rZd32dB}lJY3oQJU}+N=Fu#Efv|j`|)lxmCTZDmY}aS`$1ii zuS=J&I)j1C(NUTPyeDL|m~^6+^rlf>^20j!)k^tna{f~#R^&q6ME3Up{x~;#d~WXW z#xKRmua>`9elYRf?eA~@Q2p_vjV%B*hwPsLJkI715010b z&kQSjeQ3hi`4f^(a!{B$%*Po##3vaCl`~{W4Q)E7U=?1CCTWD?hf_cSL3n0oQd1`r zkefWRCSrH%rF4`f=ye$SN?Ld?nwHqC8a~~w*bn7yS$td3Zd6?ZhsVE^gQqT<%a uLF5HAJVhw(TV|;nHu}C;k@I*!(;vUx6O%Fa8@%}h%1_|pa~q#p%Pl1P4;KCMB`}Lr_#2DM?8J6!JsJ0_h=jR%`EI7S_9Db`~X^ zd}-A~4$Ud)sW~QxN@*{>_SmEATCz1#PCfM&3ECdpH@h}9iBZ?u=QlIozIpG>d*6)r zMnfDt|7_^nKl(ZDcQ$B0Fm|2;41X2w0Vnax+!9at5vF^VJxgBV9pU=94>`$uij#bK z@2b%HrsMB|*BubQgY*M3@IT0a)HBLug+JJbS)K|OB1AJv7U+SpsUqDfc5J`}T7!cp zJaaV95nkem=YgMi^S)Ilz)m;^4nI&OeiqQ|Apt=3x_YMs)Gr+<0Z_@#bytVrJ(Bmx zRtQi7T~R*Ce}swv>U1|0_yxhwdfYYA3%C!u;b2dXBuK&8c5>r@%ydKMlTxU|VhV8G zW1RtuVJUKCvF}8S{T-1EIENaAL+!oL_WG0*BZE>Ljxa&Pi%20hMvJ;qg5wL7l}ECU zj1t*ehoQfqE5^4@^&}`dZ^FR?Gg>TG0B?xs)q`) zY$#Ll3}l$FET{#UCtWl!0eTE*rzDiUi-Gd8heJvPYJ zWI?geR6!~3AVck#pK3=0AG5a2?LMYH7AuZ|;W)e&+vY2-naX@EG>4|Er_Vo$Vb&UW zmJ&jjl*>;plAYmlEs4e2Uo=4kNxmV%uzoH}HQ2G^lh+=;M?tRmKS z*{RdWHPh6QVz^5|&5{);hdo5otEQzfZLJK|w`>qpLM@9)Z0fpc6L}ZmA~K+8!2ZU@ z^1SOn!g1in+MSh_1G;7r8bQXMhE1akA6Qf#{sapFz0z*x|3 z4u+V<=*MtGR^i1a#C+5=Q798qaZ3X-@K0>TK>R0m<)*+Up>DwLnu(tsghV93yBh#(rlNQX*O z9jaKT{CL)fr(q`?X2M~x^PugA6q{`29HKjRJS{Hg4%nsA!Di>$Y$o}0o(?_>(7`V5 zxxAKWvw+-ow$;}_Z;a`vk5N1@{Kv2t^_LjyVs_QO+X+sKuza0^*J2399Ak_*#;cQ8 zs;58gLLP~e$s@FAVx*Ofo9MCAV6^A@)wSza=NBALFuPl6?CQq)lDx6DAj!Ay%rD%+ zvoMQ4VS=TGhJZQX!U1MgPj2BHgEPU{%lZ;TtA&Sa;o;}^UoZZ&^218??iclNzSYZY zp((^0g7~W-Hj{(Ta!+&3XrkGhZ1xWxh69m6W&Uk17fm%HXKIl%O`)#~C>p3N00_7s zakiE?`&(kHa)UMI9M3v)j(1y=`L&Ymp<#7fF-m5xXacW0u?Jf-YQZI2|82(NE+IJ~ z9cL;kTyn3YD|ilUvJ26g1o76(^ZXl5e8UYN2BQ4TH)GExo=zNcpdN~S{2c#$?7N9? LC;sA??)daCbzoV= literal 0 HcmV?d00001 diff --git a/infrasynth/features/__pycache__/registry.cpython-314.pyc b/infrasynth/features/__pycache__/registry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3ee7df24f76c0a22001b0ccb180cdb8d3fe013d6 GIT binary patch literal 2874 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 literal 0 HcmV?d00001 diff --git a/infrasynth/features/__pycache__/urls.cpython-314.pyc b/infrasynth/features/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbfe8e1884ccd9473ab3847d8b9a9a1f77161e40 GIT binary patch literal 697 zcmZuvPiqrF6rb7uX&Qso_Ta$~MI@AFJp|f=2wEu{4_a1Cg%V|&og|xVcFW9cN_$Fs zsEU_*@-ukzL-+v#^&kTx;z7Js`T_Kf$=V9O!<#q1_kO>5Z)O%R6lM_EuP0IO5F_+W zZ8Ec_!RZGGM(7#xaUFSBU{4cTN$s_|?is>>82ftN^eka{wy;a6X_QfIN*7MkS-+Ht zo~^#ow0+aJmh}k+tpC^9P0M%wsnf~`*to11m(LWls0NcLt7((9ibdYfT}PFf9}1{w z(hlQRlm=89+eCCLSQhTnHc2DlC#j$eDz2P0DZG?2^F%@#|TrR`V zUZFiOPnOPA)LY#q%}; zjh%$`tL#sAoS72$O$O8* zB#aD6-(6=(kG2GN!x&EEyRqmlsqWn^((2PVIETB_0l2HgtJ}NMxjjgNG@^IdJV>j% z=TE_%=osTK=*DMs>pQypHMj7(G_pP+>qT+Dc%+Z#Z@za&#gFJ-2F;GIu8db33fuT; gAv5=K?$yRotxfAlZJgDz9>RvTO=rfW zL{-J9jnqqeYK|O>dPx2hTSNk_5~WfrRlSjdq8@taH)|U&B<-bh`1X6v>-RloC!>)d zg7GqH-}%{x(63x*52eNI{|(Frx`i|$hq3|_vLX|Qxh&?StjuJWmvX+WpZQ&0&IPgx zQ-BvVU#=?~WWj8RgUM8{vYzOpLR zQPwPcmtsa!g5x(#W>qV|sX6*VjVL zojFmDPr&07(z+7J77o`|wIGWX5bM?yz&`B6J(n&3?$FJApE0 z%heJjaE;jds=<~`FGe!Vb&<5)4Q?Ky+(u|sEQtA5Z23m*Q4sQc?y;evORW`FB~2(u z8DUkv8E88dW$!t8b6E7SLYY@pvFC$}S~?u)yz_)aX@ zh$VkYJQ~^>s>hPsv5WV8zl2Xb_-s!SW5N6Kfgi>CAIjfm7t~uZ^`Z^hvn?0LfKfY1#zr3x^)|FWobS)}5Q zqU#l-jCI`!>Uz0as@a?m>rh{Y-Lj~opi$jBo4$;+ObgOdv*s?^?`}H z-1ny}_);(Y$RB=iVe`V~*~i%@zRNE}zwh+Eg#5F6oO=;FG}ouEKJi`K7bV{nZl4!k za@IxJ-QR?*4@bLo?0*U>&$E(b(*J!@SXdLGElAFwrysW8$yNrDDcE))6(MQ(k_;#3 zIeCkdE=~@EN_ch={>YJcIN>2Ays423obZL3cjT>t@Va*i&u@Ab$hT-OjAF?hb)un8 z?5OF6n%-8=bFilm!G0q5HP_(N7gGIBlt5R~;YBFpyH&D6pvDoAfj9$4E$I?A;VEHN zLf!On29Mc0xz@_Q#4!gD-M6Y3-}ZNlK@wgPJYyFi=GHaySg zwbAA2=EOcE@L*xrFwg&eoai-+GLPK-5!{kg*FCD}$G8GTW42dv@jQyLqs)}u@|-1e zun$jl`Z}CHxB&0!j*^pI|J#)K>7fIaByA`CU@oF ej(oBqpLCglhCJXhLk)RoR}ME#FT23ejmZ{-PsP9$1 zG<+!}!1JF&{l#Wj5dO^K{)VlM2@VxoIUU`3dr;zBv>AW-{tjg zP5~PO?ED~XAtuLvp>+(91J4Z_$DgmtSNgP0d{S$?|4iACd{v&5r{v|lm~+X~Jq@4C zY2pv&bO!v82AyuL%Y+C>fVn;hQyAaR|CYZ$Hh{M}fY&q8w0k>Y9a$Qk(G0KSASJNw@=E~=#;z!b12V(PkJ-w z85`Qh3at~ott%Dg($sC{+TF@{SXil36O{<;VwDZmh*nj|33`c7SkS^mQ*`YVv+yw{ zMbpB_P^y@DEWKWV`4*}wUm+bE6?()xot+^STP32^_?!cPx2P+|OBU2Dq?D+3jDhD@ zaapPBl#LQo*UdUbHB3s_ph^Xwj^&qE^7*U;FX^tN=(&+=#{d}?#>TNm zOrr|st<$+LQ#E3$b@0?QQrD%F1wO&}P8B4RNM9Blk654tp{khVR(6;nl3sKNqj5mBY6V|JQmWHLR-^ z^P8P$TI_OP2KOKzL{l*fL1-ZmpjTy{j=rGT-Lp<-G^0!u>%^c(sAn|WB{(+YW9u}If96V z5Q1B^u49B)7@=2nMduX>s}baw&@}@aCNTV(p;0Jo!f!Sfv;LygYCM7vh#EFjY?Mw2 zAOCD*<#x+}-MmKRVcZ>X05?vncbZ zT8XmIwn?y7F+PXB^{Fouqhd0bT7_bp&5f-GEM!8%b-zU72tkokt%MXx ziB_yrY#~IJ;V>WIC$K{9$gJXA)g;QX@-nwi%on&st=vUrEf*CF=ZZ?{CB&|epX*gY z?&8ZQmxtt|Zuqmc6XFLOomT(|ONI;jZ$fK$^!3BGPYB*>1xA~Jg+^fE+dy;i-r3^4 z?*kicuRG>@{kR<#f|r|tL?e)BMHBC%@vo1Yg`fwkFB;_6vYodK}`N2NdFQRKl*k>(f4(z8A+c-(mx4&)zR`_N`4d- literal 0 HcmV?d00001 diff --git a/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b24df6710222dd28d14a077297cacb0dcf4cd1ff GIT binary patch literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9npjd=lv=Ex zo0(peSdy8aSF9f&pP83g5+AQuP dict[str, FeatureDefinition]: + return dict(cls._features) diff --git a/infrasynth/features/serializers.py b/infrasynth/features/serializers.py new file mode 100644 index 0000000..0306258 --- /dev/null +++ b/infrasynth/features/serializers.py @@ -0,0 +1,28 @@ +from rest_framework import serializers + +from .models import FeatureFlag, FeatureFlagOverride + + +class FeatureFlagSerializer(serializers.ModelSerializer): + class Meta: + model = FeatureFlag + fields = [ + "id", + "slug", + "name", + "description", + "is_active", + "rollout_percentage", + "tenant_id", + "environments", + "category", + "metadata", + ] + read_only_fields = ["id"] + + +class FeatureFlagOverrideSerializer(serializers.ModelSerializer): + class Meta: + model = FeatureFlagOverride + fields = ["id", "flag", "user", "group", "is_enabled"] + read_only_fields = ["id"] diff --git a/infrasynth/features/services.py b/infrasynth/features/services.py new file mode 100644 index 0000000..69ed614 --- /dev/null +++ b/infrasynth/features/services.py @@ -0,0 +1,89 @@ +from django.conf import settings +from django.core.cache import cache +from django.db.models import Q + +from .models import FeatureFlag, FeatureFlagOverride +from .registry import FeatureRegistry + + +class FeatureService: + """Evaluates feature flags with caching and multi-tenant support.""" + + def is_enabled( + self, + slug: str, + *, + user=None, + tenant_id: str | None = None, + default: bool | None = None, + ttl_seconds: int = 60, + ) -> bool: + config = getattr(settings, "INFRASYNTH_FEATURES", {}) + ttl_seconds = ttl_seconds or config.get("CACHE_TTL_SECONDS", 60) + + if user and user.is_authenticated: + override = self._get_user_override(slug, user) + if override is not None: + return override + + if user and user.is_authenticated: + override = self._get_group_override(slug, user) + if override is not None: + return override + + if tenant_id: + flag = self._get_flag(slug, tenant_id, ttl_seconds) + else: + flag = self._get_flag_global(slug, ttl_seconds) + + if flag: + return flag.is_active + + registry_default = FeatureRegistry.get_all().get(slug) + if registry_default: + return registry_default.default + + return default if default is not None else False + + def get_active_flags(self, *, user=None, tenant_id: str | None = None) -> dict[str, bool]: + all_slugs = set(FeatureRegistry.get_all().keys()) + db_flags = set( + FeatureFlag.objects.filter(Q(tenant_id=tenant_id) | Q(tenant_id__isnull=True)).values_list( + "slug", flat=True + ) + ) + all_slugs.update(db_flags) + return {slug: self.is_enabled(slug, user=user, tenant_id=tenant_id) for slug in sorted(all_slugs)} + + def _get_user_override(self, slug: str, user) -> bool | None: + override = FeatureFlagOverride.objects.filter(flag__slug=slug, user=user).first() + if override: + return override.is_enabled + return None + + def _get_group_override(self, slug: str, user) -> bool | None: + groups = list(user.groups.all()) + if not groups: + return None + override = FeatureFlagOverride.objects.filter(flag__slug=slug, group__in=groups).first() + if override: + return override.is_enabled + return None + + def _get_flag(self, slug: str, tenant_id: str, ttl_seconds: int) -> FeatureFlag | None: + cache_key = f"features:{slug}:{tenant_id}" + cached = cache.get(cache_key) + if cached is not None: + return cached if isinstance(cached, FeatureFlag) else None + flag = FeatureFlag.objects.filter(slug=slug, tenant_id=tenant_id).first() + cache.set(cache_key, flag, ttl_seconds) + return flag + + def _get_flag_global(self, slug: str, ttl_seconds: int) -> FeatureFlag | None: + cache_key = f"features:{slug}:global" + cached = cache.get(cache_key) + if cached is not None: + return cached if isinstance(cached, FeatureFlag) else None + flag = FeatureFlag.objects.filter(slug=slug, tenant_id__isnull=True).first() + cache.set(cache_key, flag, ttl_seconds) + return flag diff --git a/infrasynth/features/signals.py b/infrasynth/features/signals.py new file mode 100644 index 0000000..fc1a8ef --- /dev/null +++ b/infrasynth/features/signals.py @@ -0,0 +1,7 @@ +from django.dispatch import Signal + +flag_created = Signal() +flag_toggled = Signal() +flag_deleted = Signal() +override_created = Signal() +override_deleted = Signal() diff --git a/infrasynth/features/urls.py b/infrasynth/features/urls.py new file mode 100644 index 0000000..adeeb60 --- /dev/null +++ b/infrasynth/features/urls.py @@ -0,0 +1,12 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import FeatureFlagOverrideViewSet, FeatureFlagViewSet + +router = DefaultRouter() +router.register(r"", FeatureFlagViewSet, basename="feature-flags") +router.register(r"overrides", FeatureFlagOverrideViewSet, basename="feature-overrides") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/features/views.py b/infrasynth/features/views.py new file mode 100644 index 0000000..932f4d2 --- /dev/null +++ b/infrasynth/features/views.py @@ -0,0 +1,43 @@ +from rest_framework import mixins, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .models import FeatureFlag, FeatureFlagOverride +from .serializers import FeatureFlagOverrideSerializer, FeatureFlagSerializer +from .services import FeatureService + + +class FeatureFlagViewSet(viewsets.ModelViewSet): + queryset = FeatureFlag.objects.all() + serializer_class = FeatureFlagSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return FeatureFlag.objects.all() + + @action(detail=False, methods=["get"], url_path="active") + def active_flags(self, request): + fs = FeatureService() + flags = fs.get_active_flags(user=request.user) + data = { + "flags": flags, + } + return Response(data) + + @action(detail=False, methods=["get"], url_path="check/(?P[^/]+)") + def check_flag(self, request, slug=None): + fs = FeatureService() + enabled = fs.is_enabled(slug, user=request.user) + return Response({"slug": slug, "is_enabled": enabled}) + + +class FeatureFlagOverrideViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = FeatureFlagOverride.objects.all() + serializer_class = FeatureFlagOverrideSerializer + permission_classes = [IsAuthenticated] diff --git a/infrasynth/files/__init__.py b/infrasynth/files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/files/__pycache__/__init__.cpython-314.pyc b/infrasynth/files/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a9916d98558ab19346b6ad76a6bf50c3aa2a254f GIT binary patch literal 174 zcmdPq_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9nUh+qA0MBY omst`YuUAlci^C>2KczG$)vkyYXg0|9Vi4mKGb1Bo5i^hl09a%z$^ZZW literal 0 HcmV?d00001 diff --git a/infrasynth/files/__pycache__/apps.cpython-314.pyc b/infrasynth/files/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..57cc9106ce2f3c38d76866efb5c673856c3e1c70 GIT binary patch literal 1021 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 Vy?C0Rdg}Vd|F!?W9CQuY{|^ks9Txxq literal 0 HcmV?d00001 diff --git a/infrasynth/files/__pycache__/processing.cpython-314.pyc b/infrasynth/files/__pycache__/processing.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0e12b9240a31d2a8ea9a5024441a571be468050f GIT binary patch literal 9908 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 literal 0 HcmV?d00001 diff --git a/infrasynth/files/__pycache__/serializers.cpython-314.pyc b/infrasynth/files/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ef64b45db90383ef7cca56fe6ba9f142bb772bb6 GIT binary patch literal 2693 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 literal 0 HcmV?d00001 diff --git a/infrasynth/files/__pycache__/signals.cpython-314.pyc b/infrasynth/files/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db5c027cb76c3da3da64a4d1656054733eb312d7 GIT binary patch literal 330 zcmdPq*ZCdcIytW^qAcNpeOJGf>Gb-n7h| z)cDeZoczR;)RbF%U~WNCesXGYF_6y#=BK3Qq?V+ntYr8MGWM32enx(7s(x;MQDRwQ zwthfSepYI7NwI!rURqINab;dfhHg?~ajJe&VsdtBUJ8=9KFGLY{bI1AiuDRAZ*kb< z=BJeAq}mm61C3?`;$m4K@qw9iA+3_C&@!{ z&aI!W_8(^Oz?9Uts!rXid+s^sp8K79PrqJS;U#eV-}&_QgLQ=b8{QZPdjdC0e+I=A zxk3bPkeuQat_81cgSJz4#oj{dNh=ZT4McGC*tAy_2ctQG<}ztcMsov=H)$?L^8n3j z(%gdYvP1A+b_x~ntGw(I0+-!gPK6ihg=(l-(?HTyX-@G7+k{%UuhZ|nLRA|HHT()$ z2ytrlKq@W8Zpw-@8B?+}J|O|oHz6saEGbGVGm+4E)k?d5!@3%W&R!-8ClJMU#iiJ{ zkt`V{2RMO)rg*`I4Jr;sfo=oE!6;6ln$e)!Kyx7t*mN-(bQ@?qtLJ7E=rmBgjKVXD zk5T-L;t^^Xt%A`ikp|!L3IWA8N)$iqYOPu!4rLSZwBEl22c5tl9;gZI)KU~$%j$X%(-RT`IXW|V9D#^bWoH6BmA zCuNcrde>FZj@%_{nhED_s(w+7XEIqOu1KP&ZZGRu*!s*3ZOdS)Ve9tBNLyLR2P+$Jk5aa?6N|k2Nm#)Rzjg`i}6dVkXj7|_GD=X6uKC@QGVSs zwTpO~Yh!mBo5+@N=xp*!hk=4dHEcYwoQJJ%==2LU#sF_46ty>JqxEnCxyElu(3Nd) zLy;fV4OXZ~8AZm=wCjSVyYW#?xs35u){h2wI7Jo$wRgXF=X;L>t@DA_**(R;8wLK2 ztrWG!pJOtcl;}=q^4U^GC0L~#M!AMHDTg2QvAdTdpufXqnQ3-QhV9azov<^103z=|?XS3;N3m0wKD%=hfXUEI|G~td1J?u_n&gu}* zs^IthCOpdZ=#FBA3A>~ZQnDfg_ofkBeFQy(eX1)rUF{XtgB7A}n-8?j_7?-80v~!& z(GpX%o3z*36(Ze>LfkuJN0IJ*U}twPL!|aRO;hwSWrBPQ1zBA#NT|q|ULnQcd>}Ym zR}8cl`1TE?xCg$tv9}fMD8o4i^R>(Y&}Mikx=DeeMObZ=Io3AKM%!kYjkawd&|~oW z^SIfPjkXY+%*tG?D=DQVMOrP_lhDda1J&v>_R(E~cjf(eEACW0=9}mF=IN1t=6A1_ zs|#x4_*&|OVq>|&EXE0THkaAU<*QaoALLT>CIB^<36e6Cx8WM>sSMzfXH9NR(7cQ@ zhCaDzW;~~v@!!OlHUA^?zI(M!Ak)UNbq<0ahi{g7-60^9d0n-3Ldr-KQIDLO$Vf>M z3|eh1xA8TqU=L|876>KgI5wF^VfMGt@i{J41rz^V1ppWk!2zHXuPDN_l~3a}K&|)l z@wEOVAc|L0R94cdjFicWBE^x!rdx_L0`p!JM;=8pp>IQ>)&m9vq?SsEaYdo2@w_6* z@Zo9;N=!)Uv?u~&41%EJ{B;e1pp8&0@FydF`0BU8yq0Z%;!rW2%Pv45c-4(p;s1iG zFkW>atpl(2G6mvQ8(y_B8e&210TT&pA^~4F4@1YRG5*ePF~;|bu>&s*@F@>cpgt%z z96M@5sj)K5jk9tIb5}a|KP6Bg=7y6tf~-?4PHP}-erW*oUJ4v@HgHO|QEu1?&sQ9R z-I!bj$2v8fW)0UmHQZ(mew`YgQlKgL)~Vw+zfvJo3IU-?s1|Cv_?&&zK8$F7#bLw| zRph+myfbg_B6-^YV5aYaL}+Bxc@-qlOwNGtFGR*|XCP|ukXWwE7k}QkwNjFt*Z(m;| z78B1z-ch~aJ|QO1=&?wj5E)@~-*_IPF@-q>)pz22-x+ui?L8Y&eN>uAWi#3nmk>EQ zcrJR&lb82Naaq~dt@6fM;3d-cz|7GWwME_8=UZvq4{`)|Oed7M3xK{$qGLMLy?` zExJfW({%g&j+u_xvEugq3;xEkyQb-b_m9jRnVl@|=vnX^ch?^IiaU-i__t3F+&?{Y zdiMLp=7S6VU9%3X0`;1wrNwGbt@pNjv5x#&=bxW_aQ5SKKdp&8I5*Qj-TUbMKj`_e z`}cYtoV)FwuZeut*goIb{jdQFj1d2O$xdnqI2j|yzdy{M*y;KQrwhs#4H_BF-UN0T zhjzp8qb*plaMkdiKp<$RY=pmX97tQSfi@`0=0z>u3s^LV!5-tN9bedr0GhsrUwU>R zhqCC#svTy_-;gX2Hc{+kxhz;@W|akoWsIXYOpb7d#UCEJcE}Qn>e(=?6eR42`Wwy= z6lH-PkFtVk0sWG3pj^I|U6g>U4qE9!_|$5sdI+e1Gt4IA2CR(h2q&`0&rUG0ue7W3;tcWD31gX`>un)Z3Y5g=eZdxtn=Jqye~Kp>DmTt zZRi~~rC_7TZQ$veliBPdWVH`rz3qM;$Xj81@O5)5*-=r9g2y= zy(6bWcA8?9a@wYcm`bWXz}rb!xK72>ax~;n8&{*-i3B!7{}5iK3KqBq4&TCc7Ph&- z9_d5Viec-@6{3MyrVN}X5yZarMPtW8v*4 z!ocb0l|srIt6^xElJUgw!^^~!C=E7qzDi6Puu20!C1dC%gGv+x0}lOY8|z0J-#Dl& zGYt(@GQM6BszkY5M3MZCDHn5Q1IiX*Mv4>mhG4P9Y9ni)MaJV@gU8Kb!h2X93$!&W zCIoP?hzMi3W=k+3Hj#jzc^DF0jR~)Oi!ou1$WTiv6Q?&{8Z>NK-Dnv?6hWvZgy?bZ zdP<-`gb1hA!H7F$%Rz$CsBNuvSHONAQ-9_~X&rf;=O#8>=ecE- z*}#r1h2vL)Yf3}LR)(c*;OwCf7PE!BoxdKIQnwU$+9*I}&@iPFDtIFM0R=9H zS{6fhtf@W?PcqeEcs)h_g>U+t5C5F6dCWJ>^G#)7vTplFr|+GfzFDjbS?P;z;@SQs zad^CoUedVhhki}j){q zgc0yE8R3E=1o74NA2r`=o}Rd$o=N{v<`c)K&JUZ5)zRCog+Tq?TX$|vzdoxJ1DyrF zb8A$&$@NxLWd?pSh6?(+$t4aKPA+j;>dYlh%bmHztyU+hiK(*@F02V(0x`nEYp@`4 z>pLa+1oc2In9NFY5R%Y>az2;KQYDy#C6j~_#J!6kBIGcP_}XYz8Jx_erAdhOq-2EB zEM0HWb}cR7cUbo-8G}zMTMN72DOO%A@E2cH%%xNsx*tBi@$wXk`F;46MvU9pgmHWm zrYFD&Q)u9%F{Q62L49+5*%&ceVrb5`13k619;-~qTKj#a^;(UAcNt}89x5nQNh$AX9Pkj>MCqeNmamEsN~{U><4Tv=}c z1rjYwBUeW~VW`f!+zJ&?Q!PIaG(8Tq%m-Q?2g37#@I&xpJ&)f0S>RBCKeWLp+XL@! z<;pnf;y2-E)x|cH{x%$AaKaQIIBlQ{^yY>xPI0)0uC1II&P*FWFk9f*0QclhO0W%^ zy@^?@e+wk4BHfZ;$q40gWsB!)4FWCPVLiZ|NL**?`hpL9xMmLPH@={b()umB%7}Xk*WpnnXeHYB+Ho;Dp2#DXp4pN9)QZwFxgB4YdHZ2+$r)LQ0os>Z~f zOWd*xrVuurEZ2b3VGd~73ntIZAFsx_Wu_@jZ;U6_j_OQfcDrhe9Z+rZ0Z0Z&$V^7j zGEb^IEwY3&9>TZPdG2HIL^}jqCk7n)fw}K6GBAu#{dxuhgXem7AII{{*rvs94MJmP z3(KL#C|quU0+Y}^To*()T-V2mhy8zgon2~XX9bdaVD4DLZCb@3gZlqBI%g%Dyg-XN+~gV6z@H}+0+p6G>~muNrz0vK1TR#0Oh9x_+1$1@YzFg8H{18cb<5d~A- zr~~d87+0%|?}&Xvy|Gv%#&VZg+F8g+{~hb$t1MVkD-G+?cvdUSd;C|0jQTHStzb?d z{7pssZ-l7Vq)(v^v$`ByOp#wY_WYuv?!(_Gv~)k(U#vJdHSn*#h9?cXVU53VXt*G} zQ*5|++rLo1~M7^HGd3QnYMNST#}+!@sVlmTW7Rj{v8 zG6-D_!_}}H z_c=NAOLBPGRmVZTE)>h*Ru1;dp;$h}ZO2O}mXCxuob$0Vkm!(8bRfg(7*PN4Fa=D9ar&T%y z7C5}tD94=Cc2iZrY!aym!hH)zz1(<6?pLHvc=kMdTKZ+PiMhugR$}cnPweM3L`vc4~t%YKU=}|$ZH&%e#!=)xY zTASy7#~f@|@;n>#{J4`;MJ0HQ$ zsahs0L|nxG*246lO&z`41Xd`J^h}|@4H;Oz(=i; zw5t{1*rNN|rd;C{X_qX?%jvL}yly*u0T!!OvE}s_M76|S*RHG<>>79M3isl-WL5Ao z>Vf{LZv9(a*p_4eiVJV_=+3UM105S3DL9lsYaqF2mw2fYDMAje?9U*zh())_5|Pv( zGIS>;!>34{*IusZ12fC2G(i;^;DIgocRhT}r|A zg$*rBjmVXdzs1mvDCp1w3(Sbg6GbZfisWS~`yu2#USH>MMTq(>E=%q0$_O5X5u&&L z0?bu7NRWRuZHyGPi39fv!J+-%R@4X;X(8GrXtPE*aPe$gvYH}f6m4sm!!^j^Vx(hx zjowZMR&GU$3WcFGq8v7=E2P| z!=%zST`u4q%S&0Co1MF{qkE%vWkp!-Rz?y}0d~ykadoyev+l zAW^<+RaUFnH83LvP!e%BYmiVeyJAbouAP{tx!hUtVuJs2le^N>EV1f((e?ZIR%ZQ; zJ*HW5EY~$nHwK2Lr!(ir$G5$FZmqh(a~oA*-Luwnkf(QfNxHd?vop(<%X7ex; zE(Us={Jn4R{jooe?e@L!Ui2{=`Teh(>|%pm{P3k+woum>RJeACxCqMWlb&vx6>Ec= zrWZHOjcR$*K|X21U0F{13p33XTeuQ?L8WS%Dg(reU_fMmczSTJB7w^jfcJJ!@X;u+ zs(JkY=?xniOSR&Qp&%ff#CarmW^o1e1_8X%g7#!uQVM%{Q*yww*Vl%2*)GWvcN`G) zwnhGOCnUyke;`FgFoo$pKd(Y5pgs~ska2$oe|4g zk#v1HQ`b&?rNtr_zwD0?eF)NV{=F^S|1kf#bSUYn{LuK@F13U5$9Cfm3R3vB2e2#IsBbg>xuuEm=e+UZcKJg=XiIGS5QO^f&w5jj1;_m<-23*E!>R+;na<(!|oxbE**=hS;W+=?>kz$ksk0;Fo;MK#`O(; zzbe)RR5+MzpB84zyaZD-%q6aB(cZ|R@13UZ`fWZ6Lj-qRzqF`WsChBb846s##Hg64 zfk8j`2SH6s$+yr#8&7`E#xkHHAh4ME$G~aF)35q7%PZpNu*3rDz6<2@Fr{>lOzx5M zd*r2mlKBRie-hGY=nDemt2m)A>_-RaHTpnrJ~z{NZf2k0YCp?p=D~~2kz8XWw@+ZP Ve}>W7hkA4Ta%24R*C6p@_#aHcVYrNPd19pw^z@kuhG{QPKDBwNU+dr#&tS)f7uWGDc7o-gUECT+nRU+!cYb|>wbqg>_QB5Z zc?pQf!Pl(^R@$mP+mLvcck-_3a%AHU-VL%HRb}7vp6RkZkn@7vYDjXR~+Pes$~S&VCQ2W$W_hUdedie zS3qu{sy>$QxAA)b$@9-vTB@v#R9PE4zs4u{LH_D=x&8vnuKn8ftJg}q zPst!KdgwxoPJSh$hauXk^)t!v_nxr)$VQkSt<(=dXY31E{JfrxUs!SiBrj5%CqeGl zWyX{}3;c8~`~M>=D~HoYPX0Q7gTHCR{Z=j8aDV-OPQ?v-?B-o^+s?qG6L<1=@MZok z^cFM3_o&iGtdZcNoI!IUMq8@3N1S>lx3!q4gH}U)C1jAYg||r{tI0c}wkNzmd&H+B zO%^lqYebxixPua`MjIs(kdU6%7Y|c?O_D1j@CeRI+a`|LwvF>h-obEf-T~a(59HnVA596oD$P7 zkRl1{F48nvLgmetS6(S4U@?_Jq<$0P)P;08(nOthiJEY4$|xi0#G{&?>!fi-Q`1P- zq2*eUvnV4gh&W`4IF{xUP7)MzSQXM50yUBr01X;#sYnovr&JvYCL|8axI3rFKjaW` z=@@18iU-+>R{ttsgCvSSY4}AnBv4tPn1@tN-jWqDBhVnI3F6z4caVVhvWWQ4fFlj* zZIphY=XL~rTO6C5GQrE{QW-f-ygLYsl88m(F9#rq7$CJ`Qq-4%C`rUyL5Q$dUw6sM zrY7op3f?wN>q+WBMIzxc)1);~1bnI@KtqZ!B0&vhC_W|_#DP^3$YnFCD8W(+qOmeT z1B3-SA)yM?3$IW*M=3yb5|f4GLNb7pp7gdVLvA$a#~BM^KS*iRl!!?%t?p#0 z1Ln*HY*01gt-$@d_gpO5(@a-{h|_eZsWJzSmi^IRcb?mCML>4|o9GRujnaSg-XZ}t zhqF0sh1X_9d|vC{pv89rVW;U9&q_H`2k~e*MS-A+hqngM{QFMBBfhyxq_FPZ6u|?D zxEIauL>KW2Ch~$n{DJ_l3Aqe{aZnI`$cY)s9+))<;F_jm%5O!51&^%A7!I~Z8%Gk> zUy9i*Jvfk5ngOJw_cYqclZM%+YY&8{s|&pF=*j%TG6|ka$VexFIrDHlqHiY}y{L(A zmDUt`%)s$&Q7aGE;QU*|hWql?k^&V7m7fLgt7-;`iuE`Mk7wc7mG?bdee&2EIwd)c z$)$PV+8Ue&Ytg?5w34@!Z{ggyrRSour4omfB3_f6lv&=@g*D;v)8%F2nVVgmo1I@E zel?3KFILThK&dCEfm2wMQ#mL|f}qVpD4*buA>R#et2=0T2MXJ+_+l99#dDO#`tTV) zhEk%AhRcP?wmxj~VEF7UZg^y5WK<|4pUm!Q9x!13gBLhPzYDqeGgGQ>eDlq*oAEUj zz5RJ_|4-iHz|7IW%m?r6ah)UVezSb+Hy=jw-bg9b{V~+~wx!sf$hRlntsY9ovp*Et zzc;w`l6}3w_IKWA|BrBHN#s@QuY-+SZG`F^6%yJB#^DTOY--7H2%^O4bmcp-An;ATpp zt3N#}4&BKQ-8q~$p8mEl^gDz5{c*?_u0L*JLY>CtYY;ZVC)eR0;jl>>wxX`|6aNm?dZEvp>I|lO|gC9Ds8Qf46503+G z@P)Jc(i8+=I&8jlebJZL{$epck&jOtItp z6uVW7%^$_)OML^S&YLFzcXP|J%NJ^dR5i!GYIAR~bNr}t+@|7cH^6+KHE|xca=yU! z0np2=4e9t7kp39HZ2Az-hvWZtxS}1$K_)s_>g_uT`KY5oaI}MIj8F(^;0N);wLB)R_@?icD7oy7s_sB+$ci}?El)+7ZEkrZ6_yD5LGl;4oJ9iYDEA`zhbzVR5 zIH|)PaJcbnk-cuSxx^*`)IVCFPFbL)*$Du3$+5>ay8rZ0qrVTC1tsAn?N&KVN~xsv z<599w6SePYVGER>r&YN1&06m}-Co0{{R3 literal 0 HcmV?d00001 diff --git a/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e6e14caf8b088d23d5db29e4f463c83b8a529b0d GIT binary patch literal 185 zcmdPq_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&9nUh+qpPQLp zlvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A4agzIAjT(VMn=XWW*`dyt{gBy literal 0 HcmV?d00001 diff --git a/infrasynth/files/models.py b/infrasynth/files/models.py new file mode 100644 index 0000000..39a26f4 --- /dev/null +++ b/infrasynth/files/models.py @@ -0,0 +1,86 @@ +from django.conf import settings +from django.db import models + + +class StoredFile(models.Model): + storage_backend = models.CharField(max_length=50) + storage_key = models.CharField(max_length=500) + original_filename = models.CharField(max_length=500) + mime_type = models.CharField(max_length=255) + size_bytes = models.BigIntegerField() + checksum_sha256 = models.CharField(max_length=64) + is_public = models.BooleanField(default=False) + category = models.ForeignKey( + "FileCategory", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + metadata = models.JSONField(default=dict) + uploaded_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + db_table = "files_stored_file" + + +class FileCategory(models.Model): + slug = models.SlugField(max_length=100, primary_key=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + storage_path = models.CharField(max_length=500) + allowed_extensions = models.TextField(blank=True) + max_size_bytes = models.BigIntegerField(null=True, blank=True) + is_active = models.BooleanField(default=True) + storage_backend_override = models.CharField(max_length=50, blank=True) + + class Meta: + db_table = "files_category" + + +class ProcessingPipeline(models.Model): + name = models.CharField(max_length=200) + slug = models.SlugField(max_length=100, unique=True) + steps = models.JSONField() + is_active = models.BooleanField(default=True) + + class Meta: + db_table = "files_pipeline" + + +class PipelineExecution(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + RUNNING = "running", "Running" + COMPLETED = "completed", "Completed" + FAILED = "failed", "Failed" + + file = models.ForeignKey(StoredFile, on_delete=models.CASCADE, related_name="pipeline_executions") + pipeline = models.ForeignKey( + ProcessingPipeline, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=True) + output_file = models.ForeignKey( + StoredFile, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + error = models.TextField(blank=True) + + class Meta: + db_table = "files_pipeline_execution" diff --git a/infrasynth/files/processing.py b/infrasynth/files/processing.py new file mode 100644 index 0000000..024a553 --- /dev/null +++ b/infrasynth/files/processing.py @@ -0,0 +1,145 @@ +import io +import logging + +from celery import shared_task +from django.utils import timezone + +logger = logging.getLogger(__name__) + + +class PipelineExecutor: + """Executes a processing pipeline over a stored file, step by step.""" + + def execute(self, execution): + from .models import PipelineExecution + + execution.status = PipelineExecution.Status.RUNNING + execution.started_at = timezone.now() + execution.save(update_fields=["status", "started_at"]) + + try: + data, mime_type = self._read_source(execution.file) + pipeline = execution.pipeline + steps = pipeline.steps if pipeline else [] + + for step in steps: + step_type = step.get("type") + params = step.get("params", {}) + handler = getattr(self, f"_step_{step_type}", None) + if handler is None: + raise ValueError(f"Unknown pipeline step type: '{step_type}'") + data, mime_type = handler(data, mime_type, params) + + output = self._store_output(execution, data, mime_type) + execution.status = PipelineExecution.Status.COMPLETED + execution.output_file = output + execution.completed_at = timezone.now() + execution.error = "" + execution.save(update_fields=["status", "output_file", "completed_at", "error"]) + except Exception as exc: # noqa: BLE001 + logger.exception("Pipeline execution %s failed", execution.pk) + execution.status = PipelineExecution.Status.FAILED + execution.completed_at = timezone.now() + execution.error = str(exc) + execution.save(update_fields=["status", "completed_at", "error"]) + + from .signals import file_processed + + file_processed.send( + sender=PipelineExecution, + file_id=execution.file_id, + pipeline_name=execution.pipeline.slug if execution.pipeline else "", + output_file_id=execution.output_file_id, + status=execution.status, + ) + return execution + + def _read_source(self, stored_file): + from .storage import get_storage_backend + + backend = get_storage_backend(stored_file.storage_backend) + fh = backend.open(stored_file.storage_key, "rb") + return fh.read(), stored_file.mime_type + + def _step_resize(self, data: bytes, mime_type: str, params: dict): + from PIL import Image + + img = Image.open(io.BytesIO(data)) + width = int(params.get("width", 800)) + height = params.get("height") + if height: + img.thumbnail((width, int(height))) + else: + img.thumbnail((width, width)) + output = io.BytesIO() + img.save(output, format=img.format or "PNG") + return output.getvalue(), mime_type + + def _step_optimize(self, data: bytes, mime_type: str, params: dict): + from PIL import Image + + quality = int(params.get("quality", 80)) + img = Image.open(io.BytesIO(data)) + fmt = img.format or "PNG" + if fmt.upper() == "PNG": + img = img.convert("P", palette=Image.Palette.ADAPTIVE, colors=256) # type: ignore[assignment] + output = io.BytesIO() + img.save(output, format="PNG", optimize=True) + else: + output = io.BytesIO() + img.save(output, format=fmt, quality=quality, optimize=True) + return output.getvalue(), mime_type + + def _step_watermark(self, data: bytes, mime_type: str, params: dict): + from PIL import Image, ImageDraw, ImageFont + + text = params.get("text", "Confidential") + img = Image.open(io.BytesIO(data)).convert("RGBA") + layer = Image.new("RGBA", img.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(layer) + try: + font = ImageFont.load_default(size=48) + except TypeError: + font = ImageFont.load_default() + width, height = img.size + draw.text((width // 4, height // 2), text, fill=(255, 255, 255, 120), font=font) + out = Image.alpha_composite(img, layer) + output = io.BytesIO() + out.save(output, format="PNG") + return output.getvalue(), "image/png" + + def _step_scan(self, data: bytes, mime_type: str, params: dict): + logger.info("Virus scan step executed on %d bytes (no scanner configured)", len(data)) + return data, mime_type + + def _store_output(self, execution, data: bytes, mime_type: str): + from django.core.files.base import ContentFile + + from .services import FileService + + original = execution.file + name = f"processed_{execution.pipeline.slug}_{original.original_filename}" + content_file = ContentFile(data, name=name) + content_file.content_type = mime_type # type: ignore[attr-defined] + + service = FileService() + output = service.upload( + content_file, + filename=name, + user=original.uploaded_by, + metadata={"source_file_id": original.id, "pipeline": execution.pipeline.slug}, + ) + return output + + +@shared_task(name="infrasynth.files.run_pipeline_execution", bind=True, max_retries=3) +def run_pipeline_execution(self, execution_id): + """Celery task wrapper around the pipeline executor.""" + from .models import PipelineExecution + + try: + execution = PipelineExecution.objects.select_related("file", "pipeline").get(pk=execution_id) + except PipelineExecution.DoesNotExist: + logger.warning("Pipeline execution %s not found", execution_id) + return None + return PipelineExecutor().execute(execution) diff --git a/infrasynth/files/serializers.py b/infrasynth/files/serializers.py new file mode 100644 index 0000000..3142867 --- /dev/null +++ b/infrasynth/files/serializers.py @@ -0,0 +1,79 @@ +from rest_framework import serializers + +from .models import FileCategory, PipelineExecution, ProcessingPipeline, StoredFile + + +class StoredFileSerializer(serializers.ModelSerializer): + class Meta: + model = StoredFile + fields = [ + "id", + "storage_backend", + "storage_key", + "original_filename", + "mime_type", + "size_bytes", + "checksum_sha256", + "is_public", + "category", + "metadata", + "uploaded_by", + "created_at", + ] + read_only_fields = [ + "id", + "storage_backend", + "storage_key", + "checksum_sha256", + "uploaded_by", + "created_at", + ] + + +class FileCategorySerializer(serializers.ModelSerializer): + class Meta: + model = FileCategory + fields = [ + "slug", + "name", + "description", + "storage_path", + "allowed_extensions", + "max_size_bytes", + "is_active", + "storage_backend_override", + ] + read_only_fields = ["slug"] + + +class ProcessingPipelineSerializer(serializers.ModelSerializer): + class Meta: + model = ProcessingPipeline + fields = [ + "id", + "name", + "slug", + "steps", + "is_active", + ] + read_only_fields = ["id"] + + +class PipelineExecutionSerializer(serializers.ModelSerializer): + class Meta: + model = PipelineExecution + fields = [ + "id", + "file", + "pipeline", + "status", + "started_at", + "completed_at", + "output_file", + "error", + ] + read_only_fields = [ + "id", + "started_at", + "completed_at", + ] diff --git a/infrasynth/files/services.py b/infrasynth/files/services.py new file mode 100644 index 0000000..f1e4d42 --- /dev/null +++ b/infrasynth/files/services.py @@ -0,0 +1,199 @@ +import hashlib +import uuid +from pathlib import Path + +from django.http import FileResponse, HttpResponse, HttpResponseBase, HttpResponseRedirect + +from infrasynth.shared.settings_utils import get_setting + +from .models import FileCategory, PipelineExecution, ProcessingPipeline, StoredFile +from .signals import file_deleted, file_uploaded +from .storage import get_storage_backend, save_file + + +class FileService: + """Public API for file management. Used by App B and other base apps.""" + + def upload( + self, + file_obj, + *, + filename: str, + category_slug: str | None = None, + user=None, + is_public: bool = False, + metadata: dict | None = None, + pipeline_slug: str | None = None, + ) -> StoredFile: + """Uploads a file to the configured storage backend. Returns the StoredFile.""" + category = self._resolve_category(category_slug) + self._validate_file(file_obj, filename, category) + + content = file_obj.read() + file_obj.seek(0) + checksum = hashlib.sha256(content).hexdigest() + size_bytes = getattr(file_obj, "size", None) or len(content) + mime_type = getattr(file_obj, "content_type", None) or "application/octet-stream" + + backend_name = ( + category.storage_backend_override + if category and category.storage_backend_override + else get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local") + ) + storage_key = self._build_storage_key(filename, category) + + saved_key = save_file(file_obj, storage_key, backend=backend_name) + + stored_file = StoredFile.objects.create( + storage_backend=backend_name, + storage_key=saved_key, + original_filename=filename, + mime_type=mime_type, + size_bytes=size_bytes, + checksum_sha256=checksum, + is_public=is_public, + category=category, + metadata=metadata or {}, + uploaded_by=user, + ) + + if pipeline_slug: + self._schedule_pipeline(stored_file, pipeline_slug) + + file_uploaded.send( + sender=StoredFile, + file_id=stored_file.id, + storage_key=stored_file.storage_key, + filename=stored_file.original_filename, + size=stored_file.size_bytes, + uploaded_by=user, + ) + return stored_file + + def get_signed_url(self, file_or_id, *, expiry_seconds: int = 3600) -> str: + """Generates a temporary signed URL for direct download from storage.""" + stored = self._resolve_file(file_or_id) + backend = get_storage_backend(stored.storage_backend) + return backend.generate_signed_url(stored.storage_key, expiry_seconds) + + def get_download_response(self, file_or_id, request) -> HttpResponseBase: + """Returns FileResponse, redirect to signed URL, or X-Sendfile response.""" + stored = self._resolve_file(file_or_id) + backend = get_storage_backend(stored.storage_backend) + enable_x_sendfile = get_setting("INFRASYNTH_FILES", "ENABLE_X_SENDFILE", False) + + if enable_x_sendfile: + response = HttpResponse() + response["X-Sendfile"] = stored.storage_key + response["Content-Type"] = stored.mime_type + response["Content-Disposition"] = f'attachment; filename="{stored.original_filename}"' + return response + + if stored.storage_backend.lower() == "local": + fh = backend.open(stored.storage_key, "rb") + return FileResponse( + fh, + as_attachment=True, + filename=stored.original_filename, + content_type=stored.mime_type, + ) + + signed_url = backend.generate_signed_url( + stored.storage_key, + get_setting("INFRASYNTH_FILES", "SIGNED_URL_EXPIRY_SECONDS", 3600), + ) + return HttpResponseRedirect(signed_url) + + def delete(self, file_or_id, *, soft: bool = True) -> bool: + """Deletes a file. soft=True marks it as deleted; soft=False removes from storage.""" + stored = self._resolve_file(file_or_id) + if soft: + metadata = dict(stored.metadata or {}) + metadata["is_deleted"] = True + stored.metadata = metadata + stored.save(update_fields=["metadata"]) + else: + backend = get_storage_backend(stored.storage_backend) + if backend.exists(stored.storage_key): + backend.delete(stored.storage_key) + stored.delete() + + file_deleted.send( + sender=StoredFile, + file_id=stored.id, + storage_key=stored.storage_key, + deleted_by=None, + ) + return True + + def get_file_info(self, file_or_id) -> dict: + """Complete metadata of the file.""" + stored = self._resolve_file(file_or_id) + return { + "id": stored.id, + "storage_backend": stored.storage_backend, + "storage_key": stored.storage_key, + "original_filename": stored.original_filename, + "mime_type": stored.mime_type, + "size_bytes": stored.size_bytes, + "checksum_sha256": stored.checksum_sha256, + "is_public": stored.is_public, + "category": ({"slug": stored.category.slug, "name": stored.category.name} if stored.category else None), + "metadata": stored.metadata, + "uploaded_by": ( + {"id": stored.uploaded_by.id, "username": stored.uploaded_by.username} if stored.uploaded_by else None + ), + "created_at": stored.created_at.isoformat() if stored.created_at else None, + } + + def _resolve_file(self, file_or_id) -> StoredFile: + if isinstance(file_or_id, StoredFile): + return file_or_id + return StoredFile.objects.get(pk=file_or_id) + + def _resolve_category(self, category_slug: str | None) -> FileCategory | None: + if not category_slug: + return None + category = FileCategory.objects.filter(slug=category_slug).first() + if not category or not category.is_active: + raise ValueError(f"Category '{category_slug}' not found or inactive.") + return category + + def _validate_file(self, file_obj, filename: str, category: FileCategory | None) -> None: + if not category: + return + ext = Path(filename).suffix.lstrip(".").lower() + allowed = [e.strip().lower() for e in (category.allowed_extensions or "").split(",") if e.strip()] + if allowed and ext not in allowed: + raise ValueError(f"Extension '.{ext}' not allowed. Allowed: {', '.join(allowed)}") + if category.max_size_bytes: + size = getattr(file_obj, "size", None) + if size and size > category.max_size_bytes: + raise ValueError(f"File exceeds max size of {category.max_size_bytes} bytes.") + + def _build_storage_key(self, filename: str, category: FileCategory | None) -> str: + prefix = (category.storage_path if category else "").strip("/") + safe_name = Path(filename).name + unique_dir = uuid.uuid4().hex + if prefix: + return f"{prefix}/{unique_dir}/{safe_name}" + return f"{unique_dir}/{safe_name}" + + def _schedule_pipeline(self, stored_file: StoredFile, pipeline_slug: str) -> None: + pipeline = ProcessingPipeline.objects.filter(slug=pipeline_slug, is_active=True).first() + if not pipeline: + return + execution = PipelineExecution.objects.create( + file=stored_file, + pipeline=pipeline, + status=PipelineExecution.Status.PENDING, + ) + backend = get_setting("INFRASYNTH_FILES", "PROCESSING_BACKEND", "celery") + if backend == "sync": + from .processing import PipelineExecutor + + PipelineExecutor().execute(execution) + else: + from .processing import run_pipeline_execution + + run_pipeline_execution.delay(execution.id) diff --git a/infrasynth/files/signals.py b/infrasynth/files/signals.py new file mode 100644 index 0000000..f60fe72 --- /dev/null +++ b/infrasynth/files/signals.py @@ -0,0 +1,5 @@ +from django.dispatch import Signal + +file_uploaded = Signal() +file_processed = Signal() +file_deleted = Signal() diff --git a/infrasynth/files/storage.py b/infrasynth/files/storage.py new file mode 100644 index 0000000..728a33c --- /dev/null +++ b/infrasynth/files/storage.py @@ -0,0 +1,162 @@ +from django.core.files.storage import FileSystemStorage + +from infrasynth.shared.settings_utils import get_setting + + +class _LocalStorage: + def __init__(self, config: dict): + self._storage = FileSystemStorage() + + def save(self, name: str, content) -> str: + return self._storage.save(name, content) + + def open(self, name: str, mode: str = "rb"): + return self._storage.open(name, mode) + + def exists(self, name: str) -> bool: + return self._storage.exists(name) + + def url(self, name: str) -> str: + return self._storage.url(name) + + def delete(self, name: str) -> None: + self._storage.delete(name) + + def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: + return self._storage.url(name) + + +class _S3Storage: + def __init__(self, config: dict): + from storages.backends.s3boto3 import S3Boto3Storage + + options = { + "access_key": config.get("ACCESS_KEY"), + "secret_key": config.get("SECRET_KEY"), + "bucket_name": config.get("BUCKET_NAME"), + "region_name": config.get("REGION", "us-east-1"), + "endpoint_url": config.get("ENDPOINT_URL"), + "querystring_auth": True, + "default_acl": None, + "file_overwrite": False, + } + options = {k: v for k, v in options.items() if v is not None} + self._storage = S3Boto3Storage(**options) + + def save(self, name: str, content) -> str: + return self._storage.save(name, content) + + def open(self, name: str, mode: str = "rb"): + return self._storage.open(name, mode) + + def exists(self, name: str) -> bool: + return self._storage.exists(name) + + def url(self, name: str) -> str: + return self._storage.url(name) + + def delete(self, name: str) -> None: + self._storage.delete(name) + + def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: + return self._storage.url(name, expire=expiry_seconds) + + +class _GCSStorage: + def __init__(self, config: dict): + from storages.backends.gcloud import GoogleCloudStorage + + self._storage = GoogleCloudStorage( + bucket_name=config.get("BUCKET_NAME"), + project_id=config.get("PROJECT_ID"), + credentials=config.get("CREDENTIALS_PATH"), + ) + + def save(self, name: str, content) -> str: + return self._storage.save(name, content) + + def open(self, name: str, mode: str = "rb"): + return self._storage.open(name, mode) + + def exists(self, name: str) -> bool: + return self._storage.exists(name) + + def url(self, name: str) -> str: + return self._storage.url(name) + + def delete(self, name: str) -> None: + self._storage.delete(name) + + def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: + return self._storage.url(name, expire=expiry_seconds) + + +class _CloudinaryStorage: + def __init__(self, config: dict): + import cloudinary + from cloudinary.uploader import upload + + cloudinary.config( + cloud_name=config.get("CLOUD_NAME"), + api_key=config.get("API_KEY"), + api_secret=config.get("API_SECRET"), + ) + self._upload = upload + + def save(self, name: str, content) -> str: + result = self._upload(content.read(), public_id=name, overwrite=False) + return result.get("public_id", name) + + def open(self, name: str, mode: str = "rb"): + raise NotImplementedError("Cloudinary storage does not support direct file open.") + + def exists(self, name: str) -> bool: + import cloudinary.api + + try: + cloudinary.api.resource(name) + return True + except Exception: + return False + + def url(self, name: str) -> str: + import cloudinary.utils + + return cloudinary.utils.cloudinary_url(name)[0] + + def delete(self, name: str) -> None: + from cloudinary.uploader import destroy + + destroy(name) + + def generate_signed_url(self, name: str, expiry_seconds: int = 3600) -> str: + import cloudinary.utils + + return cloudinary.utils.cloudinary_url(name, sign_url=True, expires_at=expiry_seconds)[0] + + +_BACKEND_CLASSES = { + "local": _LocalStorage, + "S3": _S3Storage, + "s3": _S3Storage, + "gcs": _GCSStorage, + "cloudinary": _CloudinaryStorage, +} + + +def get_storage_backend(backend_name: str | None = None): + """Returns a storage backend instance by name (defaults to settings config).""" + from django.conf import settings + + config = getattr(settings, "INFRASYNTH_FILES", {}) + if not backend_name: + backend_name = get_setting("INFRASYNTH_FILES", "DEFAULT_STORAGE_BACKEND", "local") + backend_config = config.get("STORAGE_BACKENDS", {}).get(backend_name, {}) + backend_class = _BACKEND_CLASSES.get(str(backend_name), _LocalStorage) + return backend_class(backend_config or {}) + + +def save_file(file_obj, storage_key: str, backend: str = "local"): + """Saves a file to the given storage backend, returns the stored key.""" + storage = get_storage_backend(backend) + return storage.save(storage_key, file_obj) diff --git a/infrasynth/files/urls.py b/infrasynth/files/urls.py new file mode 100644 index 0000000..0164f0f --- /dev/null +++ b/infrasynth/files/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import FileCategoryViewSet, ProcessingPipelineViewSet, StoredFileViewSet + +router = DefaultRouter() +router.register(r"files", StoredFileViewSet, basename="files") +router.register(r"categories", FileCategoryViewSet, basename="file-categories") +router.register(r"pipelines", ProcessingPipelineViewSet, basename="file-pipelines") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/files/views.py b/infrasynth/files/views.py new file mode 100644 index 0000000..edbbe41 --- /dev/null +++ b/infrasynth/files/views.py @@ -0,0 +1,70 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated + +from .models import FileCategory, ProcessingPipeline, StoredFile +from .serializers import ( + FileCategorySerializer, + ProcessingPipelineSerializer, + StoredFileSerializer, +) +from .services import FileService + + +class StoredFileViewSet(viewsets.ModelViewSet): + queryset = StoredFile.objects.all() + serializer_class = StoredFileSerializer + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("files", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return StoredFile.objects.select_related("category", "uploaded_by").all() + + @action(detail=True, methods=["get"]) + def download(self, request, pk=None): + stored = self.get_object() + return FileService().get_download_response(stored, request) + + +class FileCategoryViewSet(viewsets.ModelViewSet): + queryset = FileCategory.objects.all() + serializer_class = FileCategorySerializer + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("files", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return FileCategory.objects.all() + + +class ProcessingPipelineViewSet(viewsets.ModelViewSet): + queryset = ProcessingPipeline.objects.all() + serializer_class = ProcessingPipelineSerializer + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("files", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return ProcessingPipeline.objects.all() diff --git a/infrasynth/notifications/__init__.py b/infrasynth/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..116ba5bf4718d480fec7343da996647a4b300e17 GIT binary patch literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX wnV(mzA0MBYmst`YuUAlci^C>2KczG$)vkyYXa~p@#UREfW=2NFB4!{901)0UIRF3v literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/apps.cpython-314.pyc b/infrasynth/notifications/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d4faa02a0ca5499eecbb12a22d0470b00b90cc0 GIT binary patch literal 1086 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 literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc b/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bfea30b0eeb368cac23d24ede28b3efd6c3aa15 GIT binary patch literal 3477 zcmcgu-ESMm5#Rgbhe%0h@nlN=k}OI}2*;uu3AHT={($m<)-EBRWTvq!9gfG7bTLKJ zdncuGfP!cXM0qG82X(@{seMSCx8@;$UIO@`e*hH=5OWC-7kw!5CRYWV0!7=Iy(3LK zcATd!z~Su8?#|B4Z)WFMTWbq}@!!k&H(m}9@;98+hv+kVuK-gfb42BSMjSFrCi(i3 zhyO>(EI-0|b4TbTM~TYEi7JfvYaLPL5+o^pi%ZhR!;OSA^sK{I9W>1nhf|5e&jlP| zKPi$Fal~0kZOM>f4o}U8Q~{`*=SKWDj!gnszb2Vp7#yuxy8}rVTXMH=2Zi7vQ&-1~Sf-Nruevr+!CB z4!#~Dm$*ev<)*k9a15iu3qe@u4^@1^HwnL`L~vW+TXdt_y5iqNE9gt6E9Ld9nNPcs zoM{`>DmhlM;D%9TmvqDYgmVSBW|DUU^F_L(J8r9{8F}5dbC%&~nvG}nmEGyoVsXh# zEfuN0qF+l*(&8JY;n*pwFi&-Rwcsos&+4|B%Id~7vyf~0o+=a_Yu+++)NH4y?;m@p zwCYARO)r34cT7!l2cG7`q2^_XMSBd$cV+T`bV%Lzk^D7&U{}IPfXK1+>EGYj4dN_B z_IFoCZe7_8>jy?BI;Zoxpoz(2EV;O0U4LdL?zRZ3sb@v_ng0GfrYq-Oi2)2{`=om0Rk+l(v*~f zpicU`n*XN6C{Y7bQk1+X41(aQpEra|HrwKFQG-qE#d_ZLfJJcokju{(D8iA3fw+XC z1r*Wda-%nn)I-r*j~6fqXp@?`(cYxswrF*U?FRD2g$0wkQqIgSFC+u5IKNylT*1iO z>~v30LxBxNgbbE(&~{W9&Y1=P%PCTKq^|h!JQ}L&9~#dW4Lxt48EWj>2Zz{;KmZc= zWMw^9ljD`no$ge%JGF7VCLgZ6wbOH=+H>M|wk98}yk3)gHV*Gb0%zLRCf-fIldf2m zncLj$;ja4x$nJh3cUMDQ>;{jN z7F^Ft%Q;|vOlg6_!TBXcYA!QgF`;;O4-a8#8dk|N3yvE!iUr5~mBVaICwUr$Rj!D& z0w%4vr45K*HJH&<#;+Kn9+aE};%BacnXCAjt3c)|am-o;vbJMCW~{<8wxZk`#jI64 zoAF-tfjrCF{{REID%Nu?H;&~rgZV--9Og?YkReaXsY&$NV%ITp7 zAFHfxp4^U2RHGALw#UAoDHtjI?F|9>;Y@iXFF5eB;N=C@vNVkVeKzc||FF^R4EBQ5 zf1d#PRQPl5vlhmBH*C~$SlPh!Utj{bZoDD6vkZej8p-wEM<$o~%fhmdBFp?MfRi^t z8%<;cl{8~H!=3siA>KQ60f6*cBFkUsCL~ObkY%AHl=uX>+IVT6oKN-4Nr0vvf0qS_ zWWWOVG5krEV=mcr0IW-j9t_c=E_co4R%{X2!NEGv;(%2RnhpyZnY^j3_}z`|o_H$o zp0Z;Y&b6@yl*!*CN^RizkLUh4w=-~hYvAV0q|||8}g;B+?@Kb zfBp4a?OS61KLo;Y41g5Nb5cmB;c0J_j3l2V`DdPePjp;-zA48DgPf19yE{ z&tp3Yvvy6Jx2Wypt%6x7YMK}92x}*oOlPr}qrG_TV@O^E;_hqe?67BmLpq`9goe;E zG~IEim0fmB8$QFzMq6r_c_?RqDfZT2Z+*^tYS7sf2{U;HnD5?(XxIAs%Rj#-$q#t! z0l5 z=uh!BkUrj(n18)Jtmz4jdtN+4&%qKj!tMjID{vh56=}N{mUqGjz6c+voVpwCF9#k3 Ne!}(d5g_c0{{d^=-c$ep literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc b/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..4c0e159f4fc46c69593a7347b5619f02ff4b6433 GIT binary patch literal 2225 zcmai#OK;mo5P+9YQH*69sUKqzwWTfq~@U9PApblS^SQC~9pnrbuOX zl~z6ZWaQ8vkYf+Mq`#m)qXq(`U7)8Pir#|SX^!a(DaoW`*93ON*_quL?tDA?bhP3U zc-}sU9{p?)@+T^T2lVtg_z8Y?$tLl%`(#;@+C16RJpI1DY)E6-l;*M}t$8v@E)ma| zCZ4&Wt&Q~8=6-G(nIcQ6rv;w&8J)H%U;=ttrAjEkC~ zDtB9c9JAoQFmLJvEOE@6l7GX$fBlD}N`-{71=N?4hmEde0v01} z2QY|wSZ^h5R&OWVf8y`d*LZ@TBI;qh#eLC>W$UwsFIc_d2Rkfo=H}~h4hm7v0kqiZ zDHBhR<6SAi5Enq~k>AEYdTRZnyXW1f_Nyr}d+FI1FPsZ}7L0rz1?IK-+~$+0Ore-Y zfvYoz8GJ*pY|$LPIRk-5BG9-;-slyp`kH{q9MPu$&G4??0ZR_eX@myuCEQofxHUl2 zN^vy-S8Ir??b(1+2~cv1xDGDJ5w6M^ps`-ll`WDk2L#H)Y85gY;(Vo;*~KBw2I9Pd z(KUcshYU1Z4TXOQF2}pZ=QxMr z8jNArC|mAp0wUW2&8sz6jdCOt8e*G$oA4cOz`U~4-Ya;Ta0+MIlE+rCB_9a_;v2-0gn}DCsx+2S2eBaR2}S literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/services.cpython-314.pyc b/infrasynth/notifications/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6d363e85f265d2cafb9ecb95387d727f66049923 GIT binary patch literal 14869 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 LkgoB1d5QlQRGe-F literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/signals.cpython-314.pyc b/infrasynth/notifications/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff174a45ea76992e8650e059de23fed5d32e5a44 GIT binary patch literal 314 zcmdPqqe@a$jUV6S>N@j6EVo7pF5i?N9Ey29}lFYQs zSo}HbY_ulNh zncw=15jjF&{Pk&e;d_;kU+|_ew7RnM6)5XuhFHQB3GgaT3D?Cy>?hMC39}B40WvM0 z7i!i(nHC3#C0X)}G;_&P#^jpaQoG3c`ouu3`e=uI^d=w85{5`D^c&7e!~`DpGY+F^ zJ8=BPwNWoO#~gn(7i7-m@_{$!xlZ8abN;zB&3d;fvzx7{n9SvzvqZT(>*dlsyyUFd zj0Vi3J~z{J&RNa|Rl}+c^PN>!tVfC$d|?TGJ72;pLRth&%XOgu=>T1v60b`Ish{+c zn_^PO3HwJfy#F5WEUA&MWaRMEn}Q{e)zev!Q_{e0;tEPHNh!&rhv%er51^l_dc7}4mygU+kXQJuUuX=vL z3ET|W&1U-z%5Hlu<$6Gu3Rx$h;2;(jUko-oMbq`!(bIkuswO&@U(TfyYaNYM(LU#R zS(<(%a*3|EJmi8)kj~%9#SE@4<)LPLhR4ixzqX~%@Lai;pIxAC;B&*LSx}(HFpM;p z=4rrNuI8x^p{D7<3{QNv+T)X42oR^#D5u2`1cR8T8PoZc-*H&Cwz#``%2%@ek zSjMX{jmxmZ91k~M#`e+3_0JVndFQKokqyJ1*e3pWAlAtr22nl3Q8XtWuWgzim&`=bOl(-w8)jnDoVlxSA0e%So5s-kM-@?4E|&Gy zlHOO;`yOPr^pT2ATDmsmV}DkTl9p&Ge6|=q`<#eM=a0H^-?(RNbd5j$d`q9=t!705 z^ULQ3X*r4>7r~>XcNg{UjpL&azu3~p%SOljg?kGRZakd4yRd1*zrC@cUwDbrF22}_ zLG)jrxzb6V27?`82{@XEg91sNM_ zE)OVk5k8IaIv4?fc3~t}VI=(rFj7)-6O2@Vk@_kaeP->UqYfkKe}$3u24JM^gOOH+ zk#<>Yh7k+Fu`v9gvKAD`Th@xA9YkWS_aH*>IO{B79pDl(7_z+9smRo7yRt6Sx=|oW zSq}>2B|8Cvx9@VHQ6cOkKI}&k1rgKP0P2Gvxb9}0T&@AA+WJHI^c0FJ1+W{3>lk3C zanj(cI(L6nvNPa_+q}^}9w_Pq-@LV@zyD_X_=CscE&b|0^)VZPSBU9cL;50hRToIj zRv9)O4*{k>sbN{SZTtse+qIKGid9qai0pt$YI)Ny!N*`ZODEWSV8c(9PeD{9K@ff* z#;@e$&!M(bsOMRz=fQ=|(5ZEGTP0$o9J){rMapgM34Ku@4`-h11uOc-2eap literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/urls.cpython-314.pyc b/infrasynth/notifications/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f5d233aa96c4e8f9962de553a9dffa6f2f555184 GIT binary patch literal 873 zcma)4&u`N(6tz`o>B%*)`exMTwHWcq8gkg!VR^WRqh;TiiVz=M9vbc--gV19rDW=&RpyMLGB(nYw7pE|fJc_W_M?BMq~nz(N&<7RRmt;gv+Ln@R2`C#68R=X6OI=NqU^9mM7QIJ^s40Vsi|* z>LQ;}E5M>9J;AKAE{C;_Jimu<^Ac`do`qVnLv?V%&5eH49ERvAxheGJ3)7KsQx!t^ z9o+v09{kcYsGI}oav-z%p)}FI0{v~_b>TD@-+3I@Urh>MfSX|Xxb!4`b|A6C1k1*^ oAI2Ly65Bo3K`Q<3`umrsd!P0{?w{q$XWHGVsuYwrSt5VkAD?jR0ssI2 literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/__pycache__/views.cpython-314.pyc b/infrasynth/notifications/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..24f36f2997c52a5c947e8dafd19e3309ab23893b GIT binary patch literal 2702 zcmcIl&rcgi6rQmcy!hAh!{7$e5CVxXPH_{_RH9LoDiy2`0pwIU)mbg}I9^z<>CBLb zp1Ad2=rQVz|Db<{5eLw!RS&(iQV*#dg&zCfuGe^t(>9g5(!QPdX6EfTGvE6rpH9UI zJiq_CyYu}JA-|v#Jm7mz`xSf+$um--MWRzlXR#eAM)atR>WWmrr=@6dNFSENdQ8T2 zRjOW0DURrI8P^jsp(kb1iwzZ1dRnG+O=|k69GxYTWR8@Ev!oO&M^~c$FXfmQRUtZ3 zR=Uw~FB*qvqG#+xDLGGaso!vFj=EY=e7-O1%#l{bl&qSIxXH)ODzl$j?Bx?C-Asv@ z)lJ7f=#A#0?!=?I={U@O)O2dr^EJzsjJwktO=;CIlht&-u*9AzEA>HrU$VxY4gd4| zAYCey&dm%;6pjWdU~)Dz`3d8eX$9nkPqeBNycf=yPMu?HyK; z!i}1?eMDVFFuNx3zqj12LcQ5wg+`N`FU;M-7H@h(3zk#krZ{k9{iAJDu)?-k*=0_( zCth%Rh$0FvfNWxU@4!tyXVUn3pYa0$+*|ycr7!o6%mN&N=8*iT&7W%dV=aHG6^^yS ziFT)@-f2S@IU*amF;_JV$80deaN~y2Xjb=aY$px)t7&(7G{dM_Tu9q;nA0>2ZzuQ! z>IuR$JdKAO*Uv`-GX@RuDVRUMgWNf)ZWn(Dzau zM7%nPcIrTZIv5JqL7**xN~y9VD4~i#u<)wwhtT*h6;UDqM1Vl~5~P8;z$*9H;HIG% zpg0W9P|T`)5_$wQW|;$1z}7LK)rQ*H5BTRhR0TI$k2Go=s5b%d2f)5nxyjQty?6mTFKX_EJ`W2TK<#+~L&NCPgE27B4$yUQ1<+R@o;-vIA2VN!4f0pUI8 z#yX12y~TLk_mp@TE&yf=|4h8n@$S6Dg`f+6PrMRBvG)ZQ8SE{UV^#QgQGQ+ZzJH}W z;3&N)Z(nNr4Cvxv+vUaX0H1Y=la~>yjgC3K|=aC@vCLN=9U)_E~;PWOEqwBPF S;~skJ6y5xH_uqo!Z_r=lEoSim literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/apps.py b/infrasynth/notifications/apps.py new file mode 100644 index 0000000..bddd2f9 --- /dev/null +++ b/infrasynth/notifications/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.notifications" + label = "infrasynth_notifications" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "notifications", + name="Notifications", + description="Notification system — email, SMS, push, etc.", + default=True, + category="communication", + ) diff --git a/infrasynth/notifications/channels/__init__.py b/infrasynth/notifications/channels/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b3213369ff905b3d59dd3e176cd81ba009b11fd1 GIT binary patch literal 191 zcmdPq_I|p@<2{{|u766{DY#pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX znV(mzpPZ4HmzSDTtREkrnU`4-AFo$Xd5gm)H$SB`C)KWq6=)gAImIBxCuT-Q#v*1Q F3jhxtGQI!+ literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..50c6bdcb08b091bca3262b7aacc12522e5db83d9 GIT binary patch literal 2525 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 literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2b038dda900795d0e725e80382496c9873aee324 GIT binary patch literal 5281 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 D&n>1v literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7505cb380c7a0168a91260bbfbd4dd15c279b082 GIT binary patch literal 4378 zcmb7HU2qfE6~4Qxf610C|A8^~f*r7cShP4L1p~nZVhZtywc>PIll6MFwiYCt^0 z{+#=B?z!jZ+uhAgAp)f>lsosw03lyu$1UDkW1T^xOkN=pcakut<`@Tk{v-bT&_hd8ItLhWQ5k-VKm@}2b$3ANF%#PyMc z|7+}yB{(a5EUOd>S}r|T)S&B=G_#ar$QV&ft;STH#SX3QMAqMfz5?=}C5)2@<6miF zLI*M6Hq3L@n~F3pa@x!<@hl)kBu^*FaV&T?G(q-pC}S=l`GCfTYKvG{YO;BLn-@SH ztcciSL3=EOW2{LEv*roHqLKjjYPO^K3k#+(~W|7UYj$;0jen(afTXsacZizw=-=}wP#OFB@Tq1 z2IjQEWM-V#3R6?K>eZ4?wM@chffP*EGsrNCxVMrMGun&-8b=A)9?`gVvFy3{@QIUe@(80 z$MTq3Zia?o5yj`HO+ZXMdR7 zStTLKgYkL3Muk~E#~v&n)mVuZs2>KwpF9~|WeMK$%@{PVFsn(H)tq9QI4sL1F5O9ZoF|r|u3*cU#7nhow8`ZmYlPyQ=L|-*0BNu?aTy z0EM^-l*bm1?c?`!!4|cW^nyY9Xd85`U_FdjezP=AC+miu z!b@x@U|z&E&A#s%J_M_7(u-#_N>E#0^iBBw&vUp(=WKx}k@iVKT6o;i(tMIH`X@Z@ z?io0}n@FAszvSHkr%wWWa7xOC6mHL{x1qdGNVO@9Bpb?;wI}%`|NffJCj2nx3BW=M z?60|YF^~?V@aZ=c*QJZ0bSSlT0~5MZ*wu!6&)E^Y=l_@;ac4K^3*^H0k(xjP^w13@ zLVnB_!$0`?&FL`CbocLTv=?#1me2)zi9VVuHogW%)eXf*GS8uu#o#2mo?TL4l^cr` zO#6mvI`3_i;E5JHi4JI&bRFKgGL`tPL<$vyM}mE|TIY0fP}oXzE)kpG=0-UY61QmM z0ueHc=mfzMAd~slF~bHQ08GRpH7@8GEn{bI%fqyb5GqE~FP^qSMTMH0oHsI>6-EH{ zqGp=PtOgm)Yj11$9JB!go^z&AuygF%m9Kc&19zKDHS4@Q3W8NFoiw?i%j%eL`FMyh<0(( z6o9Bg#%gn~?}8W{wd3t!$jfC?jgeT}l}lGHEnd3Uy7~Hn_YYKhj;^$hR3am5@hz+I z-Ie(6C3f?T8*eQ7{~qnA&)K)qI#h`aRr%)Nz!$O2OZ(Si9ZQ~@(WU4uf7L5=HZQ*L zFhF9RYXgH<{Y#PM*i-j9d#^9Nzpxf-yWVuIX*Jfr9P7V5^rypj4uAA?CDy+ZORcqc zuWjnS8MzU8;P-55UL38)No>=Vh06=?yt(LEi?&|5c=_Uz=gOtGFRgX;+#I+uaO==Y z*N;}a#w%UpACx})`RbuF%ZJWXhNa3g%G}#? zOKtDGSqb!f`=ABRSd9)WM+Y7dJ~(hM5?_sUFGsp7TaSFyc{g&hw$1O|8YFW3;ggP`6EWe_5C`o~V_u-&$=K+& z7!21%Am5joF1k5rqe%M)+^Y-5-KyGC0%E^(9&V)jZ1V`E=q@J{gQu|y0(dqK$~%nivHTuM1?0|0)t zq~vOxs4UOu)MPompcM>Y`(-&}sIpA)=2jqm{Pgh`q@yodk=lQO%;0C1{uG!LbD0&k zfwmK#Xg>}=k7N{i?SQ?(&2?1<2d4UTiD@Pr(b9m6)ELSIW2TMq*O zeg28S>EQ_u9{L-6ZAWE(*fSBa{1~hB!Ytj7B6mLeUm7&C3dHb1tx(FFjyg6!&V~2` zf0(9?=>XorFBU~GY6T(6owlPOMX*Z`BEcgmCU8dr`-dbj>^#k3`U_y72F$%csshJx weDiTFf7$oIcYy1^dj1;%mA&JC0Y!YkoB#j- literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/channels/base.py b/infrasynth/notifications/channels/base.py new file mode 100644 index 0000000..12b4d7a --- /dev/null +++ b/infrasynth/notifications/channels/base.py @@ -0,0 +1,32 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + +from infrasynth.shared.results import Result + + +@dataclass +class Attachment: + filename: str + content: bytes + mime_type: str + cid: str | None = None + inline: bool = False + + +class BaseChannel(ABC): + @abstractmethod + def send( + self, + recipient: str, + subject: str, + body: str, + is_html: bool = True, + attachments: list[Attachment] | None = None, + ) -> Result[bool, str]: ... + + @abstractmethod + def health_check(self) -> bool: ... + + @classmethod + @abstractmethod + def from_config(cls, config: dict) -> "BaseChannel": ... diff --git a/infrasynth/notifications/channels/email_sendgrid.py b/infrasynth/notifications/channels/email_sendgrid.py new file mode 100644 index 0000000..3b9106b --- /dev/null +++ b/infrasynth/notifications/channels/email_sendgrid.py @@ -0,0 +1,90 @@ +import base64 +import logging + +import requests + +from infrasynth.shared.enums import ChannelType +from infrasynth.shared.results import Result +from infrasynth.shared.settings_utils import get_setting + +from .base import BaseChannel + +logger = logging.getLogger(__name__) + + +class SendGridChannel(BaseChannel): + """Email channel that sends via the SendGrid v3 Mail Send API. + + Configuration keys (read from ``ChannelConfig.config``): + - ``api_key`` (required) + - ``from_email`` (falls back to DEFAULT_FROM_EMAIL) + - ``timeout`` + """ + + channel_type = ChannelType.EMAIL + API_URL = "https://api.sendgrid.com/v3/mail/send" + + def __init__(self, api_key=None, from_email=None, timeout=30): + self.api_key = api_key + self.from_email = from_email or get_setting( + "INFRASYNTH_NOTIFICATIONS", "DEFAULT_FROM_EMAIL", "noreply@example.com" + ) + self.timeout = timeout + + @classmethod + def from_config(cls, config: dict) -> "SendGridChannel": + config = {str(key).lower(): value for key, value in (config or {}).items()} + return cls(**config) + + def send( + self, + recipient: str, + subject: str, + body: str, + is_html: bool = True, + attachments=None, + ) -> Result[bool, str]: + if not self.api_key: + return Result.err("SendGrid API key not configured") + + payload = { + "personalizations": [{"to": [{"email": recipient}]}], + "from": {"email": self.from_email}, + "subject": subject, + "content": [ + { + "type": "text/html" if is_html else "text/plain", + "value": body, + } + ], + } + if attachments: + payload["attachments"] = [ + { + "filename": attachment.filename, + "content": base64.b64encode(attachment.content).decode(), + "type": attachment.mime_type, + } + for attachment in attachments + ] + + try: + response = requests.post( + self.API_URL, + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json=payload, + timeout=self.timeout, + ) + except requests.RequestException as exc: + logger.exception("SendGrid request failed for %s", recipient) + return Result.err(str(exc)) + + if response.status_code >= 400: + return Result.err(f"SendGrid error {response.status_code}: {response.text[:500]}") + return Result.ok(True) + + def health_check(self) -> bool: + return bool(self.api_key) diff --git a/infrasynth/notifications/channels/email_smtp.py b/infrasynth/notifications/channels/email_smtp.py new file mode 100644 index 0000000..23b58e7 --- /dev/null +++ b/infrasynth/notifications/channels/email_smtp.py @@ -0,0 +1,99 @@ +import logging + +from django.conf import settings +from django.core.mail import EmailMessage, get_connection + +from infrasynth.shared.enums import ChannelType +from infrasynth.shared.results import Result +from infrasynth.shared.settings_utils import get_setting + +from .base import BaseChannel + +logger = logging.getLogger(__name__) + + +class SMTPChannel(BaseChannel): + """Email channel that sends via a configurable SMTP backend. + + Configuration keys (read from ``ChannelConfig.config``): + - ``host``, ``port``, ``username``, ``password`` + - ``use_tls``, ``use_ssl``, ``timeout`` + - ``backend`` (dotted path, defaults to Django's SMTP backend) + - ``from_email`` (falls back to DEFAULT_FROM_EMAIL / INFRASYNTH_NOTIFICATIONS) + """ + + channel_type = ChannelType.EMAIL + + def __init__( + self, + host=None, + port=None, + username=None, + password=None, + use_tls=True, + use_ssl=False, + timeout=30, + backend=None, + from_email=None, + ): + self.host = host + self.port = port or 587 + self.username = username + self.password = password + self.use_tls = use_tls + self.use_ssl = use_ssl + self.timeout = timeout + self.backend = backend or "django.core.mail.backends.smtp.EmailBackend" + self.from_email = ( + from_email + or getattr(settings, "DEFAULT_FROM_EMAIL", None) + or get_setting("INFRASYNTH_NOTIFICATIONS", "DEFAULT_FROM_EMAIL", "noreply@example.com") + ) + + @classmethod + def from_config(cls, config: dict) -> "SMTPChannel": + config = {str(key).lower(): value for key, value in (config or {}).items()} + return cls(**config) + + def send( + self, + recipient: str, + subject: str, + body: str, + is_html: bool = True, + attachments=None, + ) -> Result[bool, str]: + if not self.host: + return Result.err("SMTP host not configured") + try: + message = EmailMessage( + subject=subject, + body=body, + from_email=self.from_email, + to=[recipient], + connection=self._get_connection(), + ) + if is_html: + message.content_subtype = "html" + for attachment in attachments or []: + message.attach(attachment.filename, attachment.content, attachment.mime_type) + message.send() + except Exception as exc: # noqa: BLE001 + logger.exception("SMTP send failed for %s", recipient) + return Result.err(str(exc)) + return Result.ok(True) + + def health_check(self) -> bool: + return bool(self.host) + + def _get_connection(self): + return get_connection( + backend=self.backend, + host=self.host or None, + port=self.port, + username=self.username or "", + password=self.password or "", + use_tls=self.use_tls, + use_ssl=self.use_ssl, + timeout=self.timeout, + ) diff --git a/infrasynth/notifications/channels/sms_twilio.py b/infrasynth/notifications/channels/sms_twilio.py new file mode 100644 index 0000000..08bc29c --- /dev/null +++ b/infrasynth/notifications/channels/sms_twilio.py @@ -0,0 +1,62 @@ +import logging + +from twilio.base.exceptions import TwilioRestException +from twilio.rest import Client + +from infrasynth.shared.enums import ChannelType +from infrasynth.shared.results import Result + +from .base import BaseChannel + +logger = logging.getLogger(__name__) + + +class TwilioSMSChannel(BaseChannel): + """SMS channel that sends via the Twilio Programmable SMS API. + + Configuration keys (read from ``ChannelConfig.config``): + - ``account_sid`` (required) + - ``auth_token`` (required) + - ``from_number`` (required, E.164 format) + """ + + channel_type = ChannelType.SMS + + def __init__(self, account_sid=None, auth_token=None, from_number=None): + self.account_sid = account_sid + self.auth_token = auth_token + self.from_number = from_number + + @classmethod + def from_config(cls, config: dict) -> "TwilioSMSChannel": + config = {str(key).lower(): value for key, value in (config or {}).items()} + return cls(**config) + + def send( + self, + recipient: str, + subject: str, + body: str, + is_html: bool = True, + attachments=None, + ) -> Result[bool, str]: + if not (self.account_sid and self.auth_token and self.from_number): + return Result.err("Twilio credentials not configured") + try: + client = Client(self.account_sid, self.auth_token) + message_body = (body or subject or "")[:1600] + client.messages.create( + to=recipient, + from_=self.from_number, + body=message_body, + ) + except TwilioRestException as exc: + logger.exception("Twilio send failed for %s", recipient) + return Result.err(f"Twilio error {exc.status}: {exc.msg}") + except Exception as exc: # noqa: BLE001 + logger.exception("Twilio send failed for %s", recipient) + return Result.err(str(exc)) + return Result.ok(True) + + def health_check(self) -> bool: + return bool(self.account_sid and self.auth_token and self.from_number) diff --git a/infrasynth/notifications/channels/telegram.py b/infrasynth/notifications/channels/telegram.py new file mode 100644 index 0000000..c9b9e25 --- /dev/null +++ b/infrasynth/notifications/channels/telegram.py @@ -0,0 +1,71 @@ +import logging + +import requests + +from infrasynth.shared.enums import ChannelType +from infrasynth.shared.results import Result + +from .base import BaseChannel + +logger = logging.getLogger(__name__) + + +class TelegramChannel(BaseChannel): + """Chat channel that sends via the Telegram Bot API. + + Configuration keys (read from ``ChannelConfig.config``): + - ``bot_token`` (required) + - ``chat_id`` (optional default chat, overridden by recipient) + - ``timeout`` + """ + + channel_type = ChannelType.TELEGRAM + API_BASE = "https://api.telegram.org/bot{token}/{method}" + + def __init__(self, bot_token=None, chat_id=None, timeout=30): + self.bot_token = bot_token + self.chat_id = chat_id + self.timeout = timeout + + @classmethod + def from_config(cls, config: dict) -> "TelegramChannel": + config = {str(key).lower(): value for key, value in (config or {}).items()} + return cls(**config) + + def send( + self, + recipient: str, + subject: str, + body: str, + is_html: bool = True, + attachments=None, + ) -> Result[bool, str]: + if not self.bot_token: + return Result.err("Telegram bot token not configured") + chat_id = recipient or self.chat_id + if not chat_id: + return Result.err("No chat_id provided") + try: + payload = { + "chat_id": chat_id, + "text": body or subject, + } + if is_html: + payload["parse_mode"] = "HTML" + response = requests.post( + self.API_BASE.format(token=self.bot_token, method="sendMessage"), + json=payload, + timeout=self.timeout, + ) + except requests.RequestException as exc: + logger.exception("Telegram send failed for chat %s", chat_id) + return Result.err(str(exc)) + + data = response.json() + if not data.get("ok"): + description = data.get("description") or f"HTTP {response.status_code}" + return Result.err(f"Telegram error: {description}") + return Result.ok(True) + + def health_check(self) -> bool: + return bool(self.bot_token) diff --git a/infrasynth/notifications/filters.py b/infrasynth/notifications/filters.py new file mode 100644 index 0000000..2cc717b --- /dev/null +++ b/infrasynth/notifications/filters.py @@ -0,0 +1,37 @@ +import django_filters + +from .models import ChannelConfig, NotificationDispatch, NotificationTemplate + + +class NotificationTemplateFilter(django_filters.FilterSet): + class Meta: + model = NotificationTemplate + fields = { + "slug": ["exact", "in"], + "channel": ["exact"], + "namespace": ["exact"], + "is_html": ["exact"], + } + + +class NotificationDispatchFilter(django_filters.FilterSet): + class Meta: + model = NotificationDispatch + fields = { + "status": ["exact", "in"], + "channel": ["exact"], + "recipient": ["exact", "icontains"], + "template": ["exact", "isnull"], + "created_at": ["exact", "gte", "lte"], + "completed_at": ["exact", "gte", "lte"], + } + + +class ChannelConfigFilter(django_filters.FilterSet): + class Meta: + model = ChannelConfig + fields = { + "channel_type": ["exact"], + "is_active": ["exact"], + "priority": ["exact", "gte", "lte"], + } diff --git a/infrasynth/notifications/migrations/0001_initial.py b/infrasynth/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..7572b17 --- /dev/null +++ b/infrasynth/notifications/migrations/0001_initial.py @@ -0,0 +1,90 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +import django.db.models.deletion +from django.db import migrations, models + +import infrasynth.shared.enums + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ChannelConfig", + fields=[ + ("slug", models.SlugField(max_length=100, primary_key=True, serialize=False)), + ("channel_type", models.CharField(choices=infrasynth.shared.enums.ChannelType.choices, max_length=20)), + ("display_name", models.CharField(max_length=200)), + ("config", models.JSONField(default=dict)), + ("is_active", models.BooleanField(default=True)), + ("priority", models.PositiveSmallIntegerField(default=0)), + ], + options={ + "db_table": "notifications_channel_config", + }, + ), + migrations.CreateModel( + name="NotificationTemplate", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100)), + ("name", models.CharField(max_length=200)), + ("channel", models.CharField(choices=infrasynth.shared.enums.ChannelType.choices, max_length=20)), + ("subject_template", models.CharField(max_length=500)), + ("body_template", models.TextField()), + ("is_html", models.BooleanField(default=False)), + ("namespace", models.CharField(blank=True, default="", max_length=100)), + ("metadata", models.JSONField(default=dict)), + ], + options={ + "db_table": "notifications_template", + "unique_together": {("slug", "namespace")}, + }, + ), + migrations.CreateModel( + name="NotificationDispatch", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("recipient", models.CharField(max_length=255)), + ("channel", models.CharField(choices=infrasynth.shared.enums.ChannelType.choices, max_length=20)), + ("subject", models.CharField(max_length=500)), + ("body", models.TextField()), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("sent", "Sent"), + ("failed", "Failed"), + ("retrying", "Retrying"), + ], + default="pending", + max_length=20, + ), + ), + ("error_message", models.TextField(blank=True, default="")), + ("attempt", models.PositiveSmallIntegerField(default=0)), + ("next_retry_at", models.DateTimeField(blank=True, null=True)), + ("context_snapshot", models.JSONField(blank=True, default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("request_id", models.UUIDField(blank=True, null=True)), + ( + "template", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="dispatches", + to="infrasynth_notifications.notificationtemplate", + ), + ), + ], + options={ + "db_table": "notifications_dispatch", + }, + ), + ] diff --git a/infrasynth/notifications/migrations/__init__.py b/infrasynth/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eea71bffd7ba0cbd949c34889e6cb576fca6f07a GIT binary patch literal 4287 zcmc&%O;FoN7MA>P^AnjE2w+TXOeQA5h9nLXhs^9G3<(J&jv(2o?1Ca&jZq>?L~66c zoO0OO^Bg&5Pg{HA$dO}?vd2|&^<`5vr@gr*)LdtuEEz;%vYEryQia|9^z?qd@4eUU zOT9fI3jTh3BE3-0P}I-nrv2x&7w_}1culQSEWJo!8ophNu6u6mzURT7F-z{nzJ4lB zvF<^N^{lz`y=^1M-`CvM8v9v#oJx8BZEhc?TqIN$ODc~=Mb?O~tO!WbipQ@})QG)# z|Hfy=u?8`)6n3o#v74o_Crx4Rny-^bh{wgc*8=%Ir#WjKr`83QW<9KT){g6{i}itS zq?7JiSN`0`^89cIS+s*J_OWa|!un@jmK;0>PV>C}mJQC*M|vU9JO8n@IJFjLyBvDm zpqK2V2RS?SdO+_&Cp}=fj290I+`PWW82g@DR1=yc}KP&pTT(icQT$hE#~##PT=(aV{-ho2tQyl33~sH z$%)f!r71S|h-NRqB>2L?(#6wkLhnS`lw)R0g63!RE_(^|E_dR+)sL4Q7N67vd^T}e zB0)1UZHq4=LII-Qg2c-&Rg-87i$ED+Uf?lL22SSKHc!q}Wbsu6aabuKyopqDBCC18 zm&e6T5>!!9+!B$DiQf*9c+8-PPs2Q}04!Tb7DTy}a;rV2|KlKfkg6&w2Z3sQ2}o4O z+s}!g$7Up$^vLLY%&7=N3Gmp~0pKf=L>M3fn~yb_-_kY}OhPuOrYddYEGvKEctL=Q zQGf{qki%0FQW0dQVGdXc93>qx>&DCrX+KPf@`lQ5J2KwnPGFRF?%J=WyjJvP?~$M) zb8u4wU!+?_Ci_B*Q{uwPhk&;|{?okfsR0v#IDDi$urmh}@#k&)T+ksV&Yl(cgp&7d z@S=nS;#+8agCx+x39u#s)$|T80)B6^SMJ0SpzN@Pe013ULVq zXnm74xNMWa%D1cB!zW8iB&0xy_6@wB#X6$=%NG>%i}0E74NPmw}qQz@fN zSyB0I{$(bwDrVDanPViT3cQ9g1-|$aP<6;N&Z^Qf$JhSM^z`%$XVa11+EL-6PqjYG zQK#`h$=|3(Z{O>?@6PtWUTXTN;8`OOs|8|>@cDP)!QBC4C{-Iu?X4UL`qMwvhyJWH zYmM;On*k#}Q;W~+Pa3%g)!YLk_qdvSY~qxE``p=&jo5tpmP3dQWE- zn?3$WZ?lgIkLb}{)9vc-J?!rZhMG}F*k8O0_y4$U3{KVtC-*&Xlln7Gm(}{9rZc$V z%&0zOId&x377gz%7}3kM=;ggu5nYdVu{gj*IfwPYr)#nF z-s-^>oqbY|J=K{%+F4K4MyK|K`shuanQNX6LDuJ5Szl`~aXooMAI~+tZb*M54CyC7 zJN*{OrqBKHddv#r_vLz|z3;9^=XBFo~pHHWFbcpIVp$bni<+}D{0HcE4~=-k2Mdi1W&+_RCqUQ1lxUw!MT zCzf^Qp#$;#`M1gX#3P-_H^QU4+s4?{+St{DNPTQUXYSgSm#xLK`|}4-j!QbTZ0lue zW0^gnt@$nzYsEZKi%c|PiN_I|p@<2{{|u766{nw(pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(9&AUy_-YnVeXX znV(mzpPQLp1ZBp@XXa&=#K-FuRNmsS$<0qG%}KQ@Vg*_Ua!@gd@rjv{k+Fyw$N~V3 CY%{R{ literal 0 HcmV?d00001 diff --git a/infrasynth/notifications/models.py b/infrasynth/notifications/models.py new file mode 100644 index 0000000..3f68445 --- /dev/null +++ b/infrasynth/notifications/models.py @@ -0,0 +1,61 @@ +from django.db import models + +from infrasynth.shared.enums import ChannelType + + +class NotificationTemplate(models.Model): + slug = models.SlugField(max_length=100) + name = models.CharField(max_length=200) + channel = models.CharField(max_length=20, choices=ChannelType.choices) + subject_template = models.CharField(max_length=500) + body_template = models.TextField() + is_html = models.BooleanField(default=False) + namespace = models.CharField(max_length=100, default="", blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "notifications_template" + unique_together = [("slug", "namespace")] + + +class NotificationDispatch(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + SENT = "sent", "Sent" + FAILED = "failed", "Failed" + RETRYING = "retrying", "Retrying" + + template = models.ForeignKey( + NotificationTemplate, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="dispatches", + ) + recipient = models.CharField(max_length=255) + channel = models.CharField(max_length=20, choices=ChannelType.choices) + subject = models.CharField(max_length=500) + body = models.TextField() + status = models.CharField(max_length=20, choices=Status.choices, default=Status.PENDING) + error_message = models.TextField(blank=True, default="") + attempt = models.PositiveSmallIntegerField(default=0) + next_retry_at = models.DateTimeField(null=True, blank=True) + context_snapshot = models.JSONField(default=dict, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + request_id = models.UUIDField(null=True, blank=True) + + class Meta: + db_table = "notifications_dispatch" + + +class ChannelConfig(models.Model): + slug = models.SlugField(max_length=100, primary_key=True) + channel_type = models.CharField(max_length=20, choices=ChannelType.choices) + display_name = models.CharField(max_length=200) + config = models.JSONField(default=dict) + is_active = models.BooleanField(default=True) + priority = models.PositiveSmallIntegerField(default=0) + + class Meta: + db_table = "notifications_channel_config" diff --git a/infrasynth/notifications/resolvers.py b/infrasynth/notifications/resolvers.py new file mode 100644 index 0000000..104746d --- /dev/null +++ b/infrasynth/notifications/resolvers.py @@ -0,0 +1,55 @@ +import logging + +logger = logging.getLogger(__name__) + + +class VariableResolverRegistry: + _resolvers: dict[str, dict] = {} + + @classmethod + def register( + cls, + variable_name: str, + label: str | None = None, + description: str | None = None, + namespace: str = "__global__", + ): + def decorator(func): + cls._resolvers[f"{namespace}:{variable_name}"] = { + "fn": func, + "label": label or variable_name, + "description": description or "", + "namespace": namespace, + "variable_name": variable_name, + } + logger.debug(f"Variable resolver registered: {namespace}:{variable_name}") + return func + + return decorator + + @classmethod + def resolve( + cls, + variable_name: str, + recipient: str, + context: dict, + namespace: str | None = None, + request=None, + ): + if namespace: + key = f"{namespace}:{variable_name}" + entry = cls._resolvers.get(key) + if entry: + return entry["fn"](recipient, context, request=request) + + key = f"__global__:{variable_name}" + entry = cls._resolvers.get(key) + if entry: + return entry["fn"](recipient, context, request=request) + return None + + @classmethod + def get_available_variables(cls, namespace: str | None = None): + if namespace: + return {k: v for k, v in cls._resolvers.items() if v["namespace"] == namespace} + return dict(cls._resolvers) diff --git a/infrasynth/notifications/serializers.py b/infrasynth/notifications/serializers.py new file mode 100644 index 0000000..fecc3e8 --- /dev/null +++ b/infrasynth/notifications/serializers.py @@ -0,0 +1,58 @@ +from rest_framework import serializers + +from .models import ChannelConfig, NotificationDispatch, NotificationTemplate + + +class NotificationTemplateSerializer(serializers.ModelSerializer): + class Meta: + model = NotificationTemplate + fields = [ + "id", + "slug", + "name", + "channel", + "subject_template", + "body_template", + "is_html", + "namespace", + "metadata", + ] + read_only_fields = ["id"] + + +class NotificationDispatchSerializer(serializers.ModelSerializer): + template_info = NotificationTemplateSerializer(source="template", read_only=True) + + class Meta: + model = NotificationDispatch + fields = [ + "id", + "template", + "template_info", + "recipient", + "channel", + "subject", + "body", + "status", + "error_message", + "attempt", + "next_retry_at", + "context_snapshot", + "created_at", + "completed_at", + "request_id", + ] + read_only_fields = fields + + +class ChannelConfigSerializer(serializers.ModelSerializer): + class Meta: + model = ChannelConfig + fields = [ + "slug", + "channel_type", + "display_name", + "config", + "is_active", + "priority", + ] diff --git a/infrasynth/notifications/services.py b/infrasynth/notifications/services.py new file mode 100644 index 0000000..674a9e4 --- /dev/null +++ b/infrasynth/notifications/services.py @@ -0,0 +1,238 @@ +import logging +from datetime import timedelta + +from django.template import Context, Template +from django.utils import timezone +from django.utils.module_loading import import_string + +from infrasynth.shared.results import Result +from infrasynth.shared.settings_utils import get_setting + +from .models import ChannelConfig, NotificationDispatch, NotificationTemplate +from .resolvers import VariableResolverRegistry +from .signals import notification_failed, notification_sent + +logger = logging.getLogger(__name__) + + +class NotificationService: + """Public API for sending notifications through configured channels.""" + + def send( + self, + recipient: str, + subject: str, + body: str, + channel: str, + template: NotificationTemplate | None = None, + context: dict | None = None, + request_id: str | None = None, + ) -> NotificationDispatch: + """Sends a notification synchronously or asynchronously per configuration.""" + context = context or {} + rendered_subject, rendered_body = self._render(template, subject, body, context) + + dispatch = NotificationDispatch.objects.create( + template=template, + recipient=recipient, + channel=channel, + subject=rendered_subject, + body=rendered_body, + status=NotificationDispatch.Status.PENDING, + context_snapshot=self._snapshot_context(context), + request_id=request_id or None, + ) + + backend = get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_BACKEND", "celery") + if backend == "sync": + self._execute_send(dispatch) + elif backend == "thread": + import threading + + threading.Thread(target=self._execute_send, args=(dispatch,), daemon=True).start() + else: + from .tasks import deliver_notification + + deliver_notification.delay(dispatch.id) + + return dispatch + + def send_with_failover( + self, + recipient: str, + subject: str, + body: str, + channels: list[str], + template: NotificationTemplate | None = None, + context: dict | None = None, + request_id: str | None = None, + ) -> NotificationDispatch: + """Sends with automatic failover. If the primary channel fails, tries the next.""" + context = context or {} + rendered_subject, rendered_body = self._render(template, subject, body, context) + + dispatch = NotificationDispatch.objects.create( + template=template, + recipient=recipient, + channel=channels[0] if channels else "", + subject=rendered_subject, + body=rendered_body, + status=NotificationDispatch.Status.PENDING, + context_snapshot=self._snapshot_context(context), + request_id=request_id or None, + ) + + backend = get_setting("INFRASYNTH_NOTIFICATIONS", "DISPATCH_BACKEND", "celery") + if backend == "sync": + self._execute_failover(dispatch, channels) + else: + from .tasks import deliver_notification_with_failover + + deliver_notification_with_failover.delay(dispatch.id, channels) + + return dispatch + + def get_template(self, slug: str, namespace: str | None = None) -> NotificationTemplate | None: + """Retrieves a template by slug (and optional namespace).""" + qs = NotificationTemplate.objects.filter(slug=slug) + if namespace is not None: + qs = qs.filter(namespace=namespace) + else: + qs = qs.filter(namespace="") + return qs.first() + + def _render( + self, + template: NotificationTemplate | None, + subject: str, + body: str, + context: dict, + ) -> tuple[str, str]: + render_context = self._build_render_context(template, context) + if not template: + return subject, body + subject_template = Template(template.subject_template or subject) + body_template = Template(template.body_template or body) + return ( + subject_template.render(Context(render_context)), + body_template.render(Context(render_context)), + ) + + def _build_render_context(self, template, context: dict) -> dict: + render_context = dict(context) + namespace = template.namespace if template else None + if namespace: + variables = VariableResolverRegistry.get_available_variables(namespace) + for key, entry in variables.items(): + var_name = entry.get("variable_name") + if var_name and var_name not in render_context: + value = VariableResolverRegistry.resolve(var_name, "", context, namespace=namespace) + render_context[var_name] = value + return render_context + + def _snapshot_context(self, context: dict) -> dict: + snapshot = {} + for key, value in context.items(): + try: + import json + + json.dumps(value) + snapshot[key] = value + except (TypeError, ValueError): + snapshot[key] = str(value) + return snapshot + + def _execute_send(self, dispatch: NotificationDispatch) -> None: + channel_backend = self._get_channel_backend(dispatch.channel) + if channel_backend is None: + self._mark_failed(dispatch, f"No channel backend configured for '{dispatch.channel}'") + return + + is_html = bool(dispatch.template and dispatch.template.is_html) + result = channel_backend.send(dispatch.recipient, dispatch.subject, dispatch.body, is_html=is_html) + self._finalize(dispatch, result) + + def _execute_failover(self, dispatch: NotificationDispatch, channels: list[str]) -> None: + max_retries = int(get_setting("INFRASYNTH_NOTIFICATIONS", "MAX_RETRIES", 3)) + is_html = bool(dispatch.template and dispatch.template.is_html) + last_error = "No channels configured" + last_channel = dispatch.channel + + for channel_name in channels: + last_channel = channel_name + channel_backend = self._get_channel_backend(channel_name) + if channel_backend is None: + last_error = f"No channel backend configured for '{channel_name}'" + continue + + result = channel_backend.send(dispatch.recipient, dispatch.subject, dispatch.body, is_html=is_html) + if result.is_ok: + dispatch.channel = channel_name + dispatch.save(update_fields=["channel"]) + self._finalize(dispatch, Result.ok(True)) + return + last_error = result.error or "Unknown error" + dispatch.attempt += 1 + dispatch.save(update_fields=["attempt"]) + + dispatch.channel = last_channel + self._mark_failed(dispatch, last_error, max_retries) + + def _get_channel_backend(self, channel_type: str): + channels_config = get_setting("INFRASYNTH_NOTIFICATIONS", "CHANNELS", {}) + channel_config = channels_config.get(channel_type) + if not channel_config: + return None + primary_path = channel_config.get("primary") + if not primary_path: + return None + try: + channel_cls = import_string(primary_path) + except ImportError: + logger.warning("Could not import channel backend '%s'", primary_path) + return None + + db_config = ChannelConfig.objects.filter(slug=channel_type, is_active=True).first() + config = db_config.config if db_config else {} + return channel_cls.from_config(config) + + def _finalize(self, dispatch: NotificationDispatch, result: Result) -> None: + if result.is_ok: + dispatch.status = NotificationDispatch.Status.SENT + dispatch.completed_at = timezone.now() + dispatch.error_message = "" + dispatch.save(update_fields=["status", "completed_at", "error_message"]) + notification_sent.send( + sender=NotificationDispatch, + dispatch_id=dispatch.id, + recipient=dispatch.recipient, + channel=dispatch.channel, + status=dispatch.status, + ) + else: + self._mark_failed(dispatch, result.error or "Unknown error") + + def _mark_failed(self, dispatch: NotificationDispatch, error: str, max_retries: int = 3) -> None: + if dispatch.attempt < max_retries: + dispatch.status = NotificationDispatch.Status.RETRYING + delays = get_setting("INFRASYNTH_NOTIFICATIONS", "RETRY_DELAY_SECONDS", [60, 300, 900]) + delay = delays[min(dispatch.attempt, len(delays) - 1)] if delays else 60 + dispatch.next_retry_at = timezone.now() + timedelta(seconds=delay) + else: + dispatch.status = NotificationDispatch.Status.FAILED + dispatch.completed_at = timezone.now() + dispatch.error_message = error + dispatch.save() + if dispatch.status == NotificationDispatch.Status.FAILED: + notification_failed.send( + sender=NotificationDispatch, + dispatch_id=dispatch.id, + recipient=dispatch.recipient, + channel=dispatch.channel, + error=error, + ) + + +def send_notification_direct(dispatch_id: int) -> None: + dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) + NotificationService()._execute_send(dispatch) diff --git a/infrasynth/notifications/signals.py b/infrasynth/notifications/signals.py new file mode 100644 index 0000000..b1de89f --- /dev/null +++ b/infrasynth/notifications/signals.py @@ -0,0 +1,4 @@ +from django.dispatch import Signal + +notification_sent = Signal() +notification_failed = Signal() diff --git a/infrasynth/notifications/tasks.py b/infrasynth/notifications/tasks.py new file mode 100644 index 0000000..e7dd2a9 --- /dev/null +++ b/infrasynth/notifications/tasks.py @@ -0,0 +1,55 @@ +import logging + +from celery import shared_task + +logger = logging.getLogger(__name__) + + +@shared_task( + name="infrasynth.notifications.deliver_notification", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def deliver_notification(self, dispatch_id): + from .models import NotificationDispatch + from .services import NotificationService + + try: + dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) + except NotificationDispatch.DoesNotExist: + logger.warning("Notification dispatch %s not found", dispatch_id) + return None + + service = NotificationService() + try: + service._execute_send(dispatch) + except Exception as exc: # noqa: BLE001 + logger.exception("Dispatch %s failed", dispatch_id) + raise self.retry(exc=exc, countdown=60) from exc + return dispatch_id + + +@shared_task( + name="infrasynth.notifications.deliver_notification_with_failover", + bind=True, + max_retries=3, + default_retry_delay=60, +) +def deliver_notification_with_failover(self, dispatch_id, channels): + from .models import NotificationDispatch + from .services import NotificationService + + try: + dispatch = NotificationDispatch.objects.select_related("template").get(pk=dispatch_id) + except NotificationDispatch.DoesNotExist: + logger.warning("Notification dispatch %s not found", dispatch_id) + return None + + service = NotificationService() + try: + service._execute_failover(dispatch, channels) + except Exception as exc: # noqa: BLE001 + logger.exception("Dispatch %s failover failed", dispatch_id) + raise self.retry(exc=exc, countdown=60) from exc + return dispatch_id diff --git a/infrasynth/notifications/urls.py b/infrasynth/notifications/urls.py new file mode 100644 index 0000000..f684bef --- /dev/null +++ b/infrasynth/notifications/urls.py @@ -0,0 +1,17 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + ChannelConfigViewSet, + NotificationDispatchViewSet, + NotificationTemplateViewSet, +) + +router = DefaultRouter() +router.register(r"templates", NotificationTemplateViewSet, basename="notification-templates") +router.register(r"dispatches", NotificationDispatchViewSet, basename="notification-dispatches") +router.register(r"channels", ChannelConfigViewSet, basename="notification-channels") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/notifications/views.py b/infrasynth/notifications/views.py new file mode 100644 index 0000000..5d57c8b --- /dev/null +++ b/infrasynth/notifications/views.py @@ -0,0 +1,44 @@ +from rest_framework.permissions import IsAuthenticated +from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet + +from .filters import ( + ChannelConfigFilter, + NotificationDispatchFilter, + NotificationTemplateFilter, +) +from .models import ChannelConfig, NotificationDispatch, NotificationTemplate +from .serializers import ( + ChannelConfigSerializer, + NotificationDispatchSerializer, + NotificationTemplateSerializer, +) + + +class NotificationTemplateViewSet(ModelViewSet): + queryset = NotificationTemplate.objects.all() + serializer_class = NotificationTemplateSerializer + permission_classes = [IsAuthenticated] + filterset_class = NotificationTemplateFilter + + def get_queryset(self): + return NotificationTemplate.objects.all() + + +class NotificationDispatchViewSet(ReadOnlyModelViewSet): + queryset = NotificationDispatch.objects.select_related("template") + serializer_class = NotificationDispatchSerializer + permission_classes = [IsAuthenticated] + filterset_class = NotificationDispatchFilter + + def get_queryset(self): + return NotificationDispatch.objects.select_related("template") + + +class ChannelConfigViewSet(ModelViewSet): + queryset = ChannelConfig.objects.all() + serializer_class = ChannelConfigSerializer + permission_classes = [IsAuthenticated] + filterset_class = ChannelConfigFilter + + def get_queryset(self): + return ChannelConfig.objects.all() diff --git a/infrasynth/scheduler/__init__.py b/infrasynth/scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc b/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b0cb710e0ae86212da270b063e6165e659928462 GIT binary patch literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7haoROMRnv+_j qA0MBYmst`YuUAlci^C>2A0%p5#0oSYp z^Xk{|+vv$YMB)&4YdO=KcS@d3*obYykoNdmEhmH4yrtFJrP&VR#pW z0Xjn1c!gdX(pW_$R7E&bMmT$DJjy11GK0-qsAm1t*P5Z6t#0>u6t?`fn>ZvKs*Gs( z5Dp4?50Nx5l9?k@W*1QeRiueC(!yDp!)6)DJhr5LfTV+SkUy{H-nhri>jjJ_>CaZY z6B@Roy4S3CB98^4{>*Q0^<=c;v%ouB@WU3Tq94lRx)_J5DJ_|3-EnuiMH@cAW)C+& zxWeBB{0GL~csklMC7i)oY+?)NaQ<;>Uq#r4t{t8MCz#6I4x_Ti8D?!?NZwb@q_}b> zz|QXNQ*l~JO@7#3udoly?MXih*DEyiDxE0wWyHa>uX3U8ZX8D) z*62i>zNe>+JszDfSBl0}-Um%8SffeZQx3-t8-kGwStKX@Wm_SZD~; z4ipr3fDO>Oz4_fPf3quJ?MhrM$Meha(ssPC6qi;mW{^|8L^-E01XuJK?bZs)CPWW} z5LF-qRs(hhJslXKut8vh72-umsjPrDS-=;xTp~nB zDt(twDY@V5NhTn6o)9;nLU_I_T^0m{z?08TDIrX+*e1ULoa-GKF%tx_3hd8EfEnL> z^2Joi53ZMK-1ji`qKV;Y>;Q#;t literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc b/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2bad1261ee691861cb9d4d3dee15f12befd29f37 GIT binary patch literal 1385 zcmah|%}*0S6rcSrP0L52d?^}3i~&QJ5Dp%UHw_#lWHB)uV6wDRy0qPenJFJPPQ*kH zJ@)98|3*(XVqBw6x^c*Yd*%pJ0s=my_@SBJeN^wZjGo)VS z7_dq~s4S&I`ZCBYQ+6xzwB=E=z!&=aD%YOeQ_4b z($bzUn7ila%PcJP?7Q7cxLb@F7P-SSWn%Rl`E_85C4dXk9G!b-eM@EPZyV)L6N}B% zy~ciHyP2A5JZa1{Q{(mh`u6pZl}UcE`4mL(83g>vjZ2%4;$jRTgD{Q|J4~X5UhSZ1 zwCpaRAyNPrOp#_m>HpL_=>4RhgWEd%r@Epw|hP?!-bELNLq zfWj=}7`hVJVANLTjuYcQQW19flZNg(< zV7PGLhx-wT9iOpKcC2s_%bjBpYN*Zo7L)Dv$CvjfREg*9XY_(Uh7b+2x&YUPrfFYE h_6J$Mu@hRdk^3^c@_BaUhQQ)x(z)IFMPL#C*dL<R literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/__pycache__/models.cpython-314.pyc b/infrasynth/scheduler/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b33472fb41471bf9e5902bb6f271d72c3e1785de GIT binary patch literal 3934 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 Ang9R* literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc b/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f517a0825a1f07a7272d21e6b667ffbc174109c1 GIT binary patch literal 369 zcmdPqRcUxIXMGTvef&P>ls%<1X8U zrt0VB7bTV@X6pwO%*)q(k})( zvska7@)n0pZa&B@c11iu;~9atSOrLYU}j`wyvv~UP*8lbL<8SVe$noV2A-Qx9-l~e KMiX}tD5L?MUSpa7 literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc b/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c19e55d0a41d250bf89d57f60150c79477e69f5a GIT binary patch literal 830 zcma))&ubGw6vt=w_a@y&BnHt#h~gngcP$NwPSg2*w)I(WiyO}mWcf-tVYjcWT zRJ_!ae}n(cNULudNBm-|a6Re+cGVMREX7L~KC#=91)Ce6M!q)Xn? zO;ELd2vC)n-ig9~(n3rb2DDv;%({;bf}~HqIH3qvWmas2ZPZHosI?Q2-fjmSHxOl| zjN%3jXhJex6ru;uP?*q89A#9coRU$2%&qXs6!3D!en3zZ3=qqq>=qdn7s3T)8kv?F zU0R&Z2m@PHi{)G0AUcfg1osIRBgYC4tb`GvPY*D!aUA2Gji-$ftcpYRi1$OVcnnw< z!4OoNX_$f0VTbTBFh!WMJdb=#97N!BNn2wsG7=Atg>H;w8yR zMB6K(MQ1+hkNy Zr@Qa=-tL`Ql{4kWM3xHDi#!%j=@%|1+-?8> literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/__pycache__/views.cpython-314.pyc b/infrasynth/scheduler/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..410cb4e76d449c307def374c2feabf0f838100ba GIT binary patch literal 5241 zcmd^DO>i7X74F%Y-JSi@$g(WSk!)GWQM__k$yl}wPU6^c>|K-C%P4F(NEt@EBWbPG z?$X_oaf%{)0y{axDGu>bTp+q}1TNe-uOe4T)n!#kK^4W#jtqtqyw@|cKVn@JRFMO8 zmELy0e)FdL{eGj9U5O}x@%L|+Zhk99$nSCCkDxc%djyjjd54%vhNKm)4C6eI38Yo7 z%DI}+(m@`Sb1f4}hj|$0iW$u4=?IUcqdY3tp-e0t=kaucC*(Su=}H^iNO$vYxz;lW z(mlLqm>eP_#EkS4Gdiba$NU2|f2UM;&U>X~3?$iCHfP$vOs+BTxk^ymW zCcoqqR!dIdEt@T0DVDfH#X+3E{(UFEib`B2$R^rkMWRg#_WRrRXB=9zOT~9VUw@lq za|6y2!!6*f74uHMc}GGTPW>%#ZDK1mGDo;#5*~Ob&ec9rQRcL)42ssr2+1be*SMPU zSUfzZ^T=E@rSRBXoF{VRv|=j#q@?pMGtf`CG1mMBn=vOkRRp`{7*GhDlBv zY1}32TgW}6q-G$eWJ4_sZ)&RkU0(1wi(P~ap>7RRq6#UI3pRBxDYJ23%S6ya9N#>xyIZRqA*S6%pn|7llo2l{xquPIF$J znw+}6p^Kqnd4byOc9}1w7Tjl93dArR80`VAB3fjYQ?}ZX+x-=9Cshm^$BEbr=_#O&6I6E#Jalsn&fkVp;i;&6s7eFen-u z8-MZGv6^^eVyUv?OsrI>y=E^@WNGE5ljm%rdDySc+sv7mxAV(RxzNI&Xk2(gW@D;) zn-1a;hdMbv2sabnXrv1N?c^>O+FZWl`;yyEbs zN`d9QLJLBKXz(X*{3DpuNRDJ%?ST?7{99Gk*~YUCo|e-(St6%)%C0F-`!JbB)5u{$ z6Pi%`9mst+GUJdOOhh;>E-pG0YH?6R(FU$5DHbk>+10XDuG|uV>N3St-Uv$D(xWh^ zXHi7sP&0+f*76K%z}VQ>v16{$Rd`qobhI_>paymZNR9l?IQZdvci-DGj@{87b{+m; z<&L_e$3BeRjorKa@zsy6KG26A8vXYN*3Z`mCbo>px;`l#(AmttmO8&YGlLr%hK5&r z!*?FI0T6;aOej>@Mk({TgtWOv5fP{{*fH0fWSF8J*B2pPSzIhRuGJ0dEKr9nSqrqX zVio44bLh(`JPl-@D<~$L80_c@S1ZOddmYF#y%D_|y*Kso^heVV^uZlHzO4^!>O&9o zqv)Bz^}^4~KP}e>&TJWH>-t&goee^=Wv`*TMU!Iii5{rDD@DctD7b8>+gMA5 zu^_<&WWOhl8BR|k!ThA(Kq9Sl2+A*{Mlo;M9CBlRl{-+8#R0E47m9hFcSL-TLbd9AA`Yn{;9&$(mVaBy4Jh%T(W+6ysq`_ z^d)OI{;Y+AU;Cm*37&i$BBA)bsr9k-fd|3yy?_=xwWpHM$wxSQ9B_c<)_c?E^Wf{~pLORo)H2m{zrfbJpg8c8pg0`x3%8Wo5ty1T-h?tZyOgj zjSE}G#kzjcjpK_Tn>#1%bF*2Pjm%&n0xdp608_BjYLP1LDDP>*Kqkw zByS*j6G&S!XOW8pKZ+QZj(m4QFyTbP~kCG64bo)B!7h1sv@TS4E3EJY&XB*Fn(Pvz`RY+zAw!Rwq zg9qM>o^4}v(-_?{o(Dt+073iUhjsmkTebW)gE#zg>=8qI+^W!Jhq|VqbMWqc7Y};^ z;#rRN_Gj?XVIZy7ZNk^~%TigK5*>?Ev;4@SgKnS}63l=4ugcHcD1-#xz)T=+`SB5h z1PhOQ5DJ`GKePS<;DpBkHF(s+2{3yc?Y#LE#U2qLZb`zITu&YrU4S<^;7vesXbOtH z6NlTGse+S-?^Ip^L*s?SbNlw2_>8%Tt$&{YEaiWLWXOuJ2iqiwlVw_j06;n==Hf$a zK_b?~f=r@AS>uJ?_;Qz@GVwR@Ij>|%@FRLn$1dC`mu|OhVF?ypSDi8-%)Boz&5fpZ zZG!zt=r6fg_7z%%r2>B}2jr2eD9SDw-6b#XlCwbnNM8PoOnyc(yJX}^XjqxPe{Oql ua&vI<34z6v%SyL$NkQJZ&B1d|2(F%t^(ja1e`ov1)aH??zYti+_x=Or1VKXp literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/apps.py b/infrasynth/scheduler/apps.py new file mode 100644 index 0000000..61fb73c --- /dev/null +++ b/infrasynth/scheduler/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class SchedulerConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.scheduler" + label = "infrasynth_scheduler" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "scheduler", + name="Scheduler", + description="Task scheduling, execution, and monitoring", + default=True, + category="system", + ) diff --git a/infrasynth/scheduler/filters.py b/infrasynth/scheduler/filters.py new file mode 100644 index 0000000..7961ae7 --- /dev/null +++ b/infrasynth/scheduler/filters.py @@ -0,0 +1,24 @@ +import django_filters + +from .models import ScheduledTask, TaskExecution + + +class ScheduledTaskFilter(django_filters.FilterSet): + class Meta: + model = ScheduledTask + fields = { + "name": ["exact", "icontains"], + "schedule_type": ["exact"], + "is_active": ["exact"], + "queue": ["exact"], + } + + +class TaskExecutionFilter(django_filters.FilterSet): + class Meta: + model = TaskExecution + fields = { + "task": ["exact"], + "status": ["exact"], + "worker_hostname": ["exact"], + } diff --git a/infrasynth/scheduler/migrations/0001_initial.py b/infrasynth/scheduler/migrations/0001_initial.py new file mode 100644 index 0000000..eb5a251 --- /dev/null +++ b/infrasynth/scheduler/migrations/0001_initial.py @@ -0,0 +1,74 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="ScheduledTask", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200, unique=True)), + ("task_path", models.CharField(max_length=500)), + ( + "schedule_type", + models.CharField( + choices=[("cron", "Cron"), ("interval", "Interval"), ("date", "Date"), ("manual", "Manual")], + max_length=20, + ), + ), + ("schedule_config", models.JSONField(default=dict)), + ("args", models.JSONField(default=list)), + ("kwargs", models.JSONField(default=dict)), + ("is_active", models.BooleanField(default=True)), + ("queue", models.CharField(blank=True, max_length=100)), + ("priority", models.IntegerField(default=0)), + ], + options={ + "db_table": "scheduler_task", + }, + ), + migrations.CreateModel( + name="TaskExecution", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("celery_task_id", models.CharField(blank=True, max_length=255)), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("running", "Running"), + ("success", "Success"), + ("failure", "Failure"), + ], + default="pending", + max_length=20, + ), + ), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("result", models.TextField(blank=True)), + ("error_traceback", models.TextField(blank=True)), + ("worker_hostname", models.CharField(blank=True, max_length=255)), + ( + "task", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="executions", + to="infrasynth_scheduler.scheduledtask", + ), + ), + ], + options={ + "db_table": "scheduler_execution", + "ordering": ["-started_at"], + }, + ), + ] diff --git a/infrasynth/scheduler/migrations/__init__.py b/infrasynth/scheduler/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe0f7c4a2b61876abcff0a4db8a6c38e047ced92 GIT binary patch literal 3027 zcmb7GOHdm}8lKT(^gxIa2rvSK^}uU^EkSU?CQjKVBE)v$pdCmmNi9r`W*RYOG(&n? z#Q5aH)}C|P;^;kX?afDyKKdxDR3%kkLRC(=sbVVE?B5zm$Q7~WP({z*-QWC=@BgM- zi{arQ2Y~6HbaH8hF$_A-3u8mb#C$ih;Ck#Xg`eUE83p2kRLR4Jj33$tp$)K^h%8 z#Clo5$MOL>rU4saT`}~hh=z_3E?PE{O{I*ehmGwsNO>X~$Sje4DpXAU`wF4~0s-Vh zh3xC+@I7aEJOUNbVY`!nOpXuPyFPW_(p6+T+atYg)iU?=Qii8K4ecovgHVrxOK`>= z{Lp>|bX!(bq8}mZg?JU_%Ud)Q^T6@shYBpLlVj@FiZW4(2BP7vcMJ>mLB@^QxkrUP z9T}QUeb%9Kjt(<&K7E4J3Y*igiVOr?v@6mzD%eCJ6~G@NQ`5~7_+h6k9%n`K0Ui0l!UqV;`<6`{ z!dVJ5h`OoSPm{X2hZXzSB>Qr&x@_0doHA*^GG(|KATAAIWUv-H9dr?kj&;#k?m4D@ z3v1AIv-EW6=0!38g)7GcM0->Wym<03!pyIm1mPpap#BFPi(xbfLp|#-!b~VDW(5$! zqxO?|!70Xzuh1b8q<|}?2_dOZatwISXIE5E2avtx7;J4%(Txg5)VJXnnJMa*+Xcxo z4azcHgCKFRL$drmL}lE;Y($p#bZiqtH<4+?ox}`Sz#q zh~21|U2!z@iC$WRk7R?Xg$DBb3htN>w>H0YOpm6kpj1P*3E(ZuK#J+?gcvm?)Olr? z(Xy?VQOCnJdO{pCxM5+Wm(0)M!xOZ}idnL#ODlqkS^tn}+?@kLd9)L(JBKJAc@>``^Ap1xNV*P4;O zrC0fSx=vOAarG?&`agD#fjju`Fl9d}?F0-hH_!8L1ddPq#9coZ@;op6 R7;K2?*JAoNj?LOG{s)ZR`-%Vn literal 0 HcmV?d00001 diff --git a/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..606cba263677ea86bae865b6032304b14ab22dee GIT binary patch literal 189 zcmdPq_I|p@<2{{|u766{Vk%pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7haoROMRnv+_j zpPQLplvt9PpI59OAD@|*SrQ+wS5SG2!zMQ$q` TaskExecution: + """Triggers an immediate execution of a scheduled task via Celery.""" + task = ScheduledTask.objects.get(pk=task_id) + execution = TaskExecution.objects.create( + task=task, + status=TaskExecution.Status.PENDING, + started_at=timezone.now(), + ) + + try: + task_func = import_string(task.task_path) + except ImportError: + logger.error("Could not import task path '%s'", task.task_path) + execution.status = TaskExecution.Status.FAILURE + execution.error_traceback = f"Could not import task path '{task.task_path}'" + execution.completed_at = timezone.now() + execution.save() + task_failed.send( + sender=TaskExecution, + task_name=task.name, + task_id=execution.id, + error=execution.error_traceback, + traceback="", + ) + return execution + + args = task.args or [] + kwargs = dict(task.kwargs or {}) + queue = task.queue or get_setting("INFRASYNTH_SCHEDULER", "DEFAULT_QUEUE", "default") + + try: + apply_async = getattr(task_func, "apply_async", None) + if callable(apply_async): + async_result = task_func.apply_async( + args=args, + kwargs=kwargs, + queue=queue, + priority=task.priority, + ) + execution.celery_task_id = str(async_result.id) + execution.status = TaskExecution.Status.RUNNING + execution.save(update_fields=["celery_task_id", "status"]) + else: + execution.celery_task_id = "" + if args or kwargs: + task_func(*args, **kwargs) + else: + task_func() + execution.status = TaskExecution.Status.SUCCESS + execution.completed_at = timezone.now() + execution.save(update_fields=["celery_task_id", "status", "completed_at"]) + except Exception as exc: # noqa: BLE001 + logger.exception("Failed to trigger task '%s'", task.name) + execution.status = TaskExecution.Status.FAILURE + execution.error_traceback = str(exc) + execution.completed_at = timezone.now() + execution.save() + task_failed.send( + sender=TaskExecution, + task_name=task.name, + task_id=execution.id, + error=str(exc), + traceback="", + ) + return execution + + task_scheduled.send(sender=TaskExecution, task_name=task.name, eta=None) + task_started.send( + sender=TaskExecution, + task_name=task.name, + task_id=execution.id, + worker=None, + ) + return execution + + def toggle(self, task_id: int) -> ScheduledTask: + """Enables or disables a scheduled task.""" + task = ScheduledTask.objects.get(pk=task_id) + task.is_active = not task.is_active + task.save(update_fields=["is_active"]) + return task + + def get_queue_status(self) -> dict: + """Returns active/scheduled/reserved task counts per queue.""" + from celery import current_app + + inspect = current_app.control.inspect() + stats: dict = {"queues": {}, "total_active": 0, "total_scheduled": 0, "total_reserved": 0} + + active = inspect.active() or {} + scheduled = inspect.scheduled() or {} + reserved = inspect.reserved() or {} + + for worker, tasks in active.items(): + for t in tasks: + queue = t.get("delivery_info", {}).get("routing_key", "default") + entry = stats["queues"].setdefault(queue, {"active": 0, "scheduled": 0, "reserved": 0}) + entry["active"] += 1 + stats["total_active"] += 1 + + for worker, tasks in scheduled.items(): + for t in tasks: + queue = t.get("delivery_info", {}).get("routing_key", "default") + entry = stats["queues"].setdefault(queue, {"active": 0, "scheduled": 0, "reserved": 0}) + entry["scheduled"] += 1 + stats["total_scheduled"] += 1 + + for worker, tasks in reserved.items(): + for t in tasks: + queue = t.get("delivery_info", {}).get("routing_key", "default") + entry = stats["queues"].setdefault(queue, {"active": 0, "scheduled": 0, "reserved": 0}) + entry["reserved"] += 1 + stats["total_reserved"] += 1 + + return stats + + def get_workers(self) -> list[dict]: + """Returns active workers and their stats.""" + from celery import current_app + + inspect = current_app.control.inspect() + workers = [] + + stats = inspect.stats() or {} + active = inspect.active() or {} + for hostname, info in stats.items(): + workers.append( + { + "hostname": hostname, + "active_tasks": len(active.get(hostname, []) or []), + "processed": info.get("total", {}).get("task", 0), + "uptime_seconds": info.get("uptime", 0), + "queues": info.get("queues", []), + "status": "online", + } + ) + return workers diff --git a/infrasynth/scheduler/signals.py b/infrasynth/scheduler/signals.py new file mode 100644 index 0000000..c6ba739 --- /dev/null +++ b/infrasynth/scheduler/signals.py @@ -0,0 +1,6 @@ +from django.dispatch import Signal + +task_scheduled = Signal() +task_started = Signal() +task_completed = Signal() +task_failed = Signal() diff --git a/infrasynth/scheduler/urls.py b/infrasynth/scheduler/urls.py new file mode 100644 index 0000000..6ec7b5a --- /dev/null +++ b/infrasynth/scheduler/urls.py @@ -0,0 +1,13 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ScheduledTaskViewSet, SchedulerStatusViewSet, TaskExecutionViewSet + +router = DefaultRouter() +router.register(r"tasks", ScheduledTaskViewSet, basename="scheduler-tasks") +router.register(r"executions", TaskExecutionViewSet, basename="scheduler-executions") +router.register(r"status", SchedulerStatusViewSet, basename="scheduler-status") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/scheduler/views.py b/infrasynth/scheduler/views.py new file mode 100644 index 0000000..b6b3492 --- /dev/null +++ b/infrasynth/scheduler/views.py @@ -0,0 +1,87 @@ +from rest_framework import viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .filters import ScheduledTaskFilter, TaskExecutionFilter +from .models import ScheduledTask, TaskExecution +from .serializers import ScheduledTaskSerializer, TaskExecutionSerializer +from .services import TaskService + + +class ScheduledTaskViewSet(viewsets.ModelViewSet): + queryset = ScheduledTask.objects.all() + serializer_class = ScheduledTaskSerializer + permission_classes = [IsAuthenticated] + filterset_class = ScheduledTaskFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("scheduler", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return ScheduledTask.objects.all() + + @action(detail=True, methods=["post"]) + def run(self, request, pk=None): + task = self.get_object() + svc = TaskService() + svc.run_now(task.pk) + return Response({"status": "triggered"}) + + @action(detail=True, methods=["post"]) + def toggle(self, request, pk=None): + task = self.get_object() + svc = TaskService() + svc.toggle(task.pk) + task.refresh_from_db() + return Response({"is_active": task.is_active}) + + +class TaskExecutionViewSet(viewsets.ReadOnlyModelViewSet): + queryset = TaskExecution.objects.all() + serializer_class = TaskExecutionSerializer + permission_classes = [IsAuthenticated] + filterset_class = TaskExecutionFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("scheduler", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return TaskExecution.objects.select_related("task").all() + + +class SchedulerStatusViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("scheduler", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + @action(detail=False, methods=["get"]) + def queue_status(self, request): + svc = TaskService() + data = svc.get_queue_status() + return Response(data) + + @action(detail=False, methods=["get"]) + def workers(self, request): + svc = TaskService() + data = svc.get_workers() + return Response(data) diff --git a/infrasynth/security/__init__.py b/infrasynth/security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..15e08f213a944b47e6c2ea03849ad8ee69312d3f GIT binary patch literal 177 zcmdPqSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHJV rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{>Uvb literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/apps.cpython-314.pyc b/infrasynth/security/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..70380254158559eebb416f93fce5ca60b724a38a GIT binary patch literal 1056 zcmYjQ&1(}u6rW8t$>yUi{YooXx>grQ!DAoCE)5I zfDt-D*w{yVhBQ{uBB~*rSwc8_Y;0y@KbgViJyg&A)@t2Q#a6$+69yfx>%xz}wCWVq`w-yP0-L5EWj1@g2Zl1MdSB&pF!Thkp{@Pomut1SSx z!}nqMBjYfoMtdd)XK)sqIEVAN@FclkMc9ItiyWxcl+5fhDhHfl*7bzsk+Ra-!`XBw zd(Cb+4ijRy2{W9sKza_9UKp&~6!egL!$jTKZLQm_!+ralMFP<4w>x{Sw<<3pAz80( zDl?!xrphi84)^*xhbp+NLkGT879?fekVgmgthzfvQ73LuGHLeiyen9=?W`>ZC$A$F0fCV93&n0vbpgx2<*e70w zlq%=bHuL$s#*2gq32)*MDkb;Y1IYx$&JyDIR0!8|q{Dol5ZHjJ2_uB*joapPAh}+d z8({(vt04Y;f-%Q;o_;l}l;EQR5e!_C{5oi{<{vzXqRgG>O mbrEGfiznaWHBfbRu>j&SYZ%4_s{P4N8>P?ht`Yp*MEwVP=q4io literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/models.cpython-314.pyc b/infrasynth/security/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..80ca907d4aea0fbaf4ee448499fa79b31c31a3b8 GIT binary patch literal 7159 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 literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/serializers.cpython-314.pyc b/infrasynth/security/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..794ef72caa4d4dea33f398ca8eb183c6f79adb5d GIT binary patch literal 4945 zcmb7H-EULL6`yP0d*jd8**I}_lO;|TQcOY|AOu7yyX7Md1+v+@K&7&9bz@JGyScH= z+;Jdmh5UfFAg#1K_NhXBf;ajf^s$e`MC)j@l`8e2>f5$SyM1fVnQJ>v_Jx~ZKlRLyvE|VFBD}&^YV)!omX8hbg zqjGgd=S)f%4cUA2SzZ>wAzCx-T^!gT{oW!%R* zVAX(JRTtg6R~^4bxg~sdP6Tb&bk|%?S6-0WbO(#TmV}zOR^U{wQ(6?^9CfqIE^@n& z7vUmhE4J%G-DOdPY9(OEO$SB5G;x+`ijZlp6mlg8>k-quTe6(mNX#@BZ02&u&eMFs zG+BpS0?^bE`hrcJoGaptS~;_jch&$bdwNXMwWMNf2L?&3NLmCc17t);9-I&qiPMk8cF}*Z_>N11NAuvoCa(z(x-Wyb=s2 zdw_ndAU zEzGl~&>~(InltCuJciPG&CJktL?8?5M+0F8;gmuL^}AqdA$q@PGf=d})4`UEqO)Xu zDBef$0g6@xOVxY?qey&1lRc=(&pb_zz3R`j&?FngCHJUn2>Vc=`ETv0YXa3Z;i(IU z-%4GHw(5#|F6JHe)zemAJ$u(}Rv43Rtw~{;R2VyiXCFZ^j^ZaE_R$U6@>}cXD1h;{ zG<4I8y1DS~x*5fwrS**i(kcWUYq>_CxGiC-?@G-%wB0!$b&hEn@5t?)HuQxE(RH#wt-jBP)A~ zJe%G)TMSUCJrHP0REH60tk(4g+zwOk8IU9JZX*OTVT5_lO~QMP2n4*Sh@!J&{(``B^Vq6jWgr)Y9|pQn97 z%P!eYPITnxf>m;OT45&vGYuml%W(?#%si|v0*u}*fw#$J>me48j#lC)B(QZJDrNEo z_Q^8WjH>t5U2?6Bf5=vb+*HmhDuJ{5W~DPp`x#<7B#vkbAHrHu*(km0xw=RO^}EAi2^_NRS+W zbI;Z9S<_PWl?GuPI8kQPexccrf4HKYRbeU!r#@(Eqq{uap!CaS@qN^t1;#f{*x^7+khZ#A5%gj27>!{v*c(Y|VQxDp+Hetjc) zyga=b>ZyhXE1|*X2Vb4ZZG>p~!VaXrnm$)apL?DDc3P)JN2WkYJEQt5T0^y z(^>d6idw5}-GNF|3ZB8(*xI|-ZBu&o_Wpk-!PZ3VYGKjNH#sq&Idl&)ODa>@TH@0> zkwM#GGay6&Ga{LM2a2Gydj*zjnbO?A87R<}-4qB&PGcW&w!zw!M1MmW>#IWB?ccr@)z$4R>=gK#6u)@k@_qU_`SDciZqh5|2P9}wXe z{S6`vnk}qS)HiRD H$o>Bx&*Ju!x+m^xj!6Ni%em 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 literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/signals.cpython-314.pyc b/infrasynth/security/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..056b3784d7af603408b3131e737da932c76c20e7 GIT binary patch literal 566 zcmY+BK~KUk7=~M>hzJZs5fc-mUL;0#@!-LDmV?INS(mbvQ3mVMb|?pv#j6*S{R;mL z*ER8Ayy(q@KVTuluK+G-~qH@3tT|~ z_rX0p0B4CUGAYthw34FL6lGJimZDsW)>D*kXO2Ka` z(xuBboHZvx76~h zK?py<;b&zhs)wa`vmPCXdaQ51K81z2x*MHOKsm1MMZF2AF5Dzku0^@97|*tsBGc8h EKkxdRdH?_b literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/urls.cpython-314.pyc b/infrasynth/security/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..66c804bdd371d588a9b160ac0b6f7507f67f4c66 GIT binary patch literal 2300 zcma)-&rjQC7{~44*l*&Pp9usgKhmxwfhF!*y3)d`p-o#@e~7tNCk=VX1e1h>7`=W2 zi?;*YWlXzdr*Z5tyXCkee?o+j$Sh4e?6kwAvQFw{hrMqc6E_-BE#>j^VW!!lIRsGMI$=pL7piRdscc<3}UcPOFb*z z6pPpthqx3#Amu~8lppz1JmOOU6qur9CPK}(S|ULfRwtZ=+INYOIf;?Hckqhj!qork zSecjDH_&miPvYJ{2V}nlUekFZl;o4a>ZCo1+v18};zfF%wzE<|5}b);!u59H`dnOp z2RG1#8+36)9bE8IE+h@RxRDMnEC(*#->8cl>)^(_a93R1LcU-jaWfrURAw*zUt%sU-oedw;oSS1>)_s&=?FRL)9zDd`_37g_t;KF}n)N#%W9VP~B2W1te)Dq{2973CjL|!fHcm}Yc z=xR~fQ7tdTkZ!RV(pa}R*lejAY(dN9iXxs`(~;$EMuuO(vm}~6Zo-fT28Tnu6VW0B zb`0JTkpMt-3#xinB%Pa{5op$(h)rkJbY8T2GI;CWX3$!u<=@;=L|sLtUD5J3?}iVj z61F=)s(UKTZI$heXa($W-4^r{ty^I5Z3NG3mkfVfW(0^}Gp8#X1r?9o@|s-22?U~n zYFfj5S+wvjZMO+0E}{XYfYMn-#J@s8EoN{(--U1V+toxn)7#S9Q;CMqQrNLYJ&q4r z{N}b&%xK9HEa;HDharQFHH`mdw7v!LBiYwrJ_(zrONU-k^zPxar9=D$p&&;LAjU@w z`MyA&m;g2wYjb2rnVgQ#r^PAyI>9Z5tZ4Dr;)9GUMg3>$C1xn=6EUmpsN#+Wl|3ad zu0m~FO(R{*74bZJxromUId9^IlFqBeO;;X2*>nl!P+26+le=XLEbVBUrGk1N&f?bO z4X*!%?Ta3orhlid|3=+-#Q@s#io)i+X@Zo0; z_w-ix>(M*r(84cYpE3_|tpS1!5IzIbCt&*dOf_1M+%&SQ8q literal 0 HcmV?d00001 diff --git a/infrasynth/security/__pycache__/views.cpython-314.pyc b/infrasynth/security/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..81b2c5847ad1b1b605304b50c828ce061f53f31e GIT binary patch literal 19786 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 literal 0 HcmV?d00001 diff --git a/infrasynth/security/altcha/__init__.py b/infrasynth/security/altcha/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3bbbf07268f47f37eed43a4c4a6fa34190c8a2a2 GIT binary patch literal 184 zcmdPq_I|p@<2{{|u766{??+pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= ym{XFRk*FUZpP83g5+AQuPKRZ7J3`tB0kSt44v-u$wC~GFcZMq@Ti9JaMY>#_q z0tD?tR8?wrrD|970bVMFcBQ3n>|6Jt(r)|Gz9j@?GfkyRT^@J~3M!~C?KyXBC%Cj+ z+2`JS?z#7#d+s^kxpUmttNgpRU!Xzh3JkWsYWE_EUoQ&gQ98ef!8W^WR5=}8p zOt<6)wLMmot;U2(Z)h3y_V-d$*XMfRcT=a=W0|UUu{oW_l!B@C7)2!?D^lH5Gp42& zdNUlTjDIYsHxK{q7?5+^GD(B^27XRR7PtqM7MCoqEGvbAZYriK%htX(?9#h? z%nxc9dw{%NCa*})>3MY{VnTkWQN)z(g(YTjvATWLNmq`9C(vfg%Nq= zN2#PVg)=`KAD+;<;Q-)m4d4xWA#211%Ug4FS<7020q$XWvfA97mRZc1OO{8yRn(|z z$cjl@L4Udr$$lV~TglDq;8P3v+YJ+AgFPotpNYGv6NhjE@NEMyK?hHi7t~u6PipZ6 z{U*hGr+5KYgFeeV*m8k5xL@1_DyX?RdjlWadkLU}X}fFx12EaZ6}nB{xv%$_+IOl z+Zpg|;W%vp4d1|;N?eiclQ8oCutstq;z&GDP7;7Rm{&O|rVVCJOEmP{BEp&FDlFz_ zRZ7w0SMIZ0PkvI?r4u0pJ7X--tzAdc2 z>R{E^2CKd`*_eH@N(8_}LeO~_V%z~oT_*d4+`TJ-KLv=jdWV0;_FCDK8rYw?}JW7m~70(;?Q)fF$9qZI|8JS1bL#NG&E5J)j!B_V(;s%nxU4G@-wStNMJ~V@M+b@RwrdRucP6?>7A$*! zM9D$G>JFlj_xXcF?;wB(5CSPu&Dvl$dOVcu^vg3B7N(TzHbh!ee{#HNZ&?8SxIh*#2YF#M%NpoUmX3i`#0Uc z?S0&M;%PAQU2xys^LNg#PJKCZe`c-o)Wh_{v9m2OO2NzP!OI)L zi50Qj7<}9qg=v3}b*{J{iwDbM^Jl(Ke5?6YdByiwJpI%keEqVO1fnJX@pb?4mxOb- zTEWN`5B0wcjKH6@eb7Vx*b^FZ@qco1K;MoUAAsmPQG;Ws!SDmoqtDTez+Wi|9&(dl zqev|uRDgpX10i}Gh~+dFi#he($Z;Bk4V=c1!{v;;AVBUVH!E2I!|sPeeGsH+eunEt%&n9nfAJb3GsU$m?gZTnb=*lY3D7t^jlkJbV0b++yb%~(87%t)pMCtv z$M=R-&pr%2={WP`K!3?U@W?+<4o5y;S_{N59V)-P_~|IS>EFQzhQ|_RxuE3njp~u* zyq;amVH;krKV4LEl@Y%T=tB)NrxnzK4#N$yoYgb3Ob?@V-a|47#0tT)8eY6w23|HM z)n?)328cKBdU$8lTuzqboSpO_Bat82IS&&sMS|FCbOCw&3$Vgd;r&+vJczgORs8B0 zq&=4HUbTZ8{}X+gorO(|H`@1h2 dict: + difficulty = get_setting("INFRASYNTH_SECURITY", "ALTCHA_DIFFICULTY", 10000) + expiry_seconds = get_setting("INFRASYNTH_SECURITY", "ALTCHA_CHALLENGE_EXPIRY_SECONDS", 300) + challenge_id = secrets.token_hex(32) + salt = secrets.token_hex(16) + expires_at = timezone.now() + timedelta(seconds=expiry_seconds) + + ALTCHAChallenge.objects.create( + challenge_id=challenge_id, + salt=salt, + difficulty=difficulty, + expires_at=expires_at, + ) + + return { + "challenge_id": challenge_id, + "salt": salt, + "difficulty": difficulty, + "algorithm": "SHA-256", + } + + def verify(self, challenge_id: str, solution: str, number: int) -> bool: + try: + challenge = ALTCHAChallenge.objects.get(challenge_id=challenge_id, is_verified=False) + except ALTCHAChallenge.DoesNotExist: + return False + + if challenge.expires_at < timezone.now(): + return False + + expected = hashlib.sha256(f"{challenge.salt}{number}".encode()).hexdigest() + if solution != expected: + return False + + challenge.is_verified = True + challenge.save(update_fields=["is_verified"]) + return True + + def compute_solution(self, salt: str, difficulty: int) -> tuple[str, int]: + number = 0 + while True: + h = hashlib.sha256(f"{salt}{number}".encode()).hexdigest() + if h.startswith("0" * (difficulty // 10000)): + return h, number + number += 1 diff --git a/infrasynth/security/apps.py b/infrasynth/security/apps.py new file mode 100644 index 0000000..c63c82f --- /dev/null +++ b/infrasynth/security/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class SecurityConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.security" + label = "infrasynth_security" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "security", + name="Security & Access", + description="Authentication, authorization, 2FA, API keys, ALTCHA", + default=True, + category="system", + ) diff --git a/infrasynth/security/auth/__init__.py b/infrasynth/security/auth/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cad05370efea07a4d19904e9ba5d48f8a6b5c230 GIT binary patch literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= wSXz>yA0MBYmst`YuUAlci^C>2KczG$)vkyYXa~p@#UREfW=2NFB4!{9014GE7XSbN literal 0 HcmV?d00001 diff --git a/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc b/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5889f9386e2d4e6c2b9c7ce1f342ff01622ea409 GIT binary patch literal 3334 zcma(TTTCO@_0HJi2N=xG0xk;_0~?4#Fb$xkT}Tt6KtY=jXYis{f}PHeFUu_B8RyP5 zENB(+S0Yty^R;RF5%QJYuddok{oQ`_-xMUsh*eTm{pcSNs2laCJ!kB}#!96g*=No@ z_uO;OJ@-8BP;*lQ0r<~t^Zug{A%Dk#4^o9VGlAG4cZed)lR1G4JxGi5;#`0SJUTER zoRhfZ(ZTu9T$qP}78PkeG8g619@0j7i4sZ>B|I*cwekj!7Xe;$MH&xOxQ$9fKS?$I z6Nf3mZPwSQzOEKD$Jw-54mc5R1+X`5Lvjp=;c*E#S6Z=FDn_NpWRp zIx~NZZt3Nn%D7?-IeQz%8XR(ka6utlyc6Ytc4EVkc~A)}fdnxHF0F)=V1kTSH1Mz@ zB?ylwAs$^Kyg?Dc^oZNAxaDw~zwJ=A{o1r;TU&X%=p-GFn=Ar5xoPmVq@&vf>LfKM zS)eR$I1ZTaq!)DGCfo&Sa82awr$FqGtS~7I0)=o7_+6xjq{$Mo$}GSNjs$L{Gi^g#AIUKLKGnH1Vpyw8bG9tLHhdQ@Z{)6~uTv{m!yj>| zUStN}8o|9sw1T0o(=8`m*m5JPYFGwWRrhMu$7!D#!|jIw?U3X4u1Dst%sp{8`18i& zbKd_6d0+uomDrI6uIy z0QUT$`8Vt)YK%hH`4d-h6(-XT`CGh|wE}f%L%b4i!#+<2uP_5tJ;WdwM+0DtxGh(A zdbxaAue(AD48$v72|%@0hE>(!47^se47}#?JlQr_08YEozxVd%w?4bI_xe+z6dx@` zMm<|o5{si+^e<~d)QkwXN(l?8M!XhF%Tw5(K~?j1u4p11Q`HZOnpv5NtLm!39BvvG zwQQIMqjI*cs!RqwZlu5<85rMU-$C8o$ZJCrA*pp$v&@ zrm8*>+@=~PEW;~$)4L)TMSY0y?+gL@=4Wu&FM^ZjK}kxSHj_~E*PFwwR>r&+igK1t+oj^wJkR{>U zLMnryR|j7B1?!kF%%H|PPy|Kv3wj0}TnB!ojFC}syyCTjxFlY#VG0CwCs`6R7*chh z1n&s`4x4ZiHolBW=s^ZUrw%e0%XN?qC?O?0ft3j$gCSoBzMTarD_z(I@!mz2G4wr9 zBK`^1gyjyO`;(c>HJ`42eT)C6u4q+PR?2i$SNfE{GWR{S;@f4Gzw1k9l0H@a_FnX< z?}MLm^`~j5TJZns$x0ShJrBU5mwdYVRT|TR5}T;pbPrkfkAWw6xhm&VN|P^}mBye0 zCCM^Y84p4v2gL?ET+JOwRjUm$8R#a4kZN|NxokG0y27w4s0*%;-cHP08=7h4l8`J( z=pM;co8>j0&O)yW6&PJL9=K5~(KGb5Nq-h0#b4emB9X=ahmFlIA`-G_w%xncXR*F4?Os$p`J_DU(ksi326&~hpC%67=>WN5_)Me2ZEOxwgR%OKha^na=|wASyvXv)Aq5}?xd zY6=r|d%-Wa=+@e4H>S*YV{h>FW z1)eP)49y=6y|+K~-oeoOrB{?v|Kfq1-3@;kZ~x+I;#3So-x7|yuYbDo>y^i2Plbc- z(T^5(XU{|AYR9AW$LS+^a9*n@`9(~L>?#&P9DfprP$O- zy!(^Yqk$Xy12>N16Mv0Qc#}UkkZ+b^H;)_TqsDIdl^VPE29Ns2_xr~G_~1_ufB*0g zA0GD2)QX<;41W6XHxGaN;coaa(sdk(|1$PbY;U2|Keih?jEtYOT-zJ`JpEbvu;sO< z6Q%fMDKh!Zd4jZDJ0}91#Z&d>fjnJ`O`isk^KS!ps1ql!1XkMM^jFOtJ3o36ydq7V5!c<1FQ1p9hzC_o!y z_!4qE>SX8xofhx`?|6alhTt7RK_MnUIdHubpfAc6dhf5uRQb8_L-sCAVA=6HJh)O2 oo|7BTN$+zq^_<-N2kCqfiVI_36F}$J+l8r8=kUJ>d_86V1&-nCB>(^b literal 0 HcmV?d00001 diff --git a/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc b/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02ba1f46b585ddfa41deb8a22955d8ad1f55f171 GIT binary patch literal 1558 zcmZux%}*Og6o30^gAImmh*83a6AHyNwlB0*OB5vt8c>VtF2*O4(Xw9f!r~=66eMxF9hp@TEn0L1D@$48RT0ijzKo|J0D~Pzjwo8q(Lo@Y`ay6-PpFmG6!$o zYp|MCXSQiDE3<2}TE^nYjO%51cO089nsU?y+8BNbfcV9Mzz<{ zq2#$iUj@6q?iqBy&T6?egIa~P_H*cn7pP@6Je%zocv}&G=6Ck}WM`xP*yWVZcY-)G z=vSy7!!HA4#{=Vs-6sj{C~^HHJ^bat7YE-=wQkNdZ_XU2ZyzrFno57YcdDh&MX77> zL>q2t<4tY6rDdC1_WK(@Og^8Q`O$bb`23Fk^OSz9Eg#OEtB~w#B}STwktcht#N<(8 z^4oGNcfXmt|4eS>mX2~uCtCXOqtlf3^1KWBuC-DV&D6vT5V{6^EF_`*oAzUWKfOCU z4o}DXXFrghzAvEu4zgmgn2q~!Lii&k#P25jU(#^6pCsf_!*Ifx6d~)jN10>St-6cT zF+!@YNr*Ryx%~k`C^PUMKn%t_d#%ANilBZ%Ob6LewM}MPjzb9YtfwQ0%lKou>!ne9 z{CWe$pCx?*)yvQETu2-zb}MT*YW|$+MI58fzJ@UF|K%Le*AX(T<&=Mq4Jg~N8s@a!Wrbx;GHuVd7-9+ ND^FJc1T?|ge*w~Jb29({ literal 0 HcmV?d00001 diff --git a/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc b/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ab8d5e3c506aa859ea2ffea65f1fb9329144a159 GIT binary patch literal 1573 zcmZ`(&2Jk;6rc63zw&|G#Lb6NoNZGjav}DCsL+B^jB#WGwXKb7R4L79vpb15S?{_t zB_-O)8+g#HDG01BF0d=5iOy1tlNk!7f$`p?sK! zr_nid0cpW;q=jzF3y@n39?`-xC>i-vERvEJ&op_BINUOHZn@5qZrQ{H{n^jHth{al zW!NMJZ8bQ+!41MOBb-~#h9PqKL+XRW%K&t!`ZnT{hIn8#!sRjK!Wp=t1;!B%YBCR1 z5f5t$R21~av#z^kkvBHJe^n#8$y6|?>(s5Qx})YRmGXSav0JLq4N{5It&EuJw#94e z5}^*^DI?&Q1+(ZTbRly13cxN}@he3d)T9Oc4mAQ*seC%FpQ7yFMt?1)YG9$Cr6E`4 zxxQrsb~%qUc?M0Pm9Xz#m5+UuDk?{P{mEQajy-}ERF0ikd{hbg9+lvGff=Ntl`eaP zJ%r3*vF|imfw?!dhd!j#-;62`s3-VI|%uM;my z^=&^zpNz!xV*g3cL@l$kDhy9c*W5Zu*IlaL)wj}R>TVK)v$W-`Q=PROUYlRj8A-3{ z2At76rB5?rG^xc~X&v53x>p8EHCoV?lSf0mOaH^$)M>9lsEB<7Vi!FdyU@P)e*5Z; zhhKHZR`&*<#HW5N{ZM)wpL-Oadob9EFYHDCh)p~l9X|*t(NCpk6VpFOI}?}p3QrOj zA1AInN?iH1)k$RbN(UiyCjR}-cRN4fPHg_c*X`J1JF@ulD2UEX9wAA@j?A{_KIx2P z+e6s{S!iA{QOa-UGc)M-neogu`4?17M!X2_HW*`X0OPuAHf;iU2;)0V-4?BfRSe^G zi!yFo4sl#igfTW<17kV~DZO(TGY-FqfpyNQwbtZ>fzw%x4O?f-v$tnMtmUv8v*h1TWH!-CHuEB5WIeXKpcX$=UGffy-5l*!El>(vhC8X6!EFW zdfb|O-Hw@6Z`fpWo1eZQ!9)eJdJ`waNU@p@6i;=37PrS{^-XjC7i74}Et_@ULGKh( zqRLA`h?9Adk9vJwq)AwcBeMhuxE4v;N7wdI?ge^xAAN8X8kVlN6Y0MY%-#5Z0W2S- AuK)l5 literal 0 HcmV?d00001 diff --git a/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc b/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f557bf7af507f32b6b59bde42920433b12e01e34 GIT binary patch literal 1279 zcmaJ>&2JM&6o32SkAQ;tTYIDu;>;0F<;;!$pe=`_BdQ)y550vZYEjig-(=S&qEbh@Z{N&&@ArN)Z{E(F z7(E6!PW`j7{-p`7IV z8rfw30xBMoxCzo|gEUw2(mD+>Qo>Yb6=be$$$ST7p>1R0oI3j-;MWxHe9(&fN$5!` z98PZ~RLDjmD0jL%?l~Cf_=nsa)^+-v3MAL?@pOj6Mh$mb-2VfWZCD=AgbtExW}dao zJ(vJEhnCh+gv{2`NNt+S_njJYJ{16fTRp+!I@oTzhZT6VzSxd<+bZth<8x31@Q4)%o4t z@HRI3TdLgWu}_5{83i@-KX<3`hO;h%U?{fXdHM9+%?J6Na_y&b?cv4m=N`@N%q-yZ z!_30-u?x?}X1`t7GxMXB-N|=$Cfy$=-N%Jzlb60be%JnWthzg0yLWwe`rZ52U*=%^ z%*zp&y<)sNI054m2Vjg=)LY|w7JmO0Co$WT+I$V3){f7AWIg@RK=}w3>qS*0B=UNc z5Os`@UK}K$)=PxkO1v<$lnLoFE@a3e8pUWX5E8^bA^a2qs|q1PVki1UmzAw1*d^$6 zk`O=igb0`~eHw;@U=NQTS;R1Xxjx~V9?uViR)hwA^$i};&-UD3_DHU>2PpP&HT7J? zYj7r5_oA=jMj}}#8Ue+|OMeJl|5H>UZOQafrSRiA9GeRcQ>x-zy5Y;*!8OfAR8ioe X8OATr{0(Lg@+G74!2J{Oa~Sw9B40gH literal 0 HcmV?d00001 diff --git a/infrasynth/security/auth/api_keys.py b/infrasynth/security/auth/api_keys.py new file mode 100644 index 0000000..68c9111 --- /dev/null +++ b/infrasynth/security/auth/api_keys.py @@ -0,0 +1,49 @@ +from django.contrib.auth.hashers import check_password +from django.utils import timezone +from rest_framework.authentication import BaseAuthentication +from rest_framework.exceptions import AuthenticationFailed + +from ..models import APIKey + + +class SystemUser: + """Anonymous system user with scopes as permissions.""" + + def __init__(self, scopes=None): + self.scopes = scopes or [] + self.is_authenticated = True + self.is_superuser = False + self.pk = None + self.id = None + + @property + def is_anonymous(self): + return False + + def __str__(self): + return f"SystemUser(scopes={self.scopes})" + + +class APIKeyAuthentication(BaseAuthentication): + """Service-to-service authentication via X-API-Key header.""" + + keyword = "X-API-Key" + + def authenticate(self, request): + raw_key = request.META.get(f"HTTP_{self.keyword.replace('-', '_').upper()}") + if not raw_key: + return None + try: + prefix, secret = raw_key.split(".", 1) + except ValueError: + raise AuthenticationFailed("Invalid API key format.") + api_key = APIKey.objects.filter(prefix=prefix, is_active=True).first() + if not api_key: + raise AuthenticationFailed("API key not found.") + if not check_password(secret, api_key.key_hash): + raise AuthenticationFailed("Invalid API key.") + if api_key.expires_at and api_key.expires_at < timezone.now(): + raise AuthenticationFailed("API key expired.") + api_key.last_used_at = timezone.now() + api_key.save(update_fields=["last_used_at"]) + return (SystemUser(scopes=api_key.scopes), api_key) diff --git a/infrasynth/security/auth/backends.py b/infrasynth/security/auth/backends.py new file mode 100644 index 0000000..4814189 --- /dev/null +++ b/infrasynth/security/auth/backends.py @@ -0,0 +1,21 @@ +from django.contrib.auth import get_user_model +from django.contrib.auth.backends import ModelBackend +from django.db.models import Q + + +class EmailOrUsernameBackend(ModelBackend): + """Authenticate with email or username.""" + + def authenticate(self, request, username=None, password=None, **kwargs): + user_model = get_user_model() + if username is None: + username = kwargs.get(user_model.USERNAME_FIELD) + if not username or not password: + return None + try: + user = user_model.objects.get(Q(email=username) | Q(username=username)) + except user_model.DoesNotExist: + return None + if user.check_password(password) and self.user_can_authenticate(user): + return user + return None diff --git a/infrasynth/security/auth/cookies.py b/infrasynth/security/auth/cookies.py new file mode 100644 index 0000000..603a030 --- /dev/null +++ b/infrasynth/security/auth/cookies.py @@ -0,0 +1,21 @@ +from rest_framework.exceptions import AuthenticationFailed +from rest_framework_simplejwt.authentication import JWTAuthentication + +from infrasynth.shared.crypto import decrypt +from infrasynth.shared.settings_utils import get_setting + + +class CookieJWTAuthentication(JWTAuthentication): + """Reads JWT from an HTTP-Only cookie encrypted with Fernet.""" + + def authenticate(self, request): + cookie_name = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") + raw_token = request.COOKIES.get(cookie_name) + if not raw_token: + return None + try: + decrypted = decrypt(raw_token) + validated_token = self.get_validated_token(decrypted) # type: ignore[arg-type] + except Exception: + raise AuthenticationFailed("Invalid or expired token.") + return self.get_user(validated_token), validated_token diff --git a/infrasynth/security/auth/middleware.py b/infrasynth/security/auth/middleware.py new file mode 100644 index 0000000..d8c2b97 --- /dev/null +++ b/infrasynth/security/auth/middleware.py @@ -0,0 +1,18 @@ +from django.utils.deprecation import MiddlewareMixin + +from .cookies import CookieJWTAuthentication + + +class JWTAuthenticationMiddleware(MiddlewareMixin): + """Populates request.user from JWT cookie if present.""" + + def process_request(self, request): + if not hasattr(request, "user") or request.user.is_anonymous: + auth = CookieJWTAuthentication() + try: + user, token = auth.authenticate(request) + if user: + request.user = user + request.auth = token + except Exception: + pass diff --git a/infrasynth/security/filters.py b/infrasynth/security/filters.py new file mode 100644 index 0000000..a6023a3 --- /dev/null +++ b/infrasynth/security/filters.py @@ -0,0 +1,40 @@ +import django_filters + +from .models import APIKey, Grant, Revoke, Role + + +class RoleFilter(django_filters.FilterSet): + class Meta: + model = Role + fields = { + "slug": ["exact"], + "is_system": ["exact"], + } + + +class GrantFilter(django_filters.FilterSet): + class Meta: + model = Grant + fields = { + "user": ["exact"], + "codename": ["exact", "contains"], + "expires_at": ["isnull"], + } + + +class RevokeFilter(django_filters.FilterSet): + class Meta: + model = Revoke + fields = { + "user": ["exact"], + "codename": ["exact", "contains"], + } + + +class APIKeyFilter(django_filters.FilterSet): + class Meta: + model = APIKey + fields = { + "is_active": ["exact"], + "prefix": ["exact"], + } diff --git a/infrasynth/security/migrations/0001_initial.py b/infrasynth/security/migrations/0001_initial.py new file mode 100644 index 0000000..57417e0 --- /dev/null +++ b/infrasynth/security/migrations/0001_initial.py @@ -0,0 +1,163 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +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="ALTCHAChallenge", + fields=[ + ("challenge_id", models.CharField(max_length=64, primary_key=True, serialize=False)), + ("salt", models.CharField(max_length=32)), + ("difficulty", models.IntegerField(default=10000)), + ("expires_at", models.DateTimeField(db_index=True)), + ("is_verified", models.BooleanField(default=False)), + ], + options={ + "db_table": "security_altcha_challenge", + }, + ), + migrations.CreateModel( + name="Role", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=100)), + ("slug", models.SlugField(max_length=100, unique=True)), + ("description", models.TextField(blank=True)), + ("permissions", models.JSONField(default=list, help_text="List of permission codenames")), + ("is_system", models.BooleanField(default=False, help_text="System roles cannot be deleted")), + ], + options={ + "db_table": "security_role", + }, + ), + migrations.CreateModel( + name="APIKey", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("prefix", models.CharField(help_text="First 8 characters visible in UI", max_length=12, unique=True)), + ("key_hash", models.CharField(help_text="PBKDF2 hash of the full secret", max_length=255)), + ("scopes", models.JSONField(default=list, help_text='["read:users", "write:billing"]')), + ("is_active", models.BooleanField(default=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ("last_used_at", models.DateTimeField(blank=True, null=True)), + ( + "created_by", + models.ForeignKey( + null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL + ), + ), + ( + "rotated_from", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="infrasynth_security.apikey", + ), + ), + ], + options={ + "db_table": "security_api_key", + }, + ), + migrations.CreateModel( + name="TwoFactorConfig", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_enabled", models.BooleanField(default=False)), + ("is_configured", models.BooleanField(default=False)), + ( + "method", + models.CharField( + choices=[("totp", "TOTP"), ("email", "Email"), ("both", "Both")], default="totp", max_length=10 + ), + ), + ("secret_key_encrypted", models.CharField(blank=True, max_length=500, null=True)), + ("recovery_codes_encrypted", models.TextField(blank=True, null=True)), + ("email_verified", models.BooleanField(default=False)), + ("email_code", models.CharField(blank=True, max_length=6, null=True)), + ("email_code_expires_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + related_name="two_factor_config", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "security_two_factor_config", + }, + ), + migrations.CreateModel( + name="Grant", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("codename", models.CharField(db_index=True, max_length=200)), + ("reason", models.TextField(blank=True)), + ("expires_at", models.DateTimeField(blank=True, null=True)), + ( + "granted_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="grants_given", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="direct_grants", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "security_grant", + "unique_together": {("user", "codename")}, + }, + ), + migrations.CreateModel( + name="Revoke", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("codename", models.CharField(db_index=True, max_length=200)), + ("reason", models.TextField(blank=True)), + ( + "revoked_by", + models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="revokes_given", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="direct_revokes", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "security_revoke", + "unique_together": {("user", "codename")}, + }, + ), + ] diff --git a/infrasynth/security/migrations/0002_add_role_users_m2m.py b/infrasynth/security/migrations/0002_add_role_users_m2m.py new file mode 100644 index 0000000..cb19748 --- /dev/null +++ b/infrasynth/security/migrations/0002_add_role_users_m2m.py @@ -0,0 +1,21 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:30 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("infrasynth_security", "0001_initial"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AddField( + model_name="role", + name="users", + field=models.ManyToManyField( + blank=True, help_text="Users assigned this role", related_name="roles", to=settings.AUTH_USER_MODEL + ), + ), + ] diff --git a/infrasynth/security/migrations/__init__.py b/infrasynth/security/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..02ef98b176473ff88415c2e7ac2e1f633c864934 GIT binary patch literal 6454 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 B7k>Z% literal 0 HcmV?d00001 diff --git a/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f70c174edeeac578581c4981aa5cc3b0b5639763 GIT binary patch literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHK= zo0(peSdy8aSF9f&pP83g5+AQuP literal 0 HcmV?d00001 diff --git a/infrasynth/security/models.py b/infrasynth/security/models.py new file mode 100644 index 0000000..1a90935 --- /dev/null +++ b/infrasynth/security/models.py @@ -0,0 +1,120 @@ +from django.conf import settings +from django.db import models + + +class Role(models.Model): + name = models.CharField(max_length=100) + slug = models.SlugField(max_length=100, unique=True) + description = models.TextField(blank=True) + permissions = models.JSONField(default=list, help_text="List of permission codenames") + is_system = models.BooleanField(default=False, help_text="System roles cannot be deleted") + users = models.ManyToManyField( + settings.AUTH_USER_MODEL, + related_name="roles", + blank=True, + help_text="Users assigned this role", + ) + + class Meta: + db_table = "security_role" + + def __str__(self): + return self.name + + +class Grant(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="direct_grants", + ) + codename = models.CharField(max_length=200, db_index=True) + granted_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="grants_given", + ) + reason = models.TextField(blank=True) + expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "security_grant" + unique_together = [("user", "codename")] + + +class Revoke(models.Model): + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="direct_revokes", + ) + codename = models.CharField(max_length=200, db_index=True) + revoked_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + related_name="revokes_given", + ) + reason = models.TextField(blank=True) + + class Meta: + db_table = "security_revoke" + unique_together = [("user", "codename")] + + +class APIKey(models.Model): + name = models.CharField(max_length=200) + prefix = models.CharField(max_length=12, unique=True, help_text="First 8 characters visible in UI") + key_hash = models.CharField(max_length=255, help_text="PBKDF2 hash of the full secret") + scopes = models.JSONField(default=list, help_text='["read:users", "write:billing"]') + created_by = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + is_active = models.BooleanField(default=True) + expires_at = models.DateTimeField(null=True, blank=True) + last_used_at = models.DateTimeField(null=True, blank=True) + rotated_from = models.ForeignKey("self", on_delete=models.SET_NULL, null=True, blank=True) + + class Meta: + db_table = "security_api_key" + + +class TwoFactorConfig(models.Model): + METHOD_TOTP = "totp" + METHOD_EMAIL = "email" + METHOD_BOTH = "both" + + user = models.OneToOneField( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="two_factor_config", + ) + is_enabled = models.BooleanField(default=False) + is_configured = models.BooleanField(default=False) + method = models.CharField( + max_length=10, + choices=[ + (METHOD_TOTP, "TOTP"), + (METHOD_EMAIL, "Email"), + (METHOD_BOTH, "Both"), + ], + default=METHOD_TOTP, + ) + secret_key_encrypted = models.CharField(max_length=500, null=True, blank=True) + recovery_codes_encrypted = models.TextField(null=True, blank=True) + email_verified = models.BooleanField(default=False) + email_code = models.CharField(max_length=6, null=True, blank=True) + email_code_expires_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "security_two_factor_config" + + +class ALTCHAChallenge(models.Model): + challenge_id = models.CharField(max_length=64, primary_key=True) + salt = models.CharField(max_length=32) + difficulty = models.IntegerField(default=10000) + expires_at = models.DateTimeField(db_index=True) + is_verified = models.BooleanField(default=False) + + class Meta: + db_table = "security_altcha_challenge" diff --git a/infrasynth/security/permissions.py b/infrasynth/security/permissions.py new file mode 100644 index 0000000..eb913fe --- /dev/null +++ b/infrasynth/security/permissions.py @@ -0,0 +1,29 @@ +from rest_framework.permissions import BasePermission + +from .services import AuthorizationService + + +class HybridPermission(BasePermission): + """DRF permission class that uses AuthorizationService.""" + + def has_permission(self, request, view): + if not request.user or not request.user.is_authenticated: + return False + required = getattr(view, "required_permissions", []) + if not required: + return True + authz = AuthorizationService() + return authz.has_any_permission(request.user, required) + + +def require_permission(*codenames: str): + """Decorator/class for DRF views requiring all listed permissions.""" + + class PermissionRequired(HybridPermission): + def has_permission(self, request, view): + if not super().has_permission(request, view): + return False + authz = AuthorizationService() + return authz.has_all_permissions(request.user, list(codenames)) + + return PermissionRequired diff --git a/infrasynth/security/serializers.py b/infrasynth/security/serializers.py new file mode 100644 index 0000000..b7d39ac --- /dev/null +++ b/infrasynth/security/serializers.py @@ -0,0 +1,64 @@ +from rest_framework import serializers + +from .models import APIKey, Grant, Revoke, Role + + +class RoleSerializer(serializers.ModelSerializer): + class Meta: + model = Role + fields = ["id", "name", "slug", "description", "permissions", "is_system", "users"] + read_only_fields = ["id", "is_system"] + + +class GrantSerializer(serializers.ModelSerializer): + class Meta: + model = Grant + fields = ["id", "user", "codename", "granted_by", "reason", "expires_at"] + read_only_fields = ["id", "granted_by"] + + +class RevokeSerializer(serializers.ModelSerializer): + class Meta: + model = Revoke + fields = ["id", "user", "codename", "revoked_by", "reason"] + read_only_fields = ["id", "revoked_by"] + + +class APIKeySerializer(serializers.Serializer): + id = serializers.IntegerField(read_only=True) + name = serializers.CharField() + prefix = serializers.CharField(read_only=True) + key = serializers.CharField(read_only=True, help_text="Full secret, only shown once on creation") + scopes = serializers.ListField(child=serializers.CharField(), default=list) + is_active = serializers.BooleanField(default=True) + expires_at = serializers.DateTimeField(allow_null=True, required=False) + last_used_at = serializers.DateTimeField(read_only=True) + + def create(self, validated_data): + return APIKey.objects.create(**validated_data) + + def update(self, instance, validated_data): + for field, value in validated_data.items(): + setattr(instance, field, value) + instance.save() + return instance + + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + +class TwoFactorSetupSerializer(serializers.Serializer): + secret = serializers.CharField(read_only=True) + qr_base64 = serializers.CharField(read_only=True) + provisioning_uri = serializers.CharField(read_only=True) + + +class TwoFactorVerifySerializer(serializers.Serializer): + code = serializers.CharField(max_length=10) + secret = serializers.CharField(required=False) + + +class TwoFactorRecoverySerializer(serializers.Serializer): + recovery_code = serializers.CharField() diff --git a/infrasynth/security/services.py b/infrasynth/security/services.py new file mode 100644 index 0000000..db724a3 --- /dev/null +++ b/infrasynth/security/services.py @@ -0,0 +1,69 @@ +from django.db.models import Q +from django.utils import timezone + +from .models import Grant, Revoke + + +class AuthorizationService: + """Singleton service for permission resolution.""" + + def _get_system_user_scopes(self, user) -> set[str] | None: + from .auth.api_keys import SystemUser + + if isinstance(user, SystemUser): + return set(user.scopes) + return None + + def has_permission(self, user, codename: str) -> bool: + if not user or not user.is_authenticated: + return False + if user.is_superuser: + return True + system_scopes = self._get_system_user_scopes(user) + if system_scopes is not None: + return codename in system_scopes + if Revoke.objects.filter(user=user, codename=codename).exists(): + return False + if ( + Grant.objects.filter(user=user, codename=codename) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())) + .exists() + ): + return True + user_roles = self._get_role_permission_lists(user) + for perm_list in user_roles: + if codename in (perm_list or []): + return True + return False + + def get_effective_permissions(self, user) -> set[str]: + if not user or not user.is_authenticated: + return set() + if user.is_superuser: + return {"*"} + system_scopes = self._get_system_user_scopes(user) + if system_scopes is not None: + return system_scopes + revoked = set(Revoke.objects.filter(user=user).values_list("codename", flat=True)) + granted = set( + Grant.objects.filter(user=user) + .filter(Q(expires_at__isnull=True) | Q(expires_at__gt=timezone.now())) + .values_list("codename", flat=True) + ) + role_perms = set() + for perm_list in self._get_role_permission_lists(user): + role_perms.update(perm_list or []) + return (granted | role_perms) - revoked + + @staticmethod + def _get_role_permission_lists(user) -> list[list[str]]: + roles = getattr(user, "roles", None) + if roles is None: + return [] + return list(roles.values_list("permissions", flat=True)) + + def has_all_permissions(self, user, codenames: list[str]) -> bool: + return all(self.has_permission(user, c) for c in codenames) + + def has_any_permission(self, user, codenames: list[str]) -> bool: + return any(self.has_permission(user, c) for c in codenames) diff --git a/infrasynth/security/signals.py b/infrasynth/security/signals.py new file mode 100644 index 0000000..b4feba1 --- /dev/null +++ b/infrasynth/security/signals.py @@ -0,0 +1,11 @@ +from django.dispatch import Signal + +user_logged_in = Signal() +user_logged_out = Signal() +login_failed = Signal() +two_factor_setup = Signal() +two_factor_verified = Signal() +api_key_created = Signal() +api_key_rotated = Signal() +grant_created = Signal() +grant_revoked = Signal() diff --git a/infrasynth/security/two_factor/__init__.py b/infrasynth/security/two_factor/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..42b46611c1a09f31c1056dbe4ea9f7e6db8e1378 GIT binary patch literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hanp|3xSyHKA zQl1~5mY7_UU!)%&pP83g5+AQuP=P(!K}sh+OgKN?PedasO{(8kd{~N(_?{odXWen}(4i1&xw<^Ra0O)ndV()^u*u zN!!Ypt9oUZiu6<`=feOrNa_LNQW|m3LqC_VAsbTU-Xw(oMB=`*CyF4r%>7GACg zgyOVaZC?vr9GkK-Hq{A>4Nu>XZ4qi_OkLxqZN)$z%QHe*tdO;1S#68NOdiwAspX-P zr0(Vf0R;!RZSY3Cm(u!MYU-@)n z_0_%aTGd7c8SuTkP!H-Z+nZ=i?nb5*_d8xenT4WX)r<|(QsWYB1V5(*4`(!7;TJKc zi%l?1l&9PAR>z;!n8rD+*oI7owLH%fiwiCi!|`rvJnJ-1eKs|pTEwaQi!+}(O^j=l zvx3R9RFKc{+be6(Tg;JH33r-c6JlvAIdE?QT;r|dhxKwa-~@7-Keh>5u=&(ulkvFh zcp1rM96u$G@`Q0m<{6C%!`ruG&gW{@-XQ9RO|>m;T}@HD98oo`4At1S#j}YOjS+Q4 z)7Ob*)ag}5^gK2Bw#o}O&Xl`c-MG2}lAGI3aFg25hzz@-``*#8$^RG$y09NW?4r}) zwXX^XN-5ZX9PEEq`1;B7Cna?b-a~cnq%Ha?5ZN35a(sWJ)H!(6Irwv6u%;{pdXECV z2cxAx;y921{h3E;44+1BmLl<^NW2tD97Ph}-2HC++wsFl;$>vw>Ex?mWN&A0=jqNt zU#a&FyobGaPNO&W3(tbzca);{j-&TZJNpjiUUm*0wh#TTpiukeg%5>#O2L7n;J^iv z8pE%eLnpzuGatae*$pV-$KkQb82Tw02KfSY3mNN}9F<>uD1m&A7UI5QNYyq?Rq#X2 z*{eXYV)zY>o#$$?qox9)sTHEB#ps)AxQcC9+E(i~)w4ujSL2ON1ICuNK`?fjFy63@ zd`{qIj34E-Ty-UgamJ*K=S+)OHZ1!wHf$YZD((a-TF2?Y7^n(V#)5R_N}dx2mSK$b zoW_`8>Rcze9LDeqXsJ_zo46PzsAyB_cZC2X9&bO zELI#ApdmxXnr5xqNtU%K*YljwGVR~1s`K2;v7|vZ;V{bgJ{9+`qd>tV$I6{miQOeO vy(NSQ?lg>7j^ra6hp9-EiE7VeNs?ZpTd&c`ujqpd<+{|qul|W(DEs~k0aeCf literal 0 HcmV?d00001 diff --git a/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1223c5c90dccac17964ef67eee45a4512b4cc63 GIT binary patch literal 7149 zcmcf`TTC3+_0D5=XW3;3dwDs2@Uo#x47fI6(k875!7*(Ln*o+kz>{Tnu-933*LP+~ zaM~J0t%{v0I8mjzQd9+1QL`VF;=lT%Qu9$MpG!6pbnNygm8$&Nr3z_&+H>yg>;nk4 zm8e(RbMKsc=gzt3eeOQ)tE?c<{yQGO)b1waQ+%+Sm>+CX7%Y+VMB#>rVfCEBv1i`k zSuYp@>qSF^o*#0Ya~jTbF2mJ;>xCidoZE0WkQ&lN6tRvdj&9!C-KbzVC*WKKoJZkW zNI?1wmkbAZ(-qYsbTMU^E-k?Z0nYSX&Lh%`%5HY-p49BW6J!2zd!BLGa#3EXRE1}&1V}TH9^EeEa$aG;&0iqSY z8|JX3xQVJnaZZ3uMbketd{*fnog5xJr;ZK|j4MN9lV=(tVCY*^R2X2X$}v_!N7-=%AjG>sXHUB=5vbuJt+k`!?4?0RQv z(Uep*mWUauYF3x9rPIb%A{BK2mdM?Ghq8xHX1kyJD4yFlwd~xeJ@kfW6Q&4bqRkK_ z85yiG;{`zVGLRL;-tz=-!@3@gMSw=ekLD%n!*{%@8crmVM%d7R`ucL}i!=LRtzHe_izTu}ILER3 z2v+^9qc~fp19|cbIso%l3*yYS)p;PB?bQu?<|vLhe>A;{J1a`VS>b51^*m5vtax7qK6WsLo(!+Wt` z;ZX>Rz(fH$oDnH<$#muka%l76Se(`Xj@AMIJ*~&_0RW|PppPN;AOL(_Zv&9$^bChQ zp5Zd5?y>%sZuPfy3vUshH!66Ikb(gk@BjwLkO+2Teh^o0Yay;X&xoLF+Y~%bGzO)e zScROtzN7h-xWnE(%{f{ihY4G2us z^cXCstpId9Uu$b#?A7Qsx4&pMmdk$U3dY$0Y{V2`<;t}yD_5^wy}6K+Ti4~&Yx3zo zj;{x&)`C-6`E*X6&PvnYpkmOYc$SA#0rV7lGXSOn4#yMs34o0M9(K1zS$BJsA@`8J z2r@*!5ww*WHniKsemhL1gz17w21R?m*f)`oU~5B4oJPZ7Z8se#8gj}J5`!LlZLtqI zFbLS1(mPjqnatshhNf6K9)3HPcIspJ~RT6BfOxQPV_px-KKlCw;KXBu9ZOV7Q*o~OhPON zOu)HKPfY<-*yJx$QBue6+Q!C4%U|s;xTMRJc{SMoNV(eP{aIn2>B1E3vqUz0 z``3MKYreMkojG6cvT(Q3zcO`w>ZU(ed3c$>>#JJ1bp6un@nvB{s#tMdbKMYb4lcVs zk(%$yHS2Q2$8y8F!tcGS-rxCha@V@tyC(PMj; z=iOXki^AK=42mXD$UceLP?E2paMe)%b3@NSCZkRa4L>t70ePPn)F>8JUyeb1dpRJo z+=V$u`Z(_3T+nE2ZV?do%uS95YtR_fWQ80aBbU_`I{;K10$ zGq#}TwNCK}fv^Q$(lH|D17QQkyM|Rz0_ZZ}{-Co6EI2S&jKslD+Qcwhntc|7S_`3v zVGsPbVk#B@~8NtdY-1!e` z(MQQ8Sm-QaVKXwV84U=!TLOFfnk+01z^@dRtza{ETV=DgT`jl5D8%8eI3Is#&HH!n_?)R(+(QTF5Bqjsr zCEFv1NJ%|tIgTCvR2du?2@NWf>cGe|gQEb)hsO%VpCgh?Ck)yMU(r!OmLW6wn}>EG zvQVF))78;6f%4TfW}AAmeUsU#>0HgsviL^DPD4he$KU1&0DE~sC=0mbnA+zXR%;Zq zl?)`v@fcKT7#8;oh-TyNloxBWPS~R8C{;TvcS8PxGB5&7U2FVxCv{5Vu>1JTKaB1o4a8nw_P2p@jN+ z@1GQh`5%H+yU7utDFy12<{Q{31{VtyZD#n2pbT`FTt~oRx~%$4Hyu!8UeM@^FmJkz zBozMYMeP;Skxr%H8X;eYT*O&UwW<(1ne!ErIZShOvw1f;m9Jrvioj+|kT7t3xIP^L_G zdtvY>y30ykt|X$HU~h16DfL>S&;Hwx{R zy4sEo))$M~Gw@LALmpVZ6tXy55$*Udu=sC^m-J-9bVSqhDcw#6Qt*+oNLFbFd`s3% zyUDt22|Fp4dn}&)+IaiQTUXvamaF?#P7Y+HK+zu2QDMVu?xEyj$pJ4M$~+5bJRN~O z;0WkbXw(|-aod0bK_w^^NGA%Ub5%!SbY_fO1Aka#mBZF$?%F!x|Hc>-1y>mP^X0M_~kRv~YL4ljV9yKOL} zML*JOx4eN%o5;H*a_}pz07AUY{**A`$1k>!MpCpUCy9c{4v_|Om?$t8Bi|JclNcA^ z&cXo}VZG@dd?li#47j|3YcG}o;?e0(5HJfuQm{g?s$Y*)kEkd)vtc(2Dp|l-U{x*m zc+q`eqq=^*x^=C(HQRPNSKYTfxZ$sR^Qo-2@r%21{l5w4K4ALm?+MUfRs)0U&VY0LIi zXK*KUflK5b?{e4gR2kX-tDkOE)x7O}(|dF5ld9&8x`XR=fwj88`{0>RWUEg6)n8}p zQ@!c0Lw&0MQpW<~wZ5mwpFI6u;X@Ax5NeVa0XrP<(sAf%2*Ct`7Z9+LV;)EBplB_0 z3BfNAOd|L(0=(E^scOEKm_Z~0R^Wdh2KicobDZb4oS4;NwUFYlTxe$z6U&88=B}}< zIKX_vmJihEbJQiKIc!Iu^fc~{DHlaQYuz`XiJA4qzd$?W=TqW^ c@jpq=-$>t<^B@P+DuAsgk8t&y1OVIbfBrjspa1{> literal 0 HcmV?d00001 diff --git a/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97d886b7ab257524fba3ccb0b29e3f6891dfd443 GIT binary patch literal 1108 zcmah|%}*0i5TCcZw1om8)L3IG1T`kB$a)~uLqkkV480KCLoX6GOZ#XS+FgA6D%4XC zp7h|=gC-t0$lX7L_z{y;PsRf`3|GPE&GzF9VxENxALn{ll?xI=kpPBeuca);d%#_4{X za>X^UM-J)GWm_b}I%vt7Y-%5a3Z6h&*P$y=r6*f+7M5^WMm|9*wa}hhP89womf`eD$pHA??m2)U)7Ov z+B#^)bK9L{)bso>jI^(%=czI5mBtlcM%9HA5aQ-9QhWE0$h^;Sbc|VGKFA?C<)ef_ z7tZd!a(Z*FV>QEk&7RB5_jyHbgi2u%a*;;dNKB05i112YL+)ad(NXmcHHtdP*JUUb zq2E^0^V_=Nak-krEEng|>B6_{g$^7iqYM<28Hfn}?4%>6Z2!o+jE&b$6S zSBIXB!FZE&D+t?K#t3m3x1!0S=?#yOGPkTak>m4;;3834++${oXNWM*GK88DEu-^) zX6JjmV3qc}#ds%v3da?Mr{I33~u<$v%a9}O)jcjpv4{Oj6EIX|x&?K1}&W(OA?wO3% zHvO<3&GFl0Ks-clvk!buZ|0d&E2_1da#2m;F?6-~3aTSh2=Nuhzr)l|m^n$|dp*ev I_~q??01mI!>;M1& literal 0 HcmV?d00001 diff --git a/infrasynth/security/two_factor/middleware.py b/infrasynth/security/two_factor/middleware.py new file mode 100644 index 0000000..b474596 --- /dev/null +++ b/infrasynth/security/two_factor/middleware.py @@ -0,0 +1,32 @@ +from django.shortcuts import redirect +from django.utils.deprecation import MiddlewareMixin + +from ..models import TwoFactorConfig + + +class TwoFactorMiddleware(MiddlewareMixin): + """Enforces 2FA verification for users who have it configured.""" + + EXEMPT_PATHS = [ + "/api/auth/login/", + "/api/auth/logout/", + "/api/auth/2fa/verify/", + "/api/auth/2fa/recovery/", + "/api/auth/check/", + ] + + def process_request(self, request): + if not hasattr(request, "user") or not request.user.is_authenticated: + return None + if request.path in self.EXEMPT_PATHS: + return None + if request.path.startswith("/admin/"): + return None + try: + tfa = TwoFactorConfig.objects.get(user=request.user) + if tfa.is_enabled and tfa.is_configured: + if not getattr(request, "_2fa_verified", False): + return redirect("2fa-verify") + except TwoFactorConfig.DoesNotExist: + pass + return None diff --git a/infrasynth/security/two_factor/services.py b/infrasynth/security/two_factor/services.py new file mode 100644 index 0000000..0772959 --- /dev/null +++ b/infrasynth/security/two_factor/services.py @@ -0,0 +1,72 @@ +import base64 +import io +import json + +import pyotp +import qrcode + +from infrasynth.shared.crypto import decrypt, encrypt +from infrasynth.shared.settings_utils import get_setting + + +class TOTPService: + def __init__(self): + issuer = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_ISSUER_NAME", "InfraSynth") + self.issuer = issuer + + def generate_secret(self) -> str: + return pyotp.random_base32() + + def get_provisioning_uri(self, secret: str, email: str) -> str: + return pyotp.totp.TOTP(secret).provisioning_uri(name=email, issuer_name=self.issuer) + + def generate_qr_base64(self, secret: str, email: str) -> str: + uri = self.get_provisioning_uri(secret, email) + qr = qrcode.make(uri) + buf = io.BytesIO() + qr.save(buf, format="PNG") + return base64.b64encode(buf.getvalue()).decode() + + def verify(self, secret: str, code: str) -> bool: + window = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_TOTP_VALIDITY_WINDOW", 1) + totp = pyotp.TOTP(secret) + return totp.verify(code, valid_window=window) + + def encrypt_secret(self, secret: str) -> str: + return encrypt(secret) + + def decrypt_secret(self, encrypted: str) -> str: + return decrypt(encrypted) + + +class RecoveryCodeService: + def __init__(self): + self.count = get_setting("INFRASYNTH_SECURITY", "TWO_FACTOR_RECOVERY_CODES_COUNT", 8) + + def generate_codes(self) -> list[str]: + import secrets + + return [f"RC-{secrets.token_hex(4).upper()}-{secrets.token_hex(4).upper()}" for _ in range(self.count)] + + def encrypt_codes(self, codes: list[str]) -> str: + return encrypt(json.dumps(codes)) + + def decrypt_codes(self, encrypted: str) -> list[str]: + return json.loads(decrypt(encrypted)) + + def verify_code(self, code: str, stored_encrypted: str) -> bool: + try: + codes = self.decrypt_codes(stored_encrypted) + return code in codes + except Exception: + return False + + def remove_used_code(self, code: str, stored_encrypted: str) -> str | None: + try: + codes = self.decrypt_codes(stored_encrypted) + if code in codes: + codes.remove(code) + return self.encrypt_codes(codes) + return None + except Exception: + return None diff --git a/infrasynth/security/two_factor/utils.py b/infrasynth/security/two_factor/utils.py new file mode 100644 index 0000000..d565b21 --- /dev/null +++ b/infrasynth/security/two_factor/utils.py @@ -0,0 +1,12 @@ +import secrets +from datetime import timedelta + +from django.utils import timezone + + +def generate_pre_auth_token(length: int = 64) -> str: + return secrets.token_hex(length // 2) + + +def get_pre_auth_expiry(minutes: int = 5): + return timezone.now() + timedelta(minutes=minutes) diff --git a/infrasynth/security/urls.py b/infrasynth/security/urls.py new file mode 100644 index 0000000..3c9e7da --- /dev/null +++ b/infrasynth/security/urls.py @@ -0,0 +1,61 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + ALTCHAViewSet, + APIKeyViewSet, + AuthViewSet, + GrantViewSet, + RevokeViewSet, + RoleViewSet, + TwoFactorViewSet, +) + +router = DefaultRouter() +router.register(r"api-keys", APIKeyViewSet, basename="api-keys") +router.register(r"roles", RoleViewSet, basename="roles") +router.register(r"grants", GrantViewSet, basename="grants") +router.register(r"revokes", RevokeViewSet, basename="revokes") + +urlpatterns = [ + path("login/", AuthViewSet.as_view({"post": "login"}), name="auth-login"), + path("logout/", AuthViewSet.as_view({"post": "logout"}), name="auth-logout"), + path("refresh/", AuthViewSet.as_view({"post": "refresh"}), name="auth-refresh"), + path("check/", AuthViewSet.as_view({"get": "check"}), name="auth-check"), + path( + "2fa/setup/", + TwoFactorViewSet.as_view({"post": "setup"}), + name="2fa-setup", + ), + path( + "2fa/verify-setup/", + TwoFactorViewSet.as_view({"post": "verify_setup"}), + name="2fa-verify-setup", + ), + path( + "2fa/verify/", + TwoFactorViewSet.as_view({"post": "verify"}), + name="2fa-verify", + ), + path( + "2fa/disable/", + TwoFactorViewSet.as_view({"post": "disable"}), + name="2fa-disable", + ), + path( + "2fa/recovery/", + TwoFactorViewSet.as_view({"post": "recovery"}), + name="2fa-recovery", + ), + path( + "altcha/challenge/", + ALTCHAViewSet.as_view({"post": "challenge"}), + name="altcha-challenge", + ), + path( + "altcha/verify/", + ALTCHAViewSet.as_view({"post": "verify"}), + name="altcha-verify", + ), + path("", include(router.urls)), +] diff --git a/infrasynth/security/views.py b/infrasynth/security/views.py new file mode 100644 index 0000000..f9fb9f0 --- /dev/null +++ b/infrasynth/security/views.py @@ -0,0 +1,378 @@ +import secrets + +from django.contrib.auth import authenticate, get_user_model, login, logout +from rest_framework import mixins, status, viewsets +from rest_framework.decorators import action +from rest_framework.exceptions import AuthenticationFailed, PermissionDenied +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response +from rest_framework_simplejwt.tokens import RefreshToken + +from infrasynth.shared.settings_utils import get_setting + +from .altcha.services import ALTCHAService +from .models import APIKey, Grant, Revoke, Role, TwoFactorConfig +from .serializers import ( + APIKeySerializer, + GrantSerializer, + LoginSerializer, + RevokeSerializer, + RoleSerializer, +) +from .services import AuthorizationService +from .signals import login_failed, user_logged_in, user_logged_out +from .two_factor.services import RecoveryCodeService, TOTPService + +UserModel = get_user_model() + + +class AuthViewSet(viewsets.GenericViewSet): + permission_classes = [AllowAny] + + @action(detail=False, methods=["post"]) + def login(self, request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = authenticate( + request, + username=serializer.validated_data["username"], + password=serializer.validated_data["password"], + ) + if not user: + login_failed.send( + sender=self.__class__, + credentials_key=serializer.validated_data["username"], + ip=request.META.get("REMOTE_ADDR"), + reason="invalid_credentials", + ) + raise AuthenticationFailed("Invalid credentials.") + if not user.is_active: + raise AuthenticationFailed("User is disabled.") + + login(request, user) + refresh = RefreshToken.for_user(user) + access_token = str(refresh.access_token) + refresh_token = str(refresh) + + cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) + cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True) + cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax") + access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") + refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") + + try: + from infrasynth.shared.crypto import encrypt as crypto_encrypt + + encrypted_access = crypto_encrypt(access_token) + encrypted_refresh = crypto_encrypt(refresh_token) + except Exception: + encrypted_access = access_token + encrypted_refresh = refresh_token + + response = Response({"detail": "Login successful."}) + response.set_cookie( + access_cookie, + encrypted_access, + max_age=3600, + secure=cookie_secure, + httponly=cookie_httponly, + samesite=cookie_samesite, + path="/", + ) + response.set_cookie( + refresh_cookie, + encrypted_refresh, + max_age=86400 * 7, + secure=cookie_secure, + httponly=cookie_httponly, + samesite=cookie_samesite, + path="/", + ) + + user_logged_in.send( + sender=self.__class__, + user=user, + ip=request.META.get("REMOTE_ADDR"), + user_agent=request.META.get("HTTP_USER_AGENT", ""), + ) + return response + + @action(detail=False, methods=["post"]) + def logout(self, request): + logout(request) + access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") + refresh_cookie = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") + response = Response({"detail": "Logout successful."}) + response.delete_cookie(access_cookie, path="/") + response.delete_cookie(refresh_cookie, path="/") + user_logged_out.send(sender=self.__class__, user=getattr(request, "user", None)) + return response + + @action(detail=False, methods=["post"]) + def refresh(self, request): + cookie_secure = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) + cookie_httponly = get_setting("INFRASYNTH_SECURITY", "COOKIE_HTTPONLY", True) + cookie_samesite = get_setting("INFRASYNTH_SECURITY", "COOKIE_SAMESITE", "Lax") + access_cookie = get_setting("INFRASYNTH_SECURITY", "ACCESS_COOKIE_NAME", "access_token") + refresh_cookie_name = get_setting("INFRASYNTH_SECURITY", "REFRESH_COOKIE_NAME", "refresh_token") + + raw_refresh = request.COOKIES.get(refresh_cookie_name) + if not raw_refresh: + raise AuthenticationFailed("Refresh token not found.") + try: + from infrasynth.shared.crypto import decrypt as crypto_decrypt + + decrypted = crypto_decrypt(raw_refresh) + except Exception: + decrypted = raw_refresh + + try: + refresh = RefreshToken(decrypted) # type: ignore[arg-type] + access_token = str(refresh.access_token) + if get_setting("INFRASYNTH_SECURITY", "ROTATE_REFRESH_TOKENS", True): + new_refresh = str(refresh) + else: + new_refresh = raw_refresh + except Exception: + raise AuthenticationFailed("Invalid or expired refresh token.") + + try: + from infrasynth.shared.crypto import encrypt as crypto_encrypt + + encrypted_access = crypto_encrypt(access_token) + encrypted_refresh = crypto_encrypt(new_refresh) + except Exception: + encrypted_access = access_token + encrypted_refresh = new_refresh + + response = Response({"detail": "Token refreshed."}) + response.set_cookie( + access_cookie, + encrypted_access, + max_age=3600, + secure=cookie_secure, + httponly=cookie_httponly, + samesite=cookie_samesite, + path="/", + ) + response.set_cookie( + refresh_cookie_name, + encrypted_refresh, + max_age=86400 * 7, + secure=cookie_secure, + httponly=cookie_httponly, + samesite=cookie_samesite, + path="/", + ) + return response + + @action(detail=False, methods=["get"]) + def check(self, request): + if not request.user or not request.user.is_authenticated: + return Response({"authenticated": False}, status=status.HTTP_401_UNAUTHORIZED) + authz = AuthorizationService() + effective_permissions = list(authz.get_effective_permissions(request.user)) + user = request.user + return Response( + { + "id": user.pk, + "email": getattr(user, "email", ""), + "name": getattr(user, "get_full_name", lambda: str(user))(), + "effective_permissions": effective_permissions, + } + ) + + +class TwoFactorViewSet(viewsets.GenericViewSet): + permission_classes = [IsAuthenticated] + + @action(detail=False, methods=["post"]) + def setup(self, request): # type: ignore[override] + totp = TOTPService() + secret = totp.generate_secret() + qr_base64 = totp.generate_qr_base64(secret, request.user.email) + provisioning_uri = totp.get_provisioning_uri(secret, request.user.email) + request.session["_2fa_setup_secret"] = secret + return Response( + { + "secret": secret, + "qr_base64": qr_base64, + "provisioning_uri": provisioning_uri, + } + ) + + @action(detail=False, methods=["post"]) + def verify_setup(self, request): + secret = request.session.get("_2fa_setup_secret") + if not secret: + raise AuthenticationFailed("No setup in progress.") + code = request.data.get("code") + if not code: + raise AuthenticationFailed("Code is required.") + totp = TOTPService() + if not totp.verify(secret, code): + raise AuthenticationFailed("Invalid code.") + rcs = RecoveryCodeService() + recovery_codes = rcs.generate_codes() + config, _ = TwoFactorConfig.objects.get_or_create(user=request.user) + config.is_enabled = True + config.is_configured = True + config.secret_key_encrypted = totp.encrypt_secret(secret) + config.recovery_codes_encrypted = rcs.encrypt_codes(recovery_codes) + config.save() + del request.session["_2fa_setup_secret"] + return Response({"recovery_codes": recovery_codes}) + + @action(detail=False, methods=["post"], permission_classes=[AllowAny]) + def verify(self, request): + user_id = request.session.get("_2fa_user_id") + if not user_id: + raise AuthenticationFailed("No pre-auth session.") + try: + config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True) + except TwoFactorConfig.DoesNotExist: + raise AuthenticationFailed("2FA not configured.") + code = request.data.get("code") + if not code: + raise AuthenticationFailed("Code is required.") + totp = TOTPService() + secret = totp.decrypt_secret(config.secret_key_encrypted) + if not totp.verify(secret, code): + raise AuthenticationFailed("Invalid code.") + user = UserModel.objects.get(pk=user_id) + login(request, user) + request._2fa_verified = True + request.session.pop("_2fa_user_id", None) + request.session.pop("_2fa_pre_auth_token", None) + return Response({"detail": "2FA verified."}) + + @action(detail=False, methods=["post"]) + def disable(self, request): + try: + config = TwoFactorConfig.objects.get(user=request.user) + config.is_enabled = False + config.is_configured = False + config.secret_key_encrypted = None + config.recovery_codes_encrypted = None + config.save() + except TwoFactorConfig.DoesNotExist: + pass + return Response({"detail": "2FA disabled."}) + + @action(detail=False, methods=["post"], permission_classes=[AllowAny]) + def recovery(self, request): + recovery_code = request.data.get("recovery_code") + if not recovery_code: + raise AuthenticationFailed("Recovery code is required.") + user_id = request.session.get("_2fa_user_id") + if not user_id: + raise AuthenticationFailed("No pre-auth session.") + try: + config = TwoFactorConfig.objects.get(user_id=user_id, is_enabled=True) + except TwoFactorConfig.DoesNotExist: + raise AuthenticationFailed("2FA not configured.") + rcs = RecoveryCodeService() + if not rcs.verify_code(recovery_code, config.recovery_codes_encrypted): + raise AuthenticationFailed("Invalid recovery code.") + updated = rcs.remove_used_code(recovery_code, config.recovery_codes_encrypted) + if updated: + config.recovery_codes_encrypted = updated + config.save(update_fields=["recovery_codes_encrypted"]) + user = UserModel.objects.get(pk=user_id) + login(request, user) + request._2fa_verified = True + request.session.pop("_2fa_user_id", None) + request.session.pop("_2fa_pre_auth_token", None) + return Response({"detail": "Recovered access."}) + + +class ALTCHAViewSet(viewsets.GenericViewSet): + permission_classes = [AllowAny] + + @action(detail=False, methods=["post"]) + def challenge(self, request): + svc = ALTCHAService() + challenge = svc.create_challenge() + return Response(challenge) + + @action(detail=False, methods=["post"]) + def verify(self, request): + svc = ALTCHAService() + result = svc.verify( + request.data.get("challenge_id"), + request.data.get("solution"), + request.data.get("number"), + ) + return Response({"verified": result}) + + +class APIKeyViewSet(viewsets.ModelViewSet): + queryset = APIKey.objects.order_by("-id").all() + serializer_class = APIKeySerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return APIKey.objects.order_by("-id").all() + + def perform_create(self, serializer): + from django.contrib.auth.hashers import make_password + + prefix = secrets.token_hex(4) + secret = secrets.token_hex(32) + full_key = f"{prefix}.{secret}" + instance = serializer.save( + prefix=prefix, + key_hash=make_password(secret), + created_by=self.request.user, + ) + instance.key = full_key + return instance + + +class RoleViewSet(viewsets.ModelViewSet): + queryset = Role.objects.order_by("name").all() + serializer_class = RoleSerializer + permission_classes = [IsAuthenticated] + lookup_field = "slug" + + def get_queryset(self): + return Role.objects.order_by("name").all() + + def perform_destroy(self, instance): + if instance.is_system: + raise PermissionDenied("System roles cannot be deleted.") + instance.delete() + + +class GrantViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = Grant.objects.all() + serializer_class = GrantSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Grant.objects.select_related("user", "granted_by").order_by("-id").all() + + def perform_create(self, serializer): + serializer.save(granted_by=self.request.user) + + +class RevokeViewSet( + mixins.CreateModelMixin, + mixins.ListModelMixin, + mixins.DestroyModelMixin, + viewsets.GenericViewSet, +): + queryset = Revoke.objects.all() + serializer_class = RevokeSerializer + permission_classes = [IsAuthenticated] + + def get_queryset(self): + return Revoke.objects.select_related("user", "revoked_by").order_by("-id").all() + + def perform_create(self, serializer): + serializer.save(revoked_by=self.request.user) diff --git a/infrasynth/shared/__init__.py b/infrasynth/shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/shared/__pycache__/__init__.cpython-314.pyc b/infrasynth/shared/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0a688a920c3e8411c6f455258fc442f62c54b240 GIT binary patch literal 175 zcmdPq_I|p@<2{{|u76<)fdGpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hakyw@frOD22mhuo*gFd+D2v%*e2p?VL+d za@@=wQ0xFa_we4!`**(kox|hN17QO4?@Q%nD@e%S@WmIQX*|3FMwMJ4DxD@%)TUE{ zEgT`L&`Z=nFDZA~0aXM$$Zb)Tzz%UcsLEh>a9dKtU@P1XsS&Uba9dWR10>n`1x}U} zTmm|jd5*jmLlQi%d@DV0(Fe$UYbNj_{mJ(y)XSOq@8&O_ zznp;mCkm#qP+DAp(@7=fbUQIUdqEwayF5ETm7hC5aY>z?zx>1D_BX>nN|-FseoyGe zYGPGmsifrgdxG|W54_XH@yWydrJ@@ ze1Yp=v~PNa93gG=WZ}lA$ygI(nH`Jt$58`5E-ExjeLO8D5Kq7!;cQ@iPm!QHsl&v1q0cimVSta6{mD%?4})z;FfA zazlW#5;F}rYV5&!Q1yedaJz|whZq*cYE=A)&w^1Ue-Q`&9`3$%tQJ4{@v~0&R3m(* z9zOHwStmTj@$;G7{nu#M&E*@*H;o&{dd`WCTn}t0(dx{Xfg-|)+z;R6L+=Na9W*Dj zVB!XWH&=9y7KVhg7gVs_`Z}NN5fNx=3)SuCHnF(#9Di%C61s6|t+E2IPh=+Wex`|& z<~O_mPHAybyM8yPJP8YaA0Tr&n-uwVMW=BlUl+>HDQFB3@clftl20FGa&*m^Nh)NBnxLK6nycnrK82J9QK zbA}sdouHBeJBIcUsK@Xyg0-ml;m&~3!o#Kz2u{+C&aRtlH`dkc{&v69d92Zy zu6L%L&XYCy0Yd)8u!(<6VKrA^}2@V`q@h zxc3D|?q?Eo-1iy}Kqm`13lT!;$QG*G1zvctfVP&j7BK}?aDQ738En}l&$RN5z3wh7 z6xRUsiMw+ni@KpROnxBba+lmCd>vk-IsttcD!y)yejv0ql>YllzoR4?%5Ys7c9djI zOtw+&-#bMH?{$4O=W@n(8!}L8c zfGE3re?9Z@tM>wp(aHOxlk1^(qHjh2)OYNU!%nwao!O^(qvufd<cmbpV$auO&pWYm*E=@kSVKNsmk+PcG~%gxJoV99BYm!(KIf#z zo%lHMPBGW2MYpG_YkN3k15HYlP zN;ilWKVeQyxLuY)g{i#vvml?nW)@e<`U~tV)FBsH7+HYwDgB&`ZAjnlDE5nhabssk@-mf0hpeW4gdfE literal 0 HcmV?d00001 diff --git a/infrasynth/shared/__pycache__/enums.cpython-314.pyc b/infrasynth/shared/__pycache__/enums.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bf643aea5046930f32408fc54fe7bbf41826d36b GIT binary patch literal 2857 zcma)8&vV;E6kb_B@u<-Y)qoG3Yv*%=NDCvGtD6u9wLwyByCKQyCn*WW%#`+a)v$*+xP z(g==kf4{er77+TG{TLqNU~=>gOuFa}GB_QraD+RHrqOw1q-K!8-{zM1ejG_ftN^Td zJM}lqN2~;_9AkxuRe&9dv0}ugfgO#pQpAn{n~AY<#Et`-jj>9^=761uu_MOh1vEeP z3)|>CN98KPn%{2P{f+lAHTZG#223o_dm9nXKqPfXApA)bpxc7M&7h@07!i#Wkv0&K z4Ib_-&@r>n2wd9D_4U|LcTsyUZ+NtF$2;HBku&86l23HyG zD7I1=zLOYdz)v7o2Rn{U!bR7= zi>x358nppqMx*t zgRK5_^(*d+na^e(o_{>|XzuaCqlG6^Pwa#In?K~QJk4J@$X`9kz4LYTX;%ME(f|BD ztB33sA74`?;Y(DWSEyo|OrvSiv}rbjdfQ|3G1I)?w!Fbg)->D>WOcN3>6C>d=mL%`tEFflk zi$H)$I1BK{ry%uE%>9<)#GBj^kEDrb{V6Q=V_(SMVa6T33d^zVO%vYWhyd9t8a$B< z0Wwx3iXo8^Lx#*%=xCu~`M%@TIxTq7PSbKd%7@L6Di0bK39VL(D#Y=eZEQ8E&}xSb zJPFSz&!_Pe+sX+B(+o}mP*Gb|^%CW)t5vGpT2X6NwNjxkM zZSZLZkqIxe83WdnLdJiB4#%dt+F?4|EgWWNyDNPdo!Be(uVc~4Hq60F%!bK-vH>iR zX-3<1d{T9G9qf`06(D^BDnGEW@A})|3C01YiVcgdZFvLvBZ=|_y}Z0emd!uhbc6M*2ubBlVYd!-jG0n5b zHG@`>X0N**ZTYOlICUyJAGTcRs!*`l zwb)x8=ua+HNiEd$4UH-lwOTWa>lz&?sO5rIDrrS3Yadi}113t%(A5$obD}RQ$i)mU z!Z|G3$d{;IbEms{|J6TFU>5zq9J=(M4rO$Imwj~y=7@D{-6d2AT8`rZy-#d-~!n`_Xkk_&s2>0`kDR1AwCYR-cWJ?v2|20jo}V4ru)v&*7ST-4&D zb{F*sRx45RM5aY|j`rENhJ6&M@^1gzd<|RhlY=wd0r-{YIPOPusweT>O>Y0K9%5r} zOynl_FZB=%y_~@1_e(tlLvK9Ao!s|&2!>vohdIQ+z~b`{Ut;n3UQXmLvUnJJIUcV4 L=o!T1qrLnEpsacw literal 0 HcmV?d00001 diff --git a/infrasynth/shared/__pycache__/protocols.cpython-314.pyc b/infrasynth/shared/__pycache__/protocols.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d593ac1c61882554fcc11fbe4c21bcadb58106b3 GIT binary patch literal 1937 zcma)6-D?|15TCs}oz9M}_#=v`npCbsKSa`~;5?Kzga%UTJVa?PAq0xOtWW3a?9-jP zw+c8i(dkM3FOs(j$Ri^DTPAbB&(D@cINKXTKTZOaC5Ws+nwF{&CFgdO-}-j zzf0lfpA6s+nv8~>E5n~q*?~21*=<+>1y$0G+ib;9#)_%T6-!xFShdRVKDg!rxYnw9 z&CGUR%bln~ju#NO-+!jIw}{(|B<+d)@r#%E6enrOWM-?A2~!#(yLd||lk zLtI0;Yg{R!nh!+G9AU2^)?4=!-nz#o1TSBFNr7vlv(?Zu-J~mI5OYuZ!VBU^mo|K_ zCH|k6M=OMQTIaS4i%m_~$A72){dLcE27JfU$uM3zhv_&o+D3;-E+d2g(=s z)w8=j>(XF?43lu7`r_R4&wJJfgEM59g38j1ThFUK>*K>|Gz`%#96;lss2$EDfz`+L zB=_C)E#o|yjXksqn!`{4$jddhxa z*a60<&PUxq%_r%l(vv=&mNKl19kCt8qNyjS-I1(z2Mf?ll++fL9aiQHg`2XI6<|is zJb9{&-FvK3N{A;Pp~xy_W`6hXs8ULEyLU!Kg7(HC)kntadEC&D3n*ok9308V5*bO5 zXSqlsO$3=vnTYaSEWbN` zsWF;~g5(^EjOMd*d-s2S+_T;}q^>-X)62L~l1Q0cq*Ga$7ISZ6XY0XFs|gZK+=3>9HP zbwxQa(@3|cudG z*mtdxbn?|kMVQWBLvCN)G94?wWtMiVM!CSR;}U?lv!~Br5mg|hP~s){G!xq7#$9ITX2*d`})C`5BAp%lhcRcX+bsq_uUZG zr--A-agii_1ci^esz;2Hm9{rQo<2yjghlq-fSP)BV3ZpF6cXfwn7}8*B@(Zq=tm=j z{UcOvyaxv^so+68zPossoIMQB_Uh=cGk~#*oinHu_)FNy*YTj~TAtJ46;TXzgZIr^ z(-JY%pZU9SXKgOEym0_I%>#=r4G%Fo-04>99At!@^BM}DMH#{>^LYB0ru~Fk^(w|V zHT;Uh?MYm=;NwmwNGl!Q@mE+UJ76M>AvlF`5?K`e*m0!Mu#cO)L-c7Aa4q;FIljOA zV={dhPWQ5RviRAdJ%h3T0x$6j#w)Z$hBSJ-gHwnMidiqliT3{mIKA!RH1&UR@~HJX z6+tBH$4Db`MSPG@AE%%(Js=f)0gq-1z8=@85ail zzTtET4XQtHlSbUIVKmG--GvdusN3acjdZ*v?={Vu?@1a)r79d*t2V5Ljczo^yW<^y492xzMmqyABK`us~H9!RbrJJoA0Ycbm4LclBF(E>X497NHtLT>mhswM-!8+ z<)29$1>PC?JGIWjduSAS&pDn*(=PWhWZ$V3mzL5#Uh5#72#Y)ajY@MeuZi5 Sm`DHQ_+CVTM^8ypPyY=%6yziT literal 0 HcmV?d00001 diff --git a/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc b/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..662bf04ebc65467676dbba34b175252013a465da GIT binary patch literal 872 zcmZuv&1(}u6n~RV)0(uU7D_<`$3%+pBkQ3D2_i^IX;9L-t<}=nWx6{_w%P11Gn3eQ z%E61Ldhp=Eg9k7ECmw}fWWkdcd#gm~)i;|2OYyzdOo{2^vB7N*I%<1XRf4-4hyHRJk><8 z;dOaHSrpO1*r@9{s+7%E4p=+#c{xjw8eG{!+|7Kowro9G->Pk_*z1+0XI6D%D}RUP zmsVFFS1SWm1r-~j!;9G`Vc3$i6OGc!(tqIF7r-wE#nqYb>jh91QkU0R$H&5oMm&z? zFr|dhBZv)@ifNHSUW{wS_|P}bIAKut8d9%eVPSy^{gLC`xn{&HYLYEoN2UjVW?*{m z{fiIXPvUs$R&V)x?($LjZTX#ZoGW}w7sMob6-f(@^#u}_L86HV e?a=M`{Gpgbm|mmQ=}rwo$Pb`rnbD6(Fn<9P3eUU% literal 0 HcmV?d00001 diff --git a/infrasynth/shared/crypto.py b/infrasynth/shared/crypto.py new file mode 100644 index 0000000..02cf662 --- /dev/null +++ b/infrasynth/shared/crypto.py @@ -0,0 +1,46 @@ +from cryptography.fernet import Fernet, InvalidToken + + +def get_fernet() -> Fernet: + key = _resolve_crypto_key() + if not key: + raise ValueError("CRYPTO_KEY not configured. Set INFRASYNTH_SECURITY['CRYPTO_KEY'] or CRYPTO_KEY env var.") + return Fernet(key.encode() if isinstance(key, str) else key) + + +def _resolve_crypto_key() -> str | None: + from django.conf import settings + + key = getattr(settings, "INFRASYNTH_SECURITY", {}).get("CRYPTO_KEY") + if not key: + import os + + key = os.environ.get("CRYPTO_KEY") + return key + + +def encrypt(value: str) -> str: + f = get_fernet() + return f.encrypt(value.encode()).decode() + + +def decrypt(token: str) -> str: + f = get_fernet() + try: + return f.decrypt(token.encode()).decode() + except InvalidToken: + raise ValueError("Invalid Fernet token or corrupted data.") + + +def generate_key() -> str: + return Fernet.generate_key().decode() + + +def rotate_keys(old_key: str, new_key: str, tokens: list[str]) -> list[str]: + old_fernet = Fernet(old_key.encode() if isinstance(old_key, str) else old_key) + new_fernet = Fernet(new_key.encode() if isinstance(new_key, str) else new_key) + re_encrypted = [] + for token in tokens: + plaintext = old_fernet.decrypt(token.encode()).decode() + re_encrypted.append(new_fernet.encrypt(plaintext.encode()).decode()) + return re_encrypted diff --git a/infrasynth/shared/enums.py b/infrasynth/shared/enums.py new file mode 100644 index 0000000..5c84936 --- /dev/null +++ b/infrasynth/shared/enums.py @@ -0,0 +1,55 @@ +from enum import StrEnum + + +class _ChoicesEnum(StrEnum): + @classmethod + def choices(cls): + return [(m.value, m.name.replace("_", " ").title()) for m in cls] + + +class ChannelType(_ChoicesEnum): + EMAIL = "email" + SMS = "sms" + WHATSAPP = "whatsapp" + TELEGRAM = "telegram" + PUSH = "push" + + +class EventSeverity(_ChoicesEnum): + INFO = "info" + WARNING = "warning" + ERROR = "error" + CRITICAL = "critical" + + +class BillingInterval(_ChoicesEnum): + MONTHLY = "monthly" + YEARLY = "yearly" + + +class AuditAction(_ChoicesEnum): + CREATE = "create" + UPDATE = "update" + DELETE = "delete" + + +class SubscriptionStatus(_ChoicesEnum): + ACTIVE = "active" + PAST_DUE = "past_due" + CANCELLED = "cancelled" + EXPIRED = "expired" + TRIALING = "trialing" + + +class InvoiceStatus(_ChoicesEnum): + DRAFT = "draft" + OPEN = "open" + PAID = "paid" + VOID = "void" + UNCOLLECTIBLE = "uncollectible" + + +class ApprovalStrategy(_ChoicesEnum): + ANY = "any" + ALL = "all" + MAJORITY = "majority" diff --git a/infrasynth/shared/protocols.py b/infrasynth/shared/protocols.py new file mode 100644 index 0000000..847001d --- /dev/null +++ b/infrasynth/shared/protocols.py @@ -0,0 +1,23 @@ +from datetime import datetime +from typing import Any, Protocol, runtime_checkable + + +@runtime_checkable +class AuditableProtocol(Protocol): + pk: Any + usuario_creacion: Any | None + fecha_creacion: datetime | None + usuario_actualizacion: Any | None + fecha_actualizacion: datetime | None + + +@runtime_checkable +class EventProtocol(Protocol): + event_name: str + payload: dict + timestamp: str + + +@runtime_checkable +class TenantProtocol(Protocol): + tenant_id: str | None diff --git a/infrasynth/shared/results.py b/infrasynth/shared/results.py new file mode 100644 index 0000000..3c91f77 --- /dev/null +++ b/infrasynth/shared/results.py @@ -0,0 +1,23 @@ +from dataclasses import dataclass + + +@dataclass +class Result[T, E]: + value: T | None = None + error: E | None = None + + @property + def is_ok(self) -> bool: + return self.error is None + + @property + def is_err(self) -> bool: + return self.error is not None + + @staticmethod + def ok(value: T) -> "Result[T, E]": + return Result(value=value) + + @staticmethod + def err(error: E) -> "Result[T, E]": + return Result(error=error) diff --git a/infrasynth/shared/settings_utils.py b/infrasynth/shared/settings_utils.py new file mode 100644 index 0000000..dd9d197 --- /dev/null +++ b/infrasynth/shared/settings_utils.py @@ -0,0 +1,13 @@ +from django.conf import settings + + +def get_setting(prefix: str, key: str, default=None): + """ + Reads a setting with a given app prefix dict. + + Example: + get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE", True) + Looks up settings.INFRASYNTH_SECURITY["COOKIE_SECURE"] with fallback to default. + """ + config = getattr(settings, prefix, {}) + return config.get(key, default) diff --git a/infrasynth/webhooks/__init__.py b/infrasynth/webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3a53e2211039902f0ddcf1e9a837571168e7593b GIT binary patch literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpIxjU rAD@|*SrQ+wS5SG2!zMRBr8Fniu80+AJjemXAjT(VMn=XWW*`dy{FW^p literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc b/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ce01c97710425ff779d4c8f98592357952e76299 GIT binary patch literal 1010 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 literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc b/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..da1dde21c4dedb69d04e61b811f4af374d560188 GIT binary patch literal 2881 zcmb7`zi-<{6vvOhDVhEe#j+#EKk6ok;kxyY)D|+5}h!SN%iiO zR9mJXLkEu>vu5kqv4285S-36%iURFsKu)uC=zB-fRvgum3GnIe_~qT{ecpS=g`vSb zf$Pt|9^C)IAmk71WEb@1)&3WL9+Pe2DjS58ZN*hLly#M>>l)YAb*`@)+*micIYWwM zmbltDarG_o{ZRUT$Iq&kbhs^*1}M!Q%A8bMptO4^^HP}uWxj`UKq?17>GV)Ku5yjc z5B`nY-kdkY@J6^3HNxtbVYMCwAs0?I@X&|e$af7cpMT-kf`>kBo{pTnF`8*^H+EQs z26Y}pVWq=KL{B^I_n`5ZY!R-wgsa;Q*W|_(*toi?CMvGG8aG@WwrPm5Z0>BeuYwx) zY4_RRe{=Rhu5IZs6X12VMb*_^bx~PTx#=3*a!r`u5?8y=zxb`s%R&#!d%iIIpUM?3 zazP~uc{vCfG?@oO;E(6vLtEIMhf2>AdC%L6s*M`99nbr*QLd#=hCOdLpp4go&<`We zqb6)t7@+oR!rTq~T9t{S=P_R9LB%U`PJ^8W_Zdh>K-xyy5aYgILFDX$3HM2f2Jz|Mq2 zp=UTuWVkv*txHItbMb0_2&N<8E_VwF*N}msL135=7#0MEEoT0Ir6f4=J|>6+Ptn;F z|2`cCBWyoSro^lBc2}=js4t=!L(|Q_((yC!6j`tvV36kZr^ZVsquFxCnBE zICo^}h5XxG=~Z0e47@2ELxZ&2ND$$fM*bB6lc2-LeW9D|iS8tmvr2Lvwq;ni1KZRo zwnfF&5LcSb2(3#tzqv*N%*51cFbPXItfN*53)0QstMhYt;px#2FT{j?W7oi>+4s^J z-G8(nw490fPQ1}_im|odya=r8xHuY2Y5Fc2nY%dD7oZzG0Tr{tp9b#*_{{amXQJy8 zX|ZLs#@z&t$)X0W_%fN!MWUe}`beP8eb=E2m>V}iY7$5QgorVnS=4rbSm33QG}!JLXtMm7&8 ZR}UsvQ;iLp>BG{>L22b50v-9U{{#55NR$8o literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/models.cpython-314.pyc b/infrasynth/webhooks/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..56dc55b5699cbf942105319340e4826daad473b1 GIT binary patch literal 7652 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 QLl4hCIR9@3iuB9>1#aU3cmMzZ literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc b/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d040d4caa3117c774a29e777f4c5c7f87766516b GIT binary patch literal 3965 zcmahMOLG+GvFEYxT|LoC2;tQW;T7^OuOJLEu}yHmq(BUcD9VVb(e6kZnB7@_GfN_0 zvUyYmPKHZNPAN_iH}Vgh9CMO*L}Hw(kV>eOZ!0eqxpGSPH#5>oQ8}NqJzw|N^L^d@ z^=l5bq~d_%&p$SA8DW5b5E6`#KWWr(QiGeI2p7Pir+7g)FFN9R$&oUIOBdwxAt#i9 z8JJ84uWvmEU?y^0@Ng$OBX+?6C~`L_q2r=QJ?=9*F}@bYwMek$#1&x(veB=J=TcU5 zV@1u;3Z`b;ZfMTX&0mUI1O*)NX3UY&V4+fu zJQG3@!-_Z_2pRbwN^}~I35wJWX4Hu(vJ;;MC!vH;u&~>D?v7q|exT18WrGB80VQfJ z>T_DvbksSN%3_NJ+S}hBNGb_`jgJvb2sN07o8rk|0Thwi2UmmzArDuDPvEqu2qVbj z&qaSOAYE#j%QQz>LSK_XUAe54bT?7d?E*6@T%=b0r&_6E>S{$>G%c;@hV8 zlI<{8E*b>~k?@?wN}A)QRF#{U$f|17wu3eIQhvcI>G_hyv^(1E{3Nq(=>^Bm8|68s z*^6ao;hkB{*7LJk;kI5bHqrAx(PtMd>$aU|`n-WBF6JtWZc%PeT?E_w zzuoiyJdpn@f`T5U$Vy0&^OAxAXo{Ob8J-Do?~jo8D^Y}Pn`;;;d@aV;;@j4|m`TtI z#!S+W85#XF<)-*uE50vy=8d9=+_IOrX$yXG;E_UlU~EJizrjfjZUm78Q^J%uC7l+? zR5a#!1Z5x(Q}S+91;*(baZJNxaDqqhs65_o_&flUfd!j*6k%FO!cj3uY8gz4sFjqJ z8Kpl@n@tz_tb^;??>WBC^x~=hOUOzayAf5VLlP%$p>{T0c+TOD=b3mqwTRmb*^;B|6UCZCg$w zTcYiu(T`yFoTdnr$MOQ&N@NLSb0dB_$_hNLklcY}FDks8*v)k$75A zg{oMU)J+P?A8Lb-jsaA{O}mhRcMOz0JW1BuF|KtWJ-vg7=rNgR0U?kw0~fys5QDcs ziP7J2UpI`4*bwx?A=r#gywpB0#0>0*4F4mJ)6#x0glwGAm1FG`t{~WRx$4YX)pGG_ zb#}9kp`W~sJgzW0MV)|Qt5oyv=x!&Ln~G(Wu~=;pqXDt78P51}7=M&@8w!HvRH9%r zki+XACoypBmL4X9uE=BI@S1RK=(v9ZrGd}-w(4OnsC&7hVOKP#ut1%Gi6S%AUqW4@ zbIdB4SQfJq6(yrou^4K?sNPXVFtx?3jD^}Lx}sio#fphP$EGt!aQGQu!qiH$MeU3` zygg9#lHuegOsk-o_8FXbT<_q^_9%iHJpV*r>p8Lf;nn32rrt>64I%xn^nnM~@AXIe z(%C1ZdNgrKJ zzq^ut_hmBmu=ja#_|d0N&OSeQ@Pmc6T~%+pk6H8t=eHSksU;C<<@ZTnW+4m@o;u$CG8-JRdwSsMNF>gu`cE9b5^ zK|611Q)UL6YMy+Mo+O$dbC7N@#svHj@Z=oES1~J)bMQXO4~DLJ zoji?NO{%y-l^xn18M`tarLCyT!$R5GdH?Je7av@F*4n=u?cdTMXg@aXKY{$a+uv*C zcej7g-ftE}M(VK32>!p>Nkp42lXt`~Q|%9i9{g;%W8_8Z;EFu7nt?Xc1gAu zAB_0fJ_3CN=r}jQ+tQNmELcUBCGtfAR|xDUKzGZi#Ik+_&F$(S5eaZ%W^uyX)wR*F z+7Dla+UjB?bn;0@9q{qx6n`u$(+xr!5`^3Ndc6Z@8zR U0#rau)Ct{a84?B>fPf$IU&!P_>Hq)$ literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc b/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..563e021b0835880bbaa1b48ec14a44438cdd046b GIT binary patch literal 3740 zcmb7HOK%fb6u$HHBaV|mfCMKv!A+>4GSGq&k~Csbic^VzhICT|BgdYIX(k@`&NV3Q zCW{KO=%P!OEa4yYXCy6B(kpewNb+_xMVBFiGI^ z=U-UD@msvs}H9+bS$fQ8V02z-!4hUodkjV&SN+1USnTkLT3gjRl z(-FwDKn?+N7?2lQXAVl?*@umh9Lb;h8$0q6AMh+{mmK?hi+XvD53N+!-Fmfhr&_7G zc9ro|(D2NH+VK546a8OUj{VG{+pU&x#AtwdSYP+b)UGkxt(Jp*;IG=O9A1XP4tY$N zWDq7lNijtnLxN+-H)J2hR6}8!p~9v$em064MRY zPzo}O87hk#8qBWqZ133%_bgW8G1DwLj%o5_nOY@gRm>9O$$G8QEWahI`2>%fX0^0s zA_KntCyT+&yZgb{Q6C)LySM%Y(sq*ALNM{Gwgmpe1j+%AQ_*ZmmCc-A(Um8^wj%Hdpa1 zGzIWM_llc$|*Fb(Tt)I zfH5o~${%zdOQ>*RaIr%)FUXOsX!Gw07$1n34(@v0j?$ly( zQU8V%AduY0TOz>?k>PfzJlB7AaYKAJBsq@6FAQ?60+Ea1_^6UaU{#yrS}AkKLvJokPt@TW$0;h&p5gV zqAWyVLzxRCstMVuAX_bzt#0Vxx)``F&O>6_Lqdxf9=Dp|W-Tvs-E-@-Z1F^R`#W~l zOWG5Cr5%#jeFr{yC#`qCLj>i^Gvj7pv=yIVEK@`H6>PIWl_i8;Ln;2eBIPTRw0VK;NDOZe<6&jPNB z1_MzjPeoh67`fof6T3US){B>XBroH^d)`#s2hwu6FD(~(PJWD)Xo2AgPfW&50Anr@ zl97!TSXVMW!&TAbu`r27Jb~DBA_A^O5^y%~5hhe0=>6+`y`SkmbIbL7fLdh>@-~sT zq-xYHTb?IseT9LK!HK!J1>o!O4Y`;IxyZj+-pwqWR2Xx(DjJkpn=lqp7>hnH*xkWP z7*4){p0;;u4n6k(bB$x4X>z04!t_W)4AOzPNCy-?*7UaFyR^k*Uso!y7*Bll@c!zT zn9=cAt+eg9B`7?FTn}>auCK`FFp8qV^Lm)wL&BCG-%sa$P3L|~&+Oc7ul;z}cMc9% z&5!UjwLE4*TDWCBcj+b-d(cIy=~1CGL9hsaA|=7cjSt2p9wG1J1`pl))DV#F#jl$B>h2Vk5pZf-xDy6Q>rxoI2IR8s{0Vk0d}v#);G&)(e2-u#z9MR@K%L?Y+L literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f8288d8808259ed884270f3486732408f4a5b90c GIT binary patch literal 370 zcmdPq1%v$g=~#~=<2FhLogy?~4chB$^GMo$J0#u7$(hFC^L1{0`s2@?vB z8HLA!!eb3$QfAO(ehJd4$#{z`I5RyjF~?7n?G}GZR$^XyzFtaZaY159az+s|P{}Rn z{L+%7{L;LX_>|O~%(B#?%J|~a9O=-p178e*KZc56}mbkzm Rc2i1Ww#Fp}@gh)A0|3nXW`Y0! literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cf9e56c2c6b56c664f3cffb2979fefdc6788978b GIT binary patch literal 2429 zcmb7FU2Gdg5Z=8z|HO_jcHJ~p)3ga`-JBX~@&k=14GI;72o&6Fuo`4)UF=Kj#=bLq zmnNx%WC0JM1=SU;5PT1 z$0nKEvgQ+9T`u%`oHYWk4XvmEe%%k5CUqNXil`>_6qJRWuB2&`na!()spJb|)D1hc zM3Xqm8z!NARx>3`-BUWrE2hD2`vQG0n$h!WG_MooqB0YW5&eRiGL2|fn<9#_pqZH$ zCly1DPAaJxRZDM?N9WYZjIPfZQ6oF8Dd0&xR#>3T$)uubx~Z6IGD!m9#0{U(3quh- zMFQ@6#D=)^ywWW&Ed0*7CQr*8XGAM=2*$jB&wdMKuUFFQReo1U|y#K+^|V zWDlZ*lL6F-W2_!gTbGSpXt2f%)+`EqoNq=lisNR~&&QarXUMTA2nqHei()L(&rlcS z`~U3YLc*#{W)^FH#u(Va+M8o7JM7g&1(%Gi`*Lv?+?Uk@Pw}x!oO$w+(0j4(q~s;c z2^Cm1P`9e3^t4KaOkPP*UQ_3&C!-jdTy_!)D5Lbh{0eQ#sPpOUG?b3yChYlyy^^pJ zq?~C`4^)+*Ov7Qemch0okw|A}CtIl_*+DO#)zbEo~51IucKsn5mts zNnOuLnD{|rgVPGE1tDu-STg!xU^oil{zq(BIlIZG?+2{u*KTXN`@x6yoXJ^Ac2w%O zh9g*Rtf&Y8k0NSEaV}N|5A4-QMsM6%Um}~)FC;6$2~HC zy!P6HZ``-zt?SX-{@NXN|06O5eX9;<`~MYSd<M{zstZ5ggZ&XXG^KTNO6Y7gAxmnd- zizB8&65zAQeK%ftPViJj1^OQzMv2_S?e#&4XBU~P#_1GmJ3u^pD_(+BE+O# z0FkvKd#y8ph1R9dQLj2*fWIg;O`Dxn5;hIig`gp^j%1PDmVy^mlAT&$8uot_c?t9^ zC{@^a8>~t` z8QFj8FuHSi&#Bk>JHr@836DuQ+Y2C54mAlgsY|OkH*w6AnSK+eE6$3HT%D&AjIVnirCp&iC z<)&XpqnsK{ZF)t&?v)>S<$d4r_!Ea5JR&@p4DkjTg4sT3k`i$mMA!z?AJ=&|!hPZ* zMi0XX1I2$sUkN=P)r+h|oqg{3Uf-*k#lpJbbkeVgHKzs`8}VSx$pK#O<#q^bPB^`W zM*K4aNHoKmS3{Zh`q0;zTi<<0kPRJ2sgpy|bJWT(PW@@0A@fSlhL1&-1x01ZG)*;yR2|Q7#oZ zu_1NGr!|{1(awDEfnM=zM6dbOZrD|Qi~4({#F_4RJJe=}96B1v z>ovbzcgY5wL$TZEugLs!8j&RF1>~PW{!h4jp~NKl0+5_#Au;{kY)d@>_3N=O#@gf8 z+l%jZU}GTPYA-$Lz{7!jqkVIu1Mdywh4zhm9ata8bM5(j2iDG1h{jK}#S?AjxiKB>l$UF(s;=MpRvHCC|l_Psc$@ebe~i=-Z=XnE5hEU&pHd1@Jp_ A{{R30 literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/__pycache__/views.cpython-314.pyc b/infrasynth/webhooks/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..af02c485c28b465070d4c24ec6ac3ab98e6fd114 GIT binary patch literal 5026 zcmcgwO>7&-6`tkqlA^Q}DT?}+C0VkmUrUiK)N)eWk=#{MOT?NH3`Fu~MXoKz6sgP( zr6}k@hoJQ_EeceZ0zRfaxu@pd9D3A7E=Vj~q(FP=&8X-)=%syc$mL&D8}6Y4@$Jle zZ|BXv_syF(nrLhF6KMbbAoKAr1BCn)2X=E;I;X!72stG8h$5^ISzvs zi@9VsbMvut#Up!}7y5$YS`lR*^T~eZms?ni9AE*tm9=u2d!4;yo7hQYK zJIj4Gz%~7#+436AL9Q79%~sI7UYpDARp%Vynr)ydy=Kl~t{DW)_Seig!Zkyn83s++ zwpn{tt&b`lBP162JBnfs%VRK&ROY8fF&J5s*H!_*r{*CN`C9N1Y@@!krvUE~o zdMYMZqM{qSxvZhb+*V{IyOqmlQ_I=ZZZ4f=x6&D=Q!8Y%_u$0}uX8QWxbBjkN$=_O zpqbw^M~|(4C%b7U9 zOnpbEX)Tlf48E(=7NLZjv1n9LX4kjs10F=v$+H1DIXB`lAM`xBRtL^B&A*->VC8{b8RMqmU>P{||&tN~G zs-NVwOhqE8>isk|SSFp-vpH3zL0Ez1&24?GCz)Y6wM@nmKgk1x0IswXg+ZelBsH1Q z41;=*9op6DPTDX4Ic2tP&=wR(_c>7E{W9NbHFS+8x52QUNf}l|RdHLSld8rTO>gCy zZooqN;N4UB5$pY#?c9z&vy-FRp7wAiPIG*5GwJMosu>4awmr3_8T!nYmVBsZQ+52A zeSK>?mwRZ;@C`S;dq8_&MotExW6T2iDJe>W1?hq*T_{KsrZjOPO&!X`=*!E?>g`f9^m+t^aGfsbV@@ ze@fe)o`Pk)(#LJ6fcc~uykX=uIogLj?&!0?yMR(8Z^O?(N28B>3(|;*S!B+X=AJz?tWvC?gI1Gv@A!($8Mh}fnU#}m4#tvg^2=R(E$t}Ee;cQmQMQ1MRUQCexc?*F1C zHfVVgp0g|}KE*$8+w*Qfe_^_Ay$v&GorGAMH6rFVY^p*DjwUHBjj9tJn(dEY95jT zn|-8y6#y>xj8JC-vr$v4*~mNk$p}f4MQ1-r3$a$HUR^1TX$j~^R7)GWs(+f)d4(Ty zP~0$t?%Y_2>l_b)9XND zA&QWri$E+9R<{S2f)uT$X3O~iO5MOxPB&I_Z28l)!6@#e@&ZG0A`w^TFU_l~YYFw% z+6SvkRvVuIOBNNqK`{eao+QMKoh5&mom(1nemyDxVSm}QE zGYCwvXZWZGE(b4N18)C62#|6Kgqo|dF{_ONN>sR{?CASB`jDfOVxF;j8YR=UyOTlr zdNFHT(Izbz@_zD{spKmp(mXl=lrkb9vQ>*@~C+R!a@L z2IWN#KY;n(i=Y;!VqV!>UL*Y=tHES^vjlGA%=UTD?^IZHTOA6dgtctuph32t$b5`hvGdNV_^Im2mnA3z9kdilIwpalP_H!!SR9sdD&L$d0|L^ zya*CuvgC0K{YTq{fw#=jykHJ5AZMf$bO}?avuF-1mIw?=ZEj)o s=x(8Z&g`E %s (%s)", + event_name, + endpoint.url, + response.status_code, + ) + return delivery.id + + delivery.save() + return _handle_failure( + self, + delivery, + subscription, + endpoint, + f"HTTP {response.status_code}: {response.text[:500]}", + ) + + +def _build_payload(payload: dict, payload_template: str) -> str: + import json + + if payload_template: + try: + template = Template(payload_template) + return template.render(Context({"event": payload, "payload": payload})) + except Exception: # noqa: BLE001 + logger.exception("Failed to render webhook payload template") + return json.dumps(payload, default=str) + + +def _handle_failure(self, delivery, subscription, endpoint, error: str): + max_retries = int( + (endpoint.retry_policy or {}).get("max_retries", get_setting("INFRASYNTH_WEBHOOKS", "MAX_RETRIES", 5)) + ) + backoff = (endpoint.retry_policy or {}).get( + "backoff", get_setting("INFRASYNTH_WEBHOOKS", "RETRY_BACKOFF", "exponential") + ) + initial_delay = int(get_setting("INFRASYNTH_WEBHOOKS", "RETRY_INITIAL_DELAY_SECONDS", 60)) + + delivery.status = OutboundDelivery.Status.RETRYING + delivery.response_body = error[:4000] if not delivery.response_body else delivery.response_body + + if self.request.retries < max_retries: + countdown = initial_delay * (2**self.request.retries) if backoff == "exponential" else initial_delay + delivery.next_retry_at = timezone.now() + timedelta(seconds=countdown) + delivery.save() + logger.warning("Webhook delivery failed (attempt %s): %s", self.request.retries + 1, error) + raise self.retry(exc=Exception(error), countdown=countdown) from None + + delivery.status = OutboundDelivery.Status.FAILED + delivery.completed_at = timezone.now() + delivery.save() + outbound_delivery_failed.send( + sender=OutboundDelivery, + delivery_id=delivery.id, + event_name=delivery.subscription.event_name, + error=error, + ) + logger.error("Webhook delivery gave up: %s", error) + return None diff --git a/infrasynth/webhooks/filters.py b/infrasynth/webhooks/filters.py new file mode 100644 index 0000000..b0465f9 --- /dev/null +++ b/infrasynth/webhooks/filters.py @@ -0,0 +1,56 @@ +import django_filters + +from .models import ( + InboundEndpoint, + InboundEvent, + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) + + +class OutboundEndpointFilter(django_filters.FilterSet): + class Meta: + model = OutboundEndpoint + fields = {"name": ["exact", "icontains"], "is_active": ["exact"]} + + +class OutboundSubscriptionFilter(django_filters.FilterSet): + class Meta: + model = OutboundSubscription + fields = { + "event_name": ["exact", "icontains"], + "is_active": ["exact"], + "endpoint": ["exact"], + } + + +class OutboundDeliveryFilter(django_filters.FilterSet): + class Meta: + model = OutboundDelivery + fields = { + "status": ["exact"], + "subscription": ["exact"], + "attempt": ["exact"], + } + + +class InboundEndpointFilter(django_filters.FilterSet): + class Meta: + model = InboundEndpoint + fields = { + "slug": ["exact"], + "source": ["exact"], + "is_active": ["exact"], + } + + +class InboundEventFilter(django_filters.FilterSet): + class Meta: + model = InboundEvent + fields = { + "endpoint": ["exact"], + "event_type": ["exact", "icontains"], + "is_processed": ["exact"], + "is_verified": ["exact"], + } diff --git a/infrasynth/webhooks/inbound/__init__.py b/infrasynth/webhooks/inbound/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f6169119ab6609bbc1b7e4786728279b73e5330c GIT binary patch literal 185 zcmdPq_I|p@<2{{|u766{eq&pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpAAx* wlwS&zi;vID%PfhH*DI*J#bJ}1pHiBWYFESxvluL;wH) literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc b/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..52e57383a6a4a5a8ea705ac0fae49af8eb17f220 GIT binary patch literal 1324 zcmah{&ubGw6rS0gY|?6L?GLM>6fKBB?Sk#WgZLv}S_nl~4T7}8WOqVhH@k6WQpqg` z!GA%I9((lY(f>eC>Ks(?d!t_plu%jhD&WjZ7cvgurU zOh^Q$C&(?@h8k|u`_OTQE+7pUpyTHqI}m8gJu!$=5!rHEibyJLJuO2ejr9QfDyh1b zMV-)C7bvl;nWiz&zjzyurD~l&6Qb;kVPS^t-7N+hWVVsnQ2BG}`YZ zEhY3IG3^UY=+1A7a7!l9o^$_^Y_`*M3-4yF`|Z4Uz0@1ngPe;bNsTa)bGz^_LrZ%v zcu}|R(}?~X7!yr056vTUai2{2riJ2Ar=c61`aDDOfVz?StL7jeTP(; zWm?89H3sJjRj3XCXpB7rOHG4ey=|#Ft${tZPj$HqRjLt&AQWPKM@!xXB*j>~PC7;XkVnb&#{POFZZgrAzo?J2Ym0~9mMidQpuItzd;>Z1S| zceOXH_I&X*tG!?VFK1z!myKoau6<}z^WapNrs}F=XyZ7tnly&~A+6j~X(+X}$M<2a z@L_Aav|Y{L&r+{f$83f3M08OU3IBfV@&Kr(QAz)5@3gE{R81K8%%tug| zOT5{4to{jiwQTpHATWzr2VuFQ87ibCuPV!jyk#`!!m3=(8@r;;f&(AdM*zOMl+ur6 R@taqoH|W!u?*#u@+;1OvG%5f9 literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/inbound/handlers.py b/infrasynth/webhooks/inbound/handlers.py new file mode 100644 index 0000000..a33d913 --- /dev/null +++ b/infrasynth/webhooks/inbound/handlers.py @@ -0,0 +1,9 @@ +from abc import ABC, abstractmethod + + +class BaseInboundHandler(ABC): + @abstractmethod + def verify(self, payload: dict, headers: dict, secret: str) -> bool: ... + + @abstractmethod + def process(self, event_type: str, payload: dict) -> dict: ... diff --git a/infrasynth/webhooks/migrations/0001_initial.py b/infrasynth/webhooks/migrations/0001_initial.py new file mode 100644 index 0000000..21c9a24 --- /dev/null +++ b/infrasynth/webhooks/migrations/0001_initial.py @@ -0,0 +1,138 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="InboundEndpoint", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("slug", models.SlugField(unique=True)), + ( + "source", + models.CharField( + choices=[ + ("stripe", "Stripe"), + ("github", "GitHub"), + ("mercadopago", "Mercado Pago"), + ("custom", "Custom"), + ], + max_length=50, + ), + ), + ("secret", models.CharField(max_length=500)), + ("handler", models.CharField(max_length=500)), + ("is_active", models.BooleanField(default=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "webhooks_inbound_endpoint", + }, + ), + migrations.CreateModel( + name="OutboundEndpoint", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=255)), + ("url", models.URLField(max_length=1000)), + ("secret", models.CharField(max_length=500)), + ("is_active", models.BooleanField(default=True)), + ("retry_policy", models.JSONField(blank=True, default=dict)), + ("headers", models.JSONField(blank=True, default=dict)), + ("timeout_seconds", models.PositiveIntegerField(default=30)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "db_table": "webhooks_outbound_endpoint", + }, + ), + migrations.CreateModel( + name="InboundEvent", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_type", models.CharField(max_length=255)), + ("raw_payload", models.JSONField()), + ("is_verified", models.BooleanField(default=False)), + ("is_processed", models.BooleanField(default=False)), + ("error", models.TextField(blank=True)), + ("received_at", models.DateTimeField(auto_now_add=True)), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="events", + to="infrasynth_webhooks.inboundendpoint", + ), + ), + ], + options={ + "db_table": "webhooks_inbound_event", + }, + ), + migrations.CreateModel( + name="OutboundSubscription", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("event_name", models.CharField(db_index=True, max_length=255)), + ("is_active", models.BooleanField(default=True)), + ("payload_template", models.TextField(blank=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "endpoint", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="subscriptions", + to="infrasynth_webhooks.outboundendpoint", + ), + ), + ], + options={ + "db_table": "webhooks_outbound_subscription", + "unique_together": {("endpoint", "event_name")}, + }, + ), + migrations.CreateModel( + name="OutboundDelivery", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("payload", models.JSONField()), + ("response_status", models.PositiveSmallIntegerField(blank=True, null=True)), + ("response_body", models.TextField(blank=True)), + ("attempt", models.PositiveIntegerField(default=0)), + ( + "status", + models.CharField( + choices=[("success", "Success"), ("failed", "Failed"), ("retrying", "Retrying")], + default="retrying", + max_length=20, + ), + ), + ("next_retry_at", models.DateTimeField(blank=True, null=True)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ( + "subscription", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="deliveries", + to="infrasynth_webhooks.outboundsubscription", + ), + ), + ], + options={ + "db_table": "webhooks_outbound_delivery", + }, + ), + ] diff --git a/infrasynth/webhooks/migrations/__init__.py b/infrasynth/webhooks/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21a0e67da104ac76ce83d301aea10d17ba1ffe17 GIT binary patch literal 5591 zcmcgwO;Fp~6_$UnjWJjz27>X!KNwz25}O|f^7lfB34#1FB6*9~f=0G3phQT1(lrU0 zX(x+L*Kf&m({$D8bdx2^EVESfG-J=*G}CU{9n)mGeLa$F5RGA+nZD;tq zzH@ai=X<(?6#RaAB0l}=07d=7>@grYvtn6m8nEvLkA24-jBA9-cKpliKFqpFa9h|^I4sDLN_^Wgk3_X>L+va0 zWBoo}?KJ0KgSi_FN8ye&-%3vd<+y=AZpO0~Sm|{PHk$Cv1^>KE2jiRd z+Jn<>#t-OdEA;wLOkmb^i0cGge=8i=3_5UKC)vwU%+e#*`VH@r}dbGm!*8dEgHmB^e&(JRERFNWZqVN)t&<;{cvWi%V+eXA|Ub8OP&2cYS5lNeP zi+EMBx(Q>xsw8}0MZ~AdRi%tz&*v|1$wC>aN*DwM;|M0h!Y9~Z5&sq^@gh=4r=YT2 z84Ek6SwD|9xT=Uru;CWVVHmbOmE`9v$MYmut?;JH_OpP+0-lioUt%#=5)m1Ej!Ijy z{7hv9n?8#g{Ied#+(HuHhJZv~C4RYLZ%OG#ReU^Bo~j}W|Aeg#34zxTn2Ib458PF$*kZ2~5Whoq$ox^r06d)J)3`f}8}Ad1LWz1(7a= zd(Kw4T~X#BClDJ5zW~niBm|v`B11S;=yf1Pk(I1V88N-+QcxK|2+f#~&>_l@Q^Va-x7oy%cQ^g z;!(IQUeRVuSF0tptO!=9n`|v1xviuL5|3Wk8lfB1S&X(T<}{>Rb-b*;%(U{i8Q`YF zWcbjd<|Ec=hJkbMtzZc3ZT(TZ`?pLd2Q>DbeYB$VxHaI~9+4>f(JG@ggbsJ#J z$}&SWL;|d}#IP(0vMd}Bt0IDSh-JU8a-yjg=!IE!Lr_#K3KEiJ81M^{fMKN~As$sA z{YWZ9z!dX9Lb{69()PeSYbxg9kA!XsoAa>fEtTReUgqb@4+({X2yC_f);7_tl*bXZl{Yi7`mp>*Xxnk-eN6& zL62Y9_Zac(8vRW@65rdX4NvOBllvKCcuu2l)+1l-?bH&}dSd!*)JQC9^c~00{$nF? zOQVYi*}iDcL5_+HY4O;775Qcq3pd*AX#>Yhg5cTjy(02*8&-5_Xh_U;Apw2|YRSV{%HPr|S`GU|LU3@833(W{m3* zi&;TW751MPsiH)=k-VePON|#V>8VTm3vaQJ zy06g>K17Et$&BjJ(R%vapR<3;*3wh&(^IwdwfE_3wRG`)x>z5*SRcCi$?r9F=&(&z zh5FFEgH<>TtkM>%>vcM%Wo~FAa|azBQ1NI#(91dKovz};iuy60)95_Zwkfd%J+ttx zpndzDk@=NI|GFMYz8-^M>*=ZesFA**(Q^%f&g${mw=N@YrrTN5B)L^jj@L&<9Zu6! zZ^|??qo-%~AHVY&=?5D9uw|Gm-TZI*)9s{xvOaX%G3Ur21Zu`9{3{8F&u`qnb@lpw zcUO^{i(b}82JK}$$Cq;UUo>;gwOrXU*Wb>==hhd=`Uu&prnyGjrYa^daNYjk-B48M qnty;)Q*b%(xLmG}KFXE)8+GoJujqF9ehAj+%zHZXAIj`mCjJW}PbuO6 literal 0 HcmV?d00001 diff --git a/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ec9d1bc636cd15e227ec4c83649e94f7b09c195f GIT binary patch literal 188 zcmdPq_I|p@<2{{|u766{(+*pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcnv{{BpIxk< zo0(peSdy8aSF9f&pP83g5+AQuP dict[str, EventDefinition]: + return dict(cls._events) diff --git a/infrasynth/webhooks/serializers.py b/infrasynth/webhooks/serializers.py new file mode 100644 index 0000000..b83d044 --- /dev/null +++ b/infrasynth/webhooks/serializers.py @@ -0,0 +1,57 @@ +from rest_framework import serializers + +from .models import ( + InboundEndpoint, + InboundEvent, + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) + + +class OutboundEndpointSerializer(serializers.ModelSerializer): + class Meta: + model = OutboundEndpoint + fields = "__all__" + read_only_fields = ["created_at", "updated_at"] + + +class OutboundEndpointInfoSerializer(serializers.ModelSerializer): + class Meta: + model = OutboundEndpoint + fields = ["id", "name", "url"] + + +class OutboundSubscriptionSerializer(serializers.ModelSerializer): + endpoint_info = OutboundEndpointInfoSerializer(source="endpoint", read_only=True) + + class Meta: + model = OutboundSubscription + fields = "__all__" + read_only_fields = ["created_at", "updated_at"] + + +class OutboundDeliverySerializer(serializers.ModelSerializer): + class Meta: + model = OutboundDelivery + fields = "__all__" + read_only_fields = ["created_at", "completed_at"] + + +class InboundEndpointSerializer(serializers.ModelSerializer): + class Meta: + model = InboundEndpoint + fields = "__all__" + read_only_fields = ["created_at", "updated_at"] + + +class InboundEventSerializer(serializers.ModelSerializer): + class Meta: + model = InboundEvent + fields = "__all__" + read_only_fields = ["received_at", "processed_at"] + + +class InboundReceiveSerializer(serializers.Serializer): + payload = serializers.JSONField() + headers = serializers.JSONField() diff --git a/infrasynth/webhooks/signals.py b/infrasynth/webhooks/signals.py new file mode 100644 index 0000000..3e225a0 --- /dev/null +++ b/infrasynth/webhooks/signals.py @@ -0,0 +1,6 @@ +from django.dispatch import Signal + +outbound_delivery_succeeded = Signal() +outbound_delivery_failed = Signal() +inbound_event_received = Signal() +inbound_event_processed = Signal() diff --git a/infrasynth/webhooks/signature.py b/infrasynth/webhooks/signature.py new file mode 100644 index 0000000..f11c700 --- /dev/null +++ b/infrasynth/webhooks/signature.py @@ -0,0 +1,30 @@ +import hashlib +import hmac +import time + + +def sign_payload(secret: str, payload: str, timestamp: int | None = None) -> str: + ts = timestamp or int(time.time()) + message = f"{ts}.{payload}".encode() + digest = hmac.new(secret.encode(), message, hashlib.sha256).hexdigest() + return f"t={ts},v1={digest}" + + +def verify_signature(secret: str, payload: str, signature_header: str, tolerance_seconds: int = 300) -> bool: + try: + parts = signature_header.split(",") + ts_part = parts[0] + sig_part = parts[1] + ts = int(ts_part.split("=")[1]) + sig = sig_part.split("=")[1] + except (IndexError, ValueError, AttributeError): + return False + + now = int(time.time()) + if abs(now - ts) > tolerance_seconds: + return False + + expected = sign_payload(secret, payload, timestamp=ts) + expected_sig = expected.split(",")[1].split("=")[1] + + return hmac.compare_digest(sig, expected_sig) diff --git a/infrasynth/webhooks/urls.py b/infrasynth/webhooks/urls.py new file mode 100644 index 0000000..6bdf51f --- /dev/null +++ b/infrasynth/webhooks/urls.py @@ -0,0 +1,28 @@ +from django.urls import URLPattern, URLResolver, path +from rest_framework.routers import DefaultRouter + +from .views import ( + InboundEndpointViewSet, + InboundEventViewSet, + InboundReceiveView, + OutboundDeliveryViewSet, + OutboundEndpointViewSet, + OutboundSubscriptionViewSet, +) + +router = DefaultRouter() +router.register("outbound/endpoints", OutboundEndpointViewSet) +router.register("outbound/subscriptions", OutboundSubscriptionViewSet) +router.register("outbound/deliveries", OutboundDeliveryViewSet) +router.register("inbound/endpoints", InboundEndpointViewSet) +router.register("inbound/events", InboundEventViewSet) + +urlpatterns: list[URLPattern | URLResolver] = [ + path( + "inbound/receive//", + InboundReceiveView.as_view({"post": "create"}), + name="inbound-receive", + ), +] + +urlpatterns += router.urls diff --git a/infrasynth/webhooks/views.py b/infrasynth/webhooks/views.py new file mode 100644 index 0000000..91d0286 --- /dev/null +++ b/infrasynth/webhooks/views.py @@ -0,0 +1,101 @@ +from rest_framework import status, viewsets +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.response import Response + +from .filters import ( + InboundEndpointFilter, + InboundEventFilter, + OutboundDeliveryFilter, + OutboundEndpointFilter, + OutboundSubscriptionFilter, +) +from .models import ( + InboundEndpoint, + InboundEvent, + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) +from .serializers import ( + InboundEndpointSerializer, + InboundEventSerializer, + InboundReceiveSerializer, + OutboundDeliverySerializer, + OutboundEndpointSerializer, + OutboundSubscriptionSerializer, +) +from .signals import inbound_event_received + + +class OutboundEndpointViewSet(viewsets.ModelViewSet): + queryset = OutboundEndpoint.objects.all() + serializer_class = OutboundEndpointSerializer + permission_classes = [IsAuthenticated] + filterset_class = OutboundEndpointFilter + search_fields = ["name"] + + +class OutboundSubscriptionViewSet(viewsets.ModelViewSet): + queryset = OutboundSubscription.objects.select_related("endpoint").all() + serializer_class = OutboundSubscriptionSerializer + permission_classes = [IsAuthenticated] + filterset_class = OutboundSubscriptionFilter + search_fields = ["event_name"] + + +class OutboundDeliveryViewSet(viewsets.ReadOnlyModelViewSet): + queryset = OutboundDelivery.objects.select_related("subscription__endpoint").all() + serializer_class = OutboundDeliverySerializer + permission_classes = [IsAuthenticated] + filterset_class = OutboundDeliveryFilter + + +class InboundEndpointViewSet(viewsets.ModelViewSet): + queryset = InboundEndpoint.objects.all() + serializer_class = InboundEndpointSerializer + permission_classes = [IsAuthenticated] + filterset_class = InboundEndpointFilter + search_fields = ["name", "slug"] + + +class InboundEventViewSet(viewsets.ReadOnlyModelViewSet): + queryset = InboundEvent.objects.select_related("endpoint").all() + serializer_class = InboundEventSerializer + permission_classes = [IsAuthenticated] + filterset_class = InboundEventFilter + + +class InboundReceiveView(viewsets.ViewSet): + permission_classes = [AllowAny] + + def create(self, request, slug=None): + serializer = InboundReceiveSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + endpoint = InboundEndpoint.objects.get(slug=slug, is_active=True) + except InboundEndpoint.DoesNotExist: + return Response({"detail": "Endpoint not found"}, status=status.HTTP_404_NOT_FOUND) + + payload = serializer.validated_data["payload"] + headers = serializer.validated_data["headers"] + + event_type = headers.get("X-Event-Type", "unknown") + + event = InboundEvent.objects.create( + endpoint=endpoint, + event_type=event_type, + raw_payload=payload, + ) + + inbound_event_received.send( + sender=InboundReceiveView, + event=event, + payload=payload, + headers=headers, + ) + + return Response( + InboundEventSerializer(event).data, + status=status.HTTP_201_CREATED, + ) diff --git a/infrasynth/workflows/__init__.py b/infrasynth/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc b/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cc623b92859b27e657151ecb17748bbf7b4039f7 GIT binary patch literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcUzDAelV4t} sA0MBYmst`YuUAlci^C>2KczG$)vkyYXgRGX$krAY-fs0oOm2Pv%|MG;CFw4T~wvOAk@yV+f5CN1eH zH?RI1{x^Cu3={-U-U^ZGP2XfUX$SK1-kbOJ`_0??*XBwH&cFBJp_xPIr#7a?%9Qav zP=;t9VdD*YZAfDkRZ#=sTn*v;p7A)J#>pHuZ=!tyzG%{oS z2owr+_mDI&lDU0T=9f?mU8IR~(!zOJz-A4}BDSQxi==}KP`{|=Kg9gV591S&mOs7a z9nz>9x4cfP7kexet>-~^voGVVfQ8=aVi5V9ia{g~S|`&~b;V0&UUxd39@2&%V6(^T zpj_fl0ROJBGi66dW(VhR9-G+01zdcR-LE2S!_*E3b~P*WTa3y+XP9*ZA^AW#v*yZ~ z0Xuy#RqHJ31`(_KF|X4k;qfsIA9koRnpDEfl7n4#dc5c084!|Z0B5I^8ThQM_c!}axyFhXIE;0_jvSD>V_LfT;=U(|Y)5Fvqf zmryBr(CJGiAa{WfH>5&%fh%1WhJ?Vw&s}jsnqIUGegh=en=%n5C}I`F-%kK@bo1#~ zb7@p=3}22aYr|K6OvfsnBM^LPnxP-DoNd3Rk?U_UEm+gqBv3a|_OlfEI$r}*cNZGM XdEPLLGt~H9xMq|-2NwwbCRzUhvh64S literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/engine.cpython-314.pyc b/infrasynth/workflows/__pycache__/engine.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7e6dd8a2b669928eb4d0de60849eefe29646faa2 GIT binary patch literal 15609 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 ULz)cbcxp`QG-YcP`lRCj0s4A*a{vGU literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/filters.cpython-314.pyc b/infrasynth/workflows/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9e6b32c6100a17778f5246a0d1f62a49df819271 GIT binary patch literal 3347 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 OG;VVJo4|z_<$nR#7kUc- literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/signals.cpython-314.pyc b/infrasynth/workflows/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b8abd84e307959ba122306323f64aeafaf7ced58 GIT binary patch literal 427 zcmZ9HJx>BL7{}W?MWZ0(41;kn>U1&E#lgWi>p~1JEPCm+h3mbvq%9&FyCa{%)lWj) zxWvIYv6=7%>=`tkPtyLM-}CZMd$Lp80ygj>iC?UK6z~(yDp_`IG6DCX1FyjqH1Gi2 z!9#GiY8$6SZi&`Pv|gf0$2kVRyC{5om{)FjU*NRuZ{&3vW6_r_%Jl#n5-Hb?nlkzfmc=XbHi~4% zP$m_AzzMohGG@eR#6?eGJr*W94Y6h@#3W&Y{;8vpR7o$DBaQU;KXhv_&NnYINrx%B uR5fc4?a|0ypDu*(4II7i?oH2UpfRsEpB^XGd2@f-odK_;ufS8=cIOLX>vsPD literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/urls.cpython-314.pyc b/infrasynth/workflows/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a71772bb039a018c3632fc236577ff3eac5d7b39 GIT binary patch literal 1210 zcma)*zi$&U6vus+%l*hDxi$qV0cr_|Awo?+DgvPjfy8hTQYmg}hw4Dr#A&Zhe3k9A zYNx^ih^0(0vNH0YoKyy62{ACSwd&9nyN$1h#1Ie9_Qzkp_xx<%DwhgCw9i_1eKrTc zZ#hxT91M<`2EZP8382;lzQ#0PXZoa^>&={RFhh(rm}};JlbOE7EZ=6f?=Z(Nu!8S0 zcM=3f6-*D@v0_l1yP|@e_s|Gjn1|*)k%w$z*Z*}^;6Qr_ofBBFFoezxY*-vZR|-s6 z9zy2@1vv7LPOkzu8faB{qN``8%i3vCU)iShv%an|4_0n~+SnfnYC5+f-0Jq*h#Q+B zTdQfj^aORnewV?x&k(7Z{NilfMvp0tR&ftuwiu!9dBphW0tqpVSQKN0#+PEU(dow9 zO-xycTSzU8sRiju!BqWhg(9+rh+6QJS^>9>IuSk%MRzZ34}wf(sL0!12`R#15Ai$} zQRr?t6`szy%hH6iUNV$B(I9cUTS<4PjJsi4g{+4RP{1QQwz3LEg1R1+dPA`Cxyj;w0iLz57KMt{^v88dOk>8BC!f^9!3%3bc zji|UiZpawi6;|A?0GNIS$3hDD)5codLycZc!mV(lF-PKc)MB&|VUYpd!E9|x7PPSv zwl)xMXLv(ZhBl=4`sNO|XL@nF-$f6}gzzoj9Q`KDVNTPuU*P6XaQnotH2nk!b5sF^ z@eh-G<_}=L^@3F s#e`0#xd{C+$7}pPTx~IQ!iTpx;0V9<`LI3~& literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/validators.cpython-314.pyc b/infrasynth/workflows/__pycache__/validators.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61ff781d8acc4d35668e96e50342f040637fe205 GIT binary patch literal 1880 zcmZuxL2nyH6n?Y2cATWH<3?$KmNvK$VU@;BViiXO#DPPgQj~6mIiO^;S+5f}>)kRl zZpo<@AfcWrZas3%k$=*tDuuaFi392_V5IiezBjve+{h#O`R$uG^S(Fld++k;`~={7 z^>ujb2?O|>KKd)0D!s2z>B1Jc>`Pc-fH4Co2$*OkZT}=-u+w3~H zo2gofQ8{k*5|qcD(`VVzxeXDk*pEY96rD)5+5z`>1Aouk4g){E$NZ(|p|^ye9%9)9 z#avLUuQ+2IwC4F?KE1x!lbgzShPK* z^PKykCncFVm*idQ>Km2axE)m5vGDGAdldw7EAW-9w4$c)2+Dv)@zI8+-y2t&>fm^g(8B&GbhK`sc85d=z3qImf; zg8Rp*{qEEffaEfos2jNR22sZoqEpqyNFHjgGMs5pZP(sz;{v#M-s+YhDA_bJC^ z*xAwJ#~u{qxWvNX2*imJ_u(z{_st6v@w)J=y!4=Ozwj{sI5%(J(x(RLuR#tqv`{0C z%3*`NPOLhsrtRF%%o6XTkG6LLY)

Vi@?{g`r8TqcjV-=OCJJDwvh%2^Z$yKv^g zz5Dl`7D|VO(yyOBE-XIEEhhLWZW)L_ZjT_=aGMh)mMjOngW+(Eo zFE7yMMG|u;h8vCuD@YhzAERO#?vLuV?)7Jxsedh&S?<}8Egq3}ltqnfO9R`|iy|Wa z5faRF(>jj+tlGcbigpAQL{Mdt&Nk6#!X)0wuv$%~%V%O4H>jHO3W_6}G4>ZMU`T8F ee~Yn|hig5Iw1SbXX+*s|v3w5E*! literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/__pycache__/views.cpython-314.pyc b/infrasynth/workflows/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9fb6464a0c195cc904d1c79ad7064a61cb400536 GIT binary patch literal 13292 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 Iq-pE@Kk_&30{{R3 literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/apps.py b/infrasynth/workflows/apps.py new file mode 100644 index 0000000..8f27a5b --- /dev/null +++ b/infrasynth/workflows/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class WorkflowsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.workflows" + label = "infrasynth_workflows" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "workflows", + name="Workflows", + description="Workflow engine for approval-based processes", + default=True, + category="operations", + ) diff --git a/infrasynth/workflows/engine.py b/infrasynth/workflows/engine.py new file mode 100644 index 0000000..75be527 --- /dev/null +++ b/infrasynth/workflows/engine.py @@ -0,0 +1,262 @@ +from django.db import transaction +from django.utils import timezone + +from infrasynth.shared.enums import ApprovalStrategy + +from .models import ( + NodeAssignment, + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) +from .signals import ( + decision_submitted, + instance_completed, + instance_started, + node_reached, +) + +REJECT_DECISIONS = {"rejected", "reject", "denied", "deny", "declined"} + + +class WorkflowEngine: + """Workflow engine. Pure methods, no DRF dependencies.""" + + @transaction.atomic + def start(self, workflow_slug: str, owner, initial_data: dict | None = None) -> WorkflowInstance: + """Starts a new workflow instance at its START node.""" + workflow = Workflow.objects.filter(slug=workflow_slug).first() + if workflow is None: + raise ValueError(f"Workflow '{workflow_slug}' not found.") + if not workflow.is_active: + raise ValueError(f"Workflow '{workflow_slug}' is inactive.") + + start_node = workflow.nodes.filter(node_type=WorkflowNode.NodeType.START).first() + if start_node is None: + start_node = workflow.nodes.order_by("id").first() + if start_node is None: + raise ValueError(f"Workflow '{workflow_slug}' has no nodes.") + + instance = WorkflowInstance.objects.create( + workflow=workflow, + current_node=start_node, + owner=owner, + status=WorkflowInstance.Status.IN_PROGRESS, + metadata=initial_data or {}, + ) + + instance_started.send(sender=WorkflowInstance, instance=instance, workflow_slug=workflow_slug, owner=owner) + node_reached.send(sender=WorkflowInstance, instance=instance, node=start_node, visit_number=1) + return instance + + @transaction.atomic + def submit_decision( + self, + assignment_id: int, + decision: str, + data: dict | None = None, + *, + user=None, + comments: str = "", + ) -> WorkflowInstance: + """Records a decision on an assignment and advances if approvals are met.""" + assignment = NodeAssignment.objects.select_related("instance", "instance__current_node", "node").get( + pk=assignment_id + ) + + if assignment.has_processed: + raise ValueError("This assignment has already been processed.") + + if user is not None and assignment.user_id != user.id: + raise PermissionError("User is not assigned to this node.") + + assignment.has_processed = True + assignment.decision = decision + assignment.comments = comments + assignment.submitted_data = data or {} + assignment.processed_at = timezone.now() + assignment.save() + + instance = assignment.instance + decision_submitted.send( + sender=WorkflowInstance, + instance=instance, + node=assignment.node, + user=assignment.user, + decision=decision, + data=assignment.submitted_data, + ) + + if self._approvals_met(assignment.node, instance, assignment.visit_number): + self._advance(instance, decision) + + return instance + + def get_node_states(self, instance_id: int) -> dict[int, str]: + """Visual state of every node: ACTIVE, COMPLETED, PENDING, REJECTED.""" + instance = WorkflowInstance.objects.select_related("current_node").get(pk=instance_id) + nodes = instance.workflow.nodes.all() + states: dict[int, str] = {} + + for node in nodes: + if instance.current_node_id == node.id: + states[node.id] = "ACTIVE" + else: + states[node.id] = "PENDING" + + assignments = NodeAssignment.objects.filter(instance=instance).select_related("node") + for assignment in assignments: + if assignment.has_processed: + if (assignment.decision or "").lower() in REJECT_DECISIONS: + states[assignment.node_id] = "REJECTED" + elif states.get(assignment.node_id) != "ACTIVE": + states[assignment.node_id] = "COMPLETED" + + if instance.status == WorkflowInstance.Status.COMPLETED: + for node_id in states: + if states[node_id] == "ACTIVE": + states[node_id] = "COMPLETED" + return states + + def get_route(self, instance_id: int) -> list[dict]: + """Route followed by the instance (visited nodes + decisions).""" + assignments = ( + NodeAssignment.objects.filter(instance_id=instance_id) + .select_related("node", "user") + .order_by("processed_at", "visit_number") + ) + return [ + { + "node_id": a.node_id, + "node_name": a.node.name, + "visit_number": a.visit_number, + "user": ({"id": a.user_id, "display": str(a.user)} if a.user_id else None), + "decision": a.decision, + "comments": a.comments, + "submitted_data": a.submitted_data, + "has_processed": a.has_processed, + "processed_at": (a.processed_at.isoformat() if a.processed_at else None), + } + for a in assignments + ] + + def get_role_in_instance(self, instance_id: int, user) -> str: + """OWNER | ASSIGNEE | OBSERVER | NONE""" + instance = WorkflowInstance.objects.get(pk=instance_id) + if user is None or not getattr(user, "is_authenticated", False): + return "NONE" + if instance.owner_id == user.id: + return "OWNER" + if NodeAssignment.objects.filter(instance=instance, user=user, has_processed=False).exists(): + return "ASSIGNEE" + if WorkflowObserver.objects.filter(instance=instance, user=user).exists(): + return "OBSERVER" + return "NONE" + + @transaction.atomic + def assign_users( + self, instance_id: int, node_id: int, users: list, is_required: bool = True + ) -> list[NodeAssignment]: + """Assigns users as responsible for a node. Returns created assignments.""" + instance = WorkflowInstance.objects.get(pk=instance_id) + node = WorkflowNode.objects.get(pk=node_id) + last_visit = ( + NodeAssignment.objects.filter(instance=instance, node=node) + .order_by("-visit_number") + .values_list("visit_number", flat=True) + .first() + ) + visit_number = (last_visit or 0) + 1 + + created = [] + for user in users: + assignment, _ = NodeAssignment.objects.get_or_create( + instance=instance, + node=node, + user=user, + visit_number=visit_number, + defaults={"is_required": is_required}, + ) + created.append(assignment) + return created + + @transaction.atomic + def add_observer(self, instance_id: int, user) -> WorkflowObserver: + """Adds a read-only observer to an instance.""" + instance = WorkflowInstance.objects.get(pk=instance_id) + observer, _ = WorkflowObserver.objects.get_or_create(instance=instance, user=user) + return observer + + def _approvals_met(self, node: WorkflowNode, instance: WorkflowInstance, visit_number: int) -> bool: + assignments = list(NodeAssignment.objects.filter(instance=instance, node=node, visit_number=visit_number)) + if not assignments: + return False + required = [a for a in assignments if a.is_required] or assignments + processed = [a for a in required if a.has_processed] + if not required: + return False + + min_needed = min(node.min_approvals or 1, len(required)) + strategy = node.approval_strategy + + if strategy == ApprovalStrategy.ANY: + return len(processed) >= max(1, min_needed) + if strategy == ApprovalStrategy.MAJORITY: + return len(processed) >= max(min_needed, len(required) // 2 + 1) + return len(processed) >= len(required) + + def _advance(self, instance: WorkflowInstance, decision: str) -> None: + node = instance.current_node + if node is None: + return + + transition = ( + Transition.objects.filter(from_node=node, condition_slug=decision).first() + or Transition.objects.filter(from_node=node, is_default=True).first() + ) + if transition is None: + return + + next_node = transition.to_node + instance.current_node = next_node + + if next_node.node_type == WorkflowNode.NodeType.END: + instance.status = WorkflowInstance.Status.COMPLETED + instance.completed_at = timezone.now() + instance.save() + instance_completed.send( + sender=WorkflowInstance, + instance=instance, + workflow_slug=instance.workflow.slug, + final_node=next_node, + ) + return + + instance.save() + + if node.id != next_node.id: + self._clone_assignees(instance, node, next_node) + + node_reached.send(sender=WorkflowInstance, instance=instance, node=next_node, visit_number=1) + + def _clone_assignees(self, instance: WorkflowInstance, from_node: WorkflowNode, to_node: WorkflowNode) -> None: + assignees = NodeAssignment.objects.filter(instance=instance, node=from_node, is_required=True).select_related( + "user" + ) + last_visit = ( + NodeAssignment.objects.filter(instance=instance, node=to_node) + .order_by("-visit_number") + .values_list("visit_number", flat=True) + .first() + ) + visit_number = (last_visit or 0) + 1 + for assignment in assignees: + NodeAssignment.objects.get_or_create( + instance=instance, + node=to_node, + user=assignment.user, + visit_number=visit_number, + defaults={"is_required": True}, + ) diff --git a/infrasynth/workflows/filters.py b/infrasynth/workflows/filters.py new file mode 100644 index 0000000..7aefb40 --- /dev/null +++ b/infrasynth/workflows/filters.py @@ -0,0 +1,70 @@ +import django_filters + +from .models import ( + NodeAssignment, + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) + + +class WorkflowFilter(django_filters.FilterSet): + class Meta: + model = Workflow + fields = { + "slug": ["exact", "in"], + "is_active": ["exact"], + "name": ["icontains"], + } + + +class WorkflowNodeFilter(django_filters.FilterSet): + class Meta: + model = WorkflowNode + fields = { + "workflow": ["exact"], + "node_type": ["exact"], + "name": ["icontains"], + } + + +class TransitionFilter(django_filters.FilterSet): + class Meta: + model = Transition + fields = { + "from_node": ["exact"], + "to_node": ["exact"], + "is_default": ["exact"], + } + + +class WorkflowInstanceFilter(django_filters.FilterSet): + class Meta: + model = WorkflowInstance + fields = { + "workflow": ["exact"], + "status": ["exact"], + "owner": ["exact"], + } + + +class NodeAssignmentFilter(django_filters.FilterSet): + class Meta: + model = NodeAssignment + fields = { + "instance": ["exact"], + "node": ["exact"], + "user": ["exact"], + "has_processed": ["exact"], + } + + +class WorkflowObserverFilter(django_filters.FilterSet): + class Meta: + model = WorkflowObserver + fields = { + "instance": ["exact"], + "user": ["exact"], + } diff --git a/infrasynth/workflows/migrations/0001_initial.py b/infrasynth/workflows/migrations/0001_initial.py new file mode 100644 index 0000000..e9bd2ed --- /dev/null +++ b/infrasynth/workflows/migrations/0001_initial.py @@ -0,0 +1,221 @@ +# Generated by Django 5.2.16 on 2026-07-31 01:19 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + +import infrasynth.shared.enums + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Workflow", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("slug", models.SlugField(max_length=100, unique=True)), + ("name", models.CharField(max_length=200)), + ("description", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=True)), + ( + "created_by", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "workflows_definition", + }, + ), + migrations.CreateModel( + name="WorkflowNode", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ( + "node_type", + models.CharField( + choices=[("start", "Start"), ("intermediate", "Intermediate"), ("end", "End")], + default="intermediate", + max_length=20, + ), + ), + ("min_approvals", models.PositiveSmallIntegerField(default=1)), + ( + "approval_strategy", + models.CharField( + choices=infrasynth.shared.enums.ApprovalStrategy.choices, + default=infrasynth.shared.enums.ApprovalStrategy["ALL"], + max_length=20, + ), + ), + ("position_x", models.IntegerField(default=0)), + ("position_y", models.IntegerField(default=0)), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="nodes", + to="infrasynth_workflows.workflow", + ), + ), + ], + options={ + "db_table": "workflows_node", + "unique_together": {("workflow", "name")}, + }, + ), + migrations.CreateModel( + name="WorkflowInstance", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "status", + models.CharField( + choices=[ + ("in_progress", "In Progress"), + ("completed", "Completed"), + ("cancelled", "Cancelled"), + ], + default="in_progress", + max_length=20, + ), + ), + ("started_at", models.DateTimeField(auto_now_add=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("metadata", models.JSONField(default=dict)), + ( + "owner", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "workflow", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="instances", + to="infrasynth_workflows.workflow", + ), + ), + ( + "current_node", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_workflows.workflownode", + ), + ), + ], + options={ + "db_table": "workflows_instance", + }, + ), + migrations.CreateModel( + name="NodeAssignment", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("visit_number", models.IntegerField(default=1)), + ("is_required", models.BooleanField(default=True)), + ("has_processed", models.BooleanField(default=False)), + ("decision", models.CharField(blank=True, max_length=50, null=True)), + ("comments", models.TextField(blank=True)), + ("submitted_data", models.JSONField(blank=True, default=dict, null=True)), + ("processed_at", models.DateTimeField(blank=True, null=True)), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignments", + to="infrasynth_workflows.workflowinstance", + ), + ), + ( + "node", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="assignments", + to="infrasynth_workflows.workflownode", + ), + ), + ], + options={ + "db_table": "workflows_node_assignment", + }, + ), + migrations.CreateModel( + name="Transition", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("condition_slug", models.CharField(blank=True, max_length=100)), + ("is_default", models.BooleanField(default=False)), + ( + "from_node", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="outgoing_transitions", + to="infrasynth_workflows.workflownode", + ), + ), + ( + "to_node", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="incoming_transitions", + to="infrasynth_workflows.workflownode", + ), + ), + ], + options={ + "db_table": "workflows_transition", + "unique_together": {("from_node", "condition_slug")}, + }, + ), + migrations.CreateModel( + name="WorkflowObserver", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "instance", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="observers", + to="infrasynth_workflows.workflowinstance", + ), + ), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, related_name="+", to=settings.AUTH_USER_MODEL + ), + ), + ], + options={ + "db_table": "workflows_observer", + "unique_together": {("instance", "user")}, + }, + ), + ] diff --git a/infrasynth/workflows/migrations/__init__.py b/infrasynth/workflows/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f0da8715231b23c7ee948ac203aa3801339f92dc GIT binary patch literal 7270 zcmd5>O;Fp~6_)&me}EXCfe?a`Z3s948!&|YJzfBlKpsDiOqw=R(8#s`1zRGLOak3x z(dqgvvrJd7-DSx#OP03ljy-p#Q)b?(JEqBW+ny`gSO%M#zA3F|u&?etIzQj}&bhkc zPHT&ufX_c)Bwz1%3F0SwQT~`qgD<~^!FytZpvgr-A>r4sXjm{R#y&h}Tr@4174w2c zv8dPNVv}O+AUp(Z>LO_Kx;fHbR#^F+FluAPMw33mZ}}U(T=A30Dszg$3tO_^fb1!L zOJWsX6l7#ci3u(#$15rbqN{ZA#oB`XTL<50LNRRE6(db5Ca~PB7uUufV!bKiJU>4{ zYI6ZnvB90L^AZq|fi|u?BG+rKku*u0Xfth@GHL4}1KkADH)=_5BtY6aWjK|zf#l7a zl1H?Cs^lAJbby?@mK^A7*2%Si992iIRVQ~19c|y(=<${{F~#jk$>$b9c|r{XC6y&#;cSXQI6cQQSLk5uzok zqU~)^&JA)kIruaA_u#ybbRSL8o=WBdR^XbK_R*11GH7Ukas8KM+(5mI1CyQulWv{C zb`azro%dU<$01$a`Y(_p>D#)_at9=*YiV4ksbF`r@ICe=+jzeU%>$kdFg+Pv~#T;OSzN<~tl`~bzO@ds_J&q3jgP^=hlSW*&lM&06>`bfkJaqOMEPkFL<4Z|P}$horx| zQrkhj?SKPn;BiAWFmuTon1!h7aWO&9>H23FBx_eeHM{4p5T_^5EA;$$dWE(Zs%;0% zkfTM|LFoo}&}Wy-)Kzl-`!n4CKkEYlBVfG*SVya|4pa_mZcik0@69c~}lWKQDQB!9r6<~>e| zi899sY>GoBIho#44`u8-Cdmm~$~Lm31^!JM*06-1l<=_%ITD;4m-t}4l1h>hilM1qzd|+bxh`i%BOZeCY>e9(1vWWr%M?OvgPb4mhUDZ4> zC?c{ZVvNGZk{oiqEd?lp^d>Lxs$HXm51q)e$#cnsjG9E%9T_#3;aGv=B(e$c%_#f3 zV1gGEPD*hJ9t`=7s42cJ@^Ma<@L@*_tR=+@47bab{@= z*>^=*b%J@P8}B1)*->Q1X61ukUf7gad0$Yrnev((D3AO`2|s~LJ@^ZED%4tDk{PT3 zUDMEK6mg4FwmAtoUzVU)6l8@J;vBNTuacIL13U-6w*Gxqbvad<3ppgZ&#zi5a@kXn(N+`$&o1lgrE*t=I;$~=8wwxFuQZa8E?ecvKf=ozBXk3^$|YouVoLc9k`Gg* zc~M_W24v)f1TxMG2@t^V1k~EZw@diUd*E0Us7Q&Npwf0qL1ddNvr(PLlujlg;kLQ* z5OgeJjZfhV#ewn5Ctd1_a*#N0$rPVv9`dxh|bf@@@jZ~2{oTe@EjD} zoZ4KMA>m00zu7>|v;5W!wB0ATBOzN9x@l=Rx6MkW;Trc&(T2^lqL}1ZLA$a)5hV@^ z<9q1Pmc(mT*b0B-(KM@v<5H> zSeiMgFc~>#N{uaAYGzWu>UGuqO>;BRxtZ`hY6(NbTjNt)$?s>;)fH`MO7L;$>*6j~ zeskBsK!b$0cyTBzMdD*=g_9XZdJgKpz#oA&g4<$>3#LSg-D7uxK!w*_T#wap7}(vHn!t$qh2M0_KUxUlcSNzZ{r%Gu3t{`F zU>(d^2mfF#3_Z>cJ^qt*=EP`lS>7+5*wsaM&gw3<^&Gc#{IFN(x}EE~eP}xB&%9zX zf|T!)Gft)0*7c#Y(CyE4`#(iLyYk&nGR~(}llkuZ8RvsyoBN}sK#k_8(N9XAdX#Z~ zTWq`eVYA>K%elu6`}6MaGS0`xE~@CdQE(09Tmwb7x7h0`dWU}W|K4Blj%U5&1@FVG z_hG>s&U(YefuUlLuh0|9_JqDVEFGOEW?NhPNsH6fdeTp{^<-|2Lo6MwUk5vF_LG1v zoW4fG(L6tP^{GDf=Un}W+ALCmqHo}9lL=dF0*g+f-ShEQ!8@Dt&K`~By~`Qr%CT$k z^qwMhyFiU)sj&hzm8GT%)Iye8C{U3s6{)#&)bVF(^_&U!hrL4Ic&=~!a4g^VZN@oW zZ0kKcb?kCi(5r>pT-G~R92hS4Jka?+;s(SXlPYAxMW-jze=p;^e_}QQc5~zafVV{I z(DPk@-m&ZEY1n@^m^?RtMjjQeYgP=G1K_$&w5zd%a(yA-OP|J!nA)J}BxE@gRI zN6Dw;2q3(nAv~&)k|jd`?6w|Q&jr91+f;xia-NC9=Xnnuc zg^4(s^G+Ued2cx5oY#XG{!AYMkkO2Dt!g^os}-o{%4i%*lzjlGwGnb zbNTJ4Tif;niC94US6HCD_e-zwF6t<)!(-&8_K0FTy+eJP(H=v(D!Xbxdjbfk4*-%_ zwx#aOn(5zQ#nwmK!_&0%9xm~JqU1sNIx&(Y`Ncwzp1%;(Ux}fg2=`Y@)JR%>uos;D NS!e%02z;d~`WJ6%EIa@J literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..79e9dd90ef91ce27af3fb2fce0373f5b95517770 GIT binary patch literal 189 zcmdPq_I|p@<2{{|u766{Vk%pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl?|B(7hcUzDAelV4t} zpPQLplvt9PpI59OAD@|*SrQ+wS5SG2!zMRBr8Fniu80+A705BgAjT(VMn=XWW*`dy DrL{66 literal 0 HcmV?d00001 diff --git a/infrasynth/workflows/models.py b/infrasynth/workflows/models.py new file mode 100644 index 0000000..0ccc1fc --- /dev/null +++ b/infrasynth/workflows/models.py @@ -0,0 +1,141 @@ +from django.conf import settings +from django.db import models + +from infrasynth.shared.enums import ApprovalStrategy + + +class Workflow(models.Model): + slug = models.SlugField(max_length=100, unique=True) + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + + class Meta: + db_table = "workflows_definition" + + def __str__(self): + return self.name + + +class WorkflowNode(models.Model): + class NodeType(models.TextChoices): + START = "start", "Start" + INTERMEDIATE = "intermediate", "Intermediate" + END = "end", "End" + + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="nodes") + name = models.CharField(max_length=200) + node_type = models.CharField(max_length=20, choices=NodeType.choices, default=NodeType.INTERMEDIATE) + min_approvals = models.PositiveSmallIntegerField(default=1) + approval_strategy = models.CharField( + max_length=20, + choices=ApprovalStrategy.choices, + default=ApprovalStrategy.ALL, + ) + position_x = models.IntegerField(default=0) + position_y = models.IntegerField(default=0) + + class Meta: + db_table = "workflows_node" + unique_together = [("workflow", "name")] + + def __str__(self): + return f"{self.workflow.slug}:{self.name}" + + +class Transition(models.Model): + from_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="outgoing_transitions") + to_node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="incoming_transitions") + condition_slug = models.CharField(max_length=100, blank=True) + is_default = models.BooleanField(default=False) + + class Meta: + db_table = "workflows_transition" + unique_together = [("from_node", "condition_slug")] + + def __str__(self): + return f"{self.from_node} \u2192 {self.to_node}" + + +class WorkflowInstance(models.Model): + class Status(models.TextChoices): + IN_PROGRESS = "in_progress", "In Progress" + COMPLETED = "completed", "Completed" + CANCELLED = "cancelled", "Cancelled" + + workflow = models.ForeignKey(Workflow, on_delete=models.CASCADE, related_name="instances") + current_node = models.ForeignKey( + WorkflowNode, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + owner = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.IN_PROGRESS) + started_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "workflows_instance" + + def __str__(self): + return f"{self.workflow.slug}#{self.pk}" + + +class NodeAssignment(models.Model): + instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="assignments") + node = models.ForeignKey(WorkflowNode, on_delete=models.CASCADE, related_name="assignments") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+") + visit_number = models.IntegerField(default=1) + is_required = models.BooleanField(default=True) + has_processed = models.BooleanField(default=False) + decision = models.CharField(max_length=50, null=True, blank=True) + comments = models.TextField(blank=True) + submitted_data = models.JSONField(default=dict, null=True, blank=True) + processed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "workflows_node_assignment" + + def __str__(self): + return f"{self.instance}#{self.node.name}@{self.user}" + + +class WorkflowObserver(models.Model): + instance = models.ForeignKey(WorkflowInstance, on_delete=models.CASCADE, related_name="observers") + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="+") + + class Meta: + db_table = "workflows_observer" + unique_together = [("instance", "user")] + + def __str__(self): + return f"{self.instance}\u2192{self.user}" + + +class WorkflowAwareModel(models.Model): + workflow_instance = models.ForeignKey( + WorkflowInstance, + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="+", + ) + + class Meta: + abstract = True diff --git a/infrasynth/workflows/serializers.py b/infrasynth/workflows/serializers.py new file mode 100644 index 0000000..97ff8b7 --- /dev/null +++ b/infrasynth/workflows/serializers.py @@ -0,0 +1,165 @@ +from rest_framework import serializers + +from .models import ( + NodeAssignment, + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) + + +class WorkflowSerializer(serializers.ModelSerializer): + created_by_info = serializers.SerializerMethodField() + + class Meta: + model = Workflow + fields = [ + "id", + "slug", + "name", + "description", + "is_active", + "created_by", + "created_by_info", + ] + read_only_fields = ["id"] + + def get_created_by_info(self, obj): + if obj.created_by_id: + return {"id": obj.created_by_id, "display": str(obj.created_by)} + return None + + +class WorkflowNodeSerializer(serializers.ModelSerializer): + workflow_info = serializers.SerializerMethodField() + + class Meta: + model = WorkflowNode + fields = [ + "id", + "workflow", + "workflow_info", + "name", + "node_type", + "min_approvals", + "approval_strategy", + "position_x", + "position_y", + ] + read_only_fields = ["id"] + + def get_workflow_info(self, obj): + return {"id": obj.workflow_id, "slug": obj.workflow.slug, "name": obj.workflow.name} + + +class TransitionSerializer(serializers.ModelSerializer): + from_node_info = serializers.SerializerMethodField() + to_node_info = serializers.SerializerMethodField() + + class Meta: + model = Transition + fields = [ + "id", + "from_node", + "from_node_info", + "to_node", + "to_node_info", + "condition_slug", + "is_default", + ] + read_only_fields = ["id"] + + def get_from_node_info(self, obj): + return {"id": obj.from_node_id, "name": obj.from_node.name} + + def get_to_node_info(self, obj): + return {"id": obj.to_node_id, "name": obj.to_node.name} + + +class WorkflowInstanceSerializer(serializers.ModelSerializer): + workflow_info = serializers.SerializerMethodField() + current_node_info = serializers.SerializerMethodField() + owner_info = serializers.SerializerMethodField() + + class Meta: + model = WorkflowInstance + fields = [ + "id", + "workflow", + "workflow_info", + "current_node", + "current_node_info", + "owner", + "owner_info", + "status", + "started_at", + "completed_at", + "metadata", + ] + read_only_fields = ["id", "started_at", "completed_at"] + + def get_workflow_info(self, obj): + return {"id": obj.workflow_id, "slug": obj.workflow.slug, "name": obj.workflow.name} + + def get_current_node_info(self, obj): + if obj.current_node_id: + return {"id": obj.current_node_id, "name": obj.current_node.name} + return None + + def get_owner_info(self, obj): + if obj.owner_id: + return {"id": obj.owner_id, "display": str(obj.owner)} + return None + + +class NodeAssignmentSerializer(serializers.ModelSerializer): + instance_info = serializers.SerializerMethodField() + node_info = serializers.SerializerMethodField() + user_info = serializers.SerializerMethodField() + + class Meta: + model = NodeAssignment + fields = [ + "id", + "instance", + "instance_info", + "node", + "node_info", + "user", + "user_info", + "visit_number", + "is_required", + "has_processed", + "decision", + "comments", + "submitted_data", + "processed_at", + ] + read_only_fields = ["id", "processed_at"] + + def get_instance_info(self, obj): + return {"id": obj.instance_id} + + def get_node_info(self, obj): + return {"id": obj.node_id, "name": obj.node.name} + + def get_user_info(self, obj): + return {"id": obj.user_id, "display": str(obj.user)} + + +class WorkflowObserverSerializer(serializers.ModelSerializer): + instance_info = serializers.SerializerMethodField() + user_info = serializers.SerializerMethodField() + + class Meta: + model = WorkflowObserver + fields = ["id", "instance", "instance_info", "user", "user_info"] + read_only_fields = ["id"] + + def get_instance_info(self, obj): + return {"id": obj.instance_id} + + def get_user_info(self, obj): + return {"id": obj.user_id, "display": str(obj.user)} diff --git a/infrasynth/workflows/signals.py b/infrasynth/workflows/signals.py new file mode 100644 index 0000000..071cafd --- /dev/null +++ b/infrasynth/workflows/signals.py @@ -0,0 +1,7 @@ +from django.dispatch import Signal + +instance_started = Signal() +node_reached = Signal() +decision_submitted = Signal() +instance_completed = Signal() +instance_cancelled = Signal() diff --git a/infrasynth/workflows/urls.py b/infrasynth/workflows/urls.py new file mode 100644 index 0000000..22ad4b1 --- /dev/null +++ b/infrasynth/workflows/urls.py @@ -0,0 +1,23 @@ +from django.urls import include, path +from rest_framework.routers import DefaultRouter + +from .views import ( + NodeAssignmentViewSet, + TransitionViewSet, + WorkflowInstanceViewSet, + WorkflowNodeViewSet, + WorkflowObserverViewSet, + WorkflowViewSet, +) + +router = DefaultRouter() +router.register(r"definitions", WorkflowViewSet, basename="workflow-definitions") +router.register(r"nodes", WorkflowNodeViewSet, basename="workflow-nodes") +router.register(r"transitions", TransitionViewSet, basename="workflow-transitions") +router.register(r"instances", WorkflowInstanceViewSet, basename="workflow-instances") +router.register(r"assignments", NodeAssignmentViewSet, basename="workflow-assignments") +router.register(r"observers", WorkflowObserverViewSet, basename="workflow-observers") + +urlpatterns = [ + path("", include(router.urls)), +] diff --git a/infrasynth/workflows/validators.py b/infrasynth/workflows/validators.py new file mode 100644 index 0000000..2a3a658 --- /dev/null +++ b/infrasynth/workflows/validators.py @@ -0,0 +1,18 @@ +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class DataValidatorProtocol(Protocol): + def validate(self, node, data, context) -> dict: ... + + +class DataValidatorRegistry: + _validators: dict[str, DataValidatorProtocol] = {} + + @classmethod + def register(cls, workflow_slug, validator): + cls._validators[workflow_slug] = validator + + @classmethod + def get(cls, workflow_slug): + return cls._validators.get(workflow_slug) diff --git a/infrasynth/workflows/views.py b/infrasynth/workflows/views.py new file mode 100644 index 0000000..c7b4c2b --- /dev/null +++ b/infrasynth/workflows/views.py @@ -0,0 +1,259 @@ +from rest_framework import status, viewsets +from rest_framework.decorators import action +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .filters import ( + NodeAssignmentFilter, + TransitionFilter, + WorkflowFilter, + WorkflowInstanceFilter, + WorkflowNodeFilter, + WorkflowObserverFilter, +) +from .models import ( + NodeAssignment, + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) +from .serializers import ( + NodeAssignmentSerializer, + TransitionSerializer, + WorkflowInstanceSerializer, + WorkflowNodeSerializer, + WorkflowObserverSerializer, + WorkflowSerializer, +) + + +class WorkflowViewSet(viewsets.ModelViewSet): + queryset = Workflow.objects.all() + serializer_class = WorkflowSerializer + permission_classes = [IsAuthenticated] + filterset_class = WorkflowFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return Workflow.objects.select_related("created_by").all() + + +class WorkflowNodeViewSet(viewsets.ModelViewSet): + queryset = WorkflowNode.objects.all() + serializer_class = WorkflowNodeSerializer + permission_classes = [IsAuthenticated] + filterset_class = WorkflowNodeFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return WorkflowNode.objects.select_related("workflow").all() + + +class TransitionViewSet(viewsets.ModelViewSet): + queryset = Transition.objects.all() + serializer_class = TransitionSerializer + permission_classes = [IsAuthenticated] + filterset_class = TransitionFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return Transition.objects.select_related("from_node", "to_node").all() + + +class WorkflowInstanceViewSet(viewsets.ModelViewSet): + queryset = WorkflowInstance.objects.all() + serializer_class = WorkflowInstanceSerializer + permission_classes = [IsAuthenticated] + filterset_class = WorkflowInstanceFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return WorkflowInstance.objects.select_related("workflow", "current_node", "owner").all() + + def perform_create(self, serializer): + from .engine import WorkflowEngine + + workflow = serializer.validated_data["workflow"] + initial_data = serializer.validated_data.get("metadata") + instance = WorkflowEngine().start(workflow.slug, owner=self.request.user, initial_data=initial_data) + serializer.instance = instance + + @action(detail=True, methods=["get"]) + def route(self, request, pk=None): + instance = self.get_object() + from .engine import WorkflowEngine + + return Response({"route": WorkflowEngine().get_route(instance.pk)}) + + @action(detail=True, methods=["get"]) + def state(self, request, pk=None): + instance = self.get_object() + from .engine import WorkflowEngine + + return Response({"state": WorkflowEngine().get_node_states(instance.pk)}) + + @action(detail=True, methods=["post"]) + def submit(self, request, pk=None): + instance = self.get_object() + from .engine import WorkflowEngine + + assignment_id = request.data.get("assignment_id") + decision = request.data.get("decision") + if not assignment_id or not decision: + return Response( + {"detail": "assignment_id and decision are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + engine = WorkflowEngine() + instance = engine.submit_decision( + assignment_id, + decision, + data=request.data.get("data"), + user=request.user, + comments=request.data.get("comments", ""), + ) + return Response( + { + "instance_id": instance.pk, + "status": instance.status, + "current_node": ( + {"id": instance.current_node_id, "name": instance.current_node.name} + if instance.current_node_id + else None + ), + } + ) + + @action(detail=True, methods=["post"]) + def assign(self, request, pk=None): + instance = self.get_object() + from .engine import WorkflowEngine + + node_id = request.data.get("node_id") + user_ids = request.data.get("users", []) + if not node_id or not user_ids: + return Response( + {"detail": "node_id and users are required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + from django.contrib.auth import get_user_model + + user_model = get_user_model() + users = user_model.objects.filter(pk__in=user_ids) + assignments = WorkflowEngine().assign_users( + instance.pk, + node_id, + list(users), + is_required=request.data.get("is_required", True), + ) + from .serializers import NodeAssignmentSerializer + + return Response( + NodeAssignmentSerializer(assignments, many=True).data, + status=status.HTTP_201_CREATED, + ) + + @action(detail=True, methods=["post", "delete"]) + def observers(self, request, pk=None): + instance = self.get_object() + from .engine import WorkflowEngine + from .models import WorkflowObserver + + user_id = request.data.get("user_id") + if not user_id: + return Response( + {"detail": "user_id is required."}, + status=status.HTTP_400_BAD_REQUEST, + ) + engine = WorkflowEngine() + if request.method == "DELETE": + deleted, _ = WorkflowObserver.objects.filter(instance=instance, user_id=user_id).delete() + if not deleted: + return Response( + {"detail": "Observer not found."}, + status=status.HTTP_404_NOT_FOUND, + ) + return Response(status=status.HTTP_204_NO_CONTENT) + + from django.contrib.auth import get_user_model + + user_model = get_user_model() + user = user_model.objects.filter(pk=user_id).first() + if user is None: + return Response({"detail": "User not found."}, status=status.HTTP_404_NOT_FOUND) + observer = engine.add_observer(instance.pk, user) + from .serializers import WorkflowObserverSerializer + + return Response(WorkflowObserverSerializer(observer).data, status=status.HTTP_201_CREATED) + + +class NodeAssignmentViewSet(viewsets.ModelViewSet): + queryset = NodeAssignment.objects.all() + serializer_class = NodeAssignmentSerializer + permission_classes = [IsAuthenticated] + filterset_class = NodeAssignmentFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return NodeAssignment.objects.select_related("instance", "node", "user").all() + + +class WorkflowObserverViewSet(viewsets.ModelViewSet): + queryset = WorkflowObserver.objects.all() + serializer_class = WorkflowObserverSerializer + permission_classes = [IsAuthenticated] + filterset_class = WorkflowObserverFilter + + def initial(self, request, *args, **kwargs): + from infrasynth.features.services import FeatureService + + if not FeatureService().is_enabled("workflows", user=request.user): + from django.http import Http404 + + raise Http404() + super().initial(request, *args, **kwargs) + + def get_queryset(self): + return WorkflowObserver.objects.select_related("instance", "user").all() diff --git a/infrasynth_base.egg-info/PKG-INFO b/infrasynth_base.egg-info/PKG-INFO new file mode 100644 index 0000000..3fea035 --- /dev/null +++ b/infrasynth_base.egg-info/PKG-INFO @@ -0,0 +1,37 @@ +Metadata-Version: 2.4 +Name: infrasynth-base +Version: 1.0.0 +Summary: InfraSynth Base — reusable Django infrastructure kit +License: MIT +Requires-Python: >=3.12 +Requires-Dist: django<6.0,>=5.2 +Requires-Dist: djangorestframework<4.0,>=3.16 +Requires-Dist: django-cors-headers>=4.7 +Requires-Dist: djangorestframework-simplejwt>=5.5 +Requires-Dist: django-filter>=25.1 +Requires-Dist: psycopg2-binary>=2.9 +Requires-Dist: python-dotenv>=1.0 +Requires-Dist: cryptography>=44.0 +Requires-Dist: pydantic>=2.0 +Requires-Dist: pyotp>=2.10 +Requires-Dist: qrcode[pil]>=8.1 +Requires-Dist: celery[redis]>=5.4 +Requires-Dist: django-celery-results>=2.5 +Requires-Dist: django-celery-beat>=2.7 +Requires-Dist: boto3>=1.35 +Requires-Dist: django-storages>=1.14 +Requires-Dist: Pillow>=11.0 +Requires-Dist: twilio>=9.0 +Requires-Dist: stripe>=10.0 +Requires-Dist: mercadopago>=3.0 +Requires-Dist: requests>=2.32 +Requires-Dist: flower>=2.0 +Provides-Extra: dev +Requires-Dist: pytest>=8.0; extra == "dev" +Requires-Dist: pytest-django>=4.8; extra == "dev" +Requires-Dist: pytest-cov>=5.0; extra == "dev" +Requires-Dist: factory-boy>=3.3; extra == "dev" +Requires-Dist: faker>=28.0; extra == "dev" +Requires-Dist: ruff>=0.6; extra == "dev" +Requires-Dist: mypy>=1.11; extra == "dev" +Requires-Dist: pre-commit>=3.8; extra == "dev" diff --git a/infrasynth_base.egg-info/SOURCES.txt b/infrasynth_base.egg-info/SOURCES.txt new file mode 100644 index 0000000..d89d6f9 --- /dev/null +++ b/infrasynth_base.egg-info/SOURCES.txt @@ -0,0 +1,150 @@ +pyproject.toml +infrasynth/__init__.py +infrasynth/audit/__init__.py +infrasynth/audit/apps.py +infrasynth/audit/filters.py +infrasynth/audit/middleware.py +infrasynth/audit/mixins.py +infrasynth/audit/models.py +infrasynth/audit/receivers.py +infrasynth/audit/serializers.py +infrasynth/audit/signals.py +infrasynth/audit/urls.py +infrasynth/audit/views.py +infrasynth/audit/migrations/0001_initial.py +infrasynth/audit/migrations/__init__.py +infrasynth/billing/__init__.py +infrasynth/billing/apps.py +infrasynth/billing/filters.py +infrasynth/billing/invoice_generator.py +infrasynth/billing/models.py +infrasynth/billing/serializers.py +infrasynth/billing/services.py +infrasynth/billing/signals.py +infrasynth/billing/urls.py +infrasynth/billing/views.py +infrasynth/billing/gateways/__init__.py +infrasynth/billing/gateways/base.py +infrasynth/billing/gateways/mercadopago.py +infrasynth/billing/gateways/stripe.py +infrasynth/billing/gateways/wompi.py +infrasynth/billing/migrations/0001_initial.py +infrasynth/billing/migrations/__init__.py +infrasynth/features/__init__.py +infrasynth/features/apps.py +infrasynth/features/decorators.py +infrasynth/features/filters.py +infrasynth/features/models.py +infrasynth/features/registry.py +infrasynth/features/serializers.py +infrasynth/features/services.py +infrasynth/features/signals.py +infrasynth/features/urls.py +infrasynth/features/views.py +infrasynth/features/migrations/0001_initial.py +infrasynth/features/migrations/__init__.py +infrasynth/files/__init__.py +infrasynth/files/apps.py +infrasynth/files/filters.py +infrasynth/files/models.py +infrasynth/files/processing.py +infrasynth/files/serializers.py +infrasynth/files/services.py +infrasynth/files/signals.py +infrasynth/files/storage.py +infrasynth/files/urls.py +infrasynth/files/views.py +infrasynth/files/migrations/0001_initial.py +infrasynth/files/migrations/__init__.py +infrasynth/notifications/__init__.py +infrasynth/notifications/apps.py +infrasynth/notifications/filters.py +infrasynth/notifications/models.py +infrasynth/notifications/resolvers.py +infrasynth/notifications/serializers.py +infrasynth/notifications/services.py +infrasynth/notifications/signals.py +infrasynth/notifications/tasks.py +infrasynth/notifications/urls.py +infrasynth/notifications/views.py +infrasynth/notifications/channels/__init__.py +infrasynth/notifications/channels/base.py +infrasynth/notifications/channels/email_sendgrid.py +infrasynth/notifications/channels/email_smtp.py +infrasynth/notifications/channels/sms_twilio.py +infrasynth/notifications/channels/telegram.py +infrasynth/notifications/migrations/0001_initial.py +infrasynth/notifications/migrations/__init__.py +infrasynth/scheduler/__init__.py +infrasynth/scheduler/apps.py +infrasynth/scheduler/filters.py +infrasynth/scheduler/models.py +infrasynth/scheduler/serializers.py +infrasynth/scheduler/services.py +infrasynth/scheduler/signals.py +infrasynth/scheduler/urls.py +infrasynth/scheduler/views.py +infrasynth/scheduler/migrations/0001_initial.py +infrasynth/scheduler/migrations/__init__.py +infrasynth/security/__init__.py +infrasynth/security/apps.py +infrasynth/security/filters.py +infrasynth/security/models.py +infrasynth/security/permissions.py +infrasynth/security/serializers.py +infrasynth/security/services.py +infrasynth/security/signals.py +infrasynth/security/urls.py +infrasynth/security/views.py +infrasynth/security/altcha/__init__.py +infrasynth/security/altcha/services.py +infrasynth/security/auth/__init__.py +infrasynth/security/auth/api_keys.py +infrasynth/security/auth/backends.py +infrasynth/security/auth/cookies.py +infrasynth/security/auth/middleware.py +infrasynth/security/migrations/0001_initial.py +infrasynth/security/migrations/0002_add_role_users_m2m.py +infrasynth/security/migrations/__init__.py +infrasynth/security/two_factor/__init__.py +infrasynth/security/two_factor/middleware.py +infrasynth/security/two_factor/services.py +infrasynth/security/two_factor/utils.py +infrasynth/shared/__init__.py +infrasynth/shared/crypto.py +infrasynth/shared/enums.py +infrasynth/shared/protocols.py +infrasynth/shared/results.py +infrasynth/shared/settings_utils.py +infrasynth/webhooks/__init__.py +infrasynth/webhooks/apps.py +infrasynth/webhooks/dispatch.py +infrasynth/webhooks/filters.py +infrasynth/webhooks/models.py +infrasynth/webhooks/registry.py +infrasynth/webhooks/serializers.py +infrasynth/webhooks/signals.py +infrasynth/webhooks/signature.py +infrasynth/webhooks/urls.py +infrasynth/webhooks/views.py +infrasynth/webhooks/inbound/__init__.py +infrasynth/webhooks/inbound/handlers.py +infrasynth/webhooks/migrations/0001_initial.py +infrasynth/webhooks/migrations/__init__.py +infrasynth/workflows/__init__.py +infrasynth/workflows/apps.py +infrasynth/workflows/engine.py +infrasynth/workflows/filters.py +infrasynth/workflows/models.py +infrasynth/workflows/serializers.py +infrasynth/workflows/signals.py +infrasynth/workflows/urls.py +infrasynth/workflows/validators.py +infrasynth/workflows/views.py +infrasynth/workflows/migrations/0001_initial.py +infrasynth/workflows/migrations/__init__.py +infrasynth_base.egg-info/PKG-INFO +infrasynth_base.egg-info/SOURCES.txt +infrasynth_base.egg-info/dependency_links.txt +infrasynth_base.egg-info/requires.txt +infrasynth_base.egg-info/top_level.txt diff --git a/infrasynth_base.egg-info/dependency_links.txt b/infrasynth_base.egg-info/dependency_links.txt new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth_base.egg-info/requires.txt b/infrasynth_base.egg-info/requires.txt new file mode 100644 index 0000000..1e83d51 --- /dev/null +++ b/infrasynth_base.egg-info/requires.txt @@ -0,0 +1,32 @@ +django<6.0,>=5.2 +djangorestframework<4.0,>=3.16 +django-cors-headers>=4.7 +djangorestframework-simplejwt>=5.5 +django-filter>=25.1 +psycopg2-binary>=2.9 +python-dotenv>=1.0 +cryptography>=44.0 +pydantic>=2.0 +pyotp>=2.10 +qrcode[pil]>=8.1 +celery[redis]>=5.4 +django-celery-results>=2.5 +django-celery-beat>=2.7 +boto3>=1.35 +django-storages>=1.14 +Pillow>=11.0 +twilio>=9.0 +stripe>=10.0 +mercadopago>=3.0 +requests>=2.32 +flower>=2.0 + +[dev] +pytest>=8.0 +pytest-django>=4.8 +pytest-cov>=5.0 +factory-boy>=3.3 +faker>=28.0 +ruff>=0.6 +mypy>=1.11 +pre-commit>=3.8 diff --git a/infrasynth_base.egg-info/top_level.txt b/infrasynth_base.egg-info/top_level.txt new file mode 100644 index 0000000..45f452b --- /dev/null +++ b/infrasynth_base.egg-info/top_level.txt @@ -0,0 +1 @@ +infrasynth diff --git a/manage.py b/manage.py new file mode 100644 index 0000000..d98fd81 --- /dev/null +++ b/manage.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.dev") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..056d212 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,112 @@ +[project] +name = "infrasynth-base" +version = "1.0.0" +description = "InfraSynth Base — reusable Django infrastructure kit" +requires-python = ">=3.12" +license = { text = "MIT" } + +dependencies = [ + "django>=5.2,<6.0", + "djangorestframework>=3.16,<4.0", + "django-cors-headers>=4.7", + "djangorestframework-simplejwt>=5.5", + "django-filter>=25.1", + "psycopg2-binary>=2.9", + "python-dotenv>=1.0", + "cryptography>=44.0", + "pydantic>=2.0", + "pyotp>=2.10", + "qrcode[pil]>=8.1", + "celery[redis]>=5.4", + "django-celery-results>=2.5", + "django-celery-beat>=2.7", + "boto3>=1.35", + "django-storages>=1.14", + "Pillow>=11.0", + "twilio>=9.0", + "stripe>=10.0", + "mercadopago>=3.0", + "requests>=2.32", + "flower>=2.0", + "gunicorn>=23.0", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-django>=4.8", + "pytest-cov>=5.0", + "factory-boy>=3.3", + "faker>=28.0", + "ruff>=0.6", + "mypy>=1.11", + "pre-commit>=3.8", + "django-stubs[compatible-mypy]>=5.2", + "djangorestframework-stubs[compatible-mypy]>=3.16", + "types-requests", +] + +[tool.setuptools.packages.find] +include = ["infrasynth*"] + +[tool.setuptools.package-data] +infrasynth = ["**/*.py", "**/migrations/*.py"] + +[tool.ruff] +target-version = "py312" +line-length = 120 + +[tool.ruff.lint] +select = ["E", "F", "I", "N", "W", "UP"] + +[tool.ruff.lint.per-file-ignores] +"**/migrations/*.py" = ["E501"] +"config/settings/*.py" = ["F403", "F405"] + +[tool.ruff.format] +quote-style = "double" + +[tool.mypy] +python_version = "3.12" +plugins = ["mypy_django_plugin.main"] +strict_equality = true +no_implicit_optional = true +disallow_untyped_defs = false +disallow_any_generics = false +disallow_subclassing_any = false +disallow_untyped_calls = false +disallow_untyped_decorators = false +disallow_incomplete_defs = false +check_untyped_defs = false +no_implicit_reexport = true +warn_redundant_casts = true +warn_return_any = false +warn_unused_ignores = false +disable_error_code = ["var-annotated", "no-any-return", "import-untyped"] + +[[tool.mypy.overrides]] +module = [ + "mercadopago.*", + "mercadopago", + "twilio.*", + "twilio", + "stripe.*", + "stripe", + "cloudinary.*", + "cloudinary", + "storages.*", + "rest_framework_simplejwt.*", +] +ignore_missing_imports = true + +[[tool.mypy.overrides]] +module = "infrasynth.billing.gateways.stripe" +disable_error_code = ["attr-defined"] + +[tool.django-stubs] +django_settings_module = "config.settings.test" + +[tool.pytest.ini_options] +DJANGO_SETTINGS_MODULE = "config.settings.test" +testpaths = ["tests"] +python_files = ["test_*.py"] diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/__pycache__/__init__.cpython-314.pyc b/tests/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3725e761378097a20ff02536797cd83945730612 GIT binary patch literal 163 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<)ELDpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3l#P$i%*!l^ lkJl@xyv1RYo1apelWJGQ3N#R8Wig2HiJ6g+v4|PS0suN0CvN}% literal 0 HcmV?d00001 diff --git a/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..869868d8706c02730d72ba353ddffb32f7c9ace8 GIT binary patch literal 4234 zcmcgv-)|eo5#BowyrD1iA%wiO|TBzB2I}Dd6H<6 zJYx4)TS6WfH%JW|uFGjzkEw|GZg!@JJz~gfIOj^@O8+fshvYJ;@8J#1{Ckm<=?=1j$NvFyW5K zwhSZUYACDN3fBcz!`X-(;j*+E&8oJVAa5&kqr%X!(iYgjaH_H>(x1 zZkKvr#mPj3CjigUBy=ru-y7>kc!4|-?ocEWEFzZI86p;)<;<2{OICdYCB3oZM2tqISmGAZD6AQVhar_) zE zH+p*e5x#)y8Z08{i07S&Pyh8Lqy#m~>-2 z;fI&|jGqTc5+0U-4q`qs`O#agTOBEKD9yB`nNP3mOEU-3QYRW~t+|o@A8nG(f&mn* zfURH<_*Uo~NGJE@KB2?={>T zr$iD2L+D>tvVtW*HVT)aYEO}>(xXX$V$k87lk!&8Ji!@^D(6fcgzK=-F>CZbM|z9_ zfY%D-m?OUy2OcLLbG%6exDoVmLv|!Q8+OC@;Lu2-X^zCI&CT5~o+4?AiYQdWT7|KS zRcEdv1N22$krz$+5=fAUmr#+$?q%qBB3??i+=9|SQTS?e*bij@4ld&Yzku%FNP+QB zZg<@7+}44R0LoT`T%Zg!IDwhGVj2}BvyW4)Hf}- z&``M5pqAbQ3?QneLFsEC`|~)?)x{0CxHPF*bSK;bP#%FZ(0vV?_)5C?g%tka_Pe*= zTl>2d?zr;%Yu__Wl*j4J$y$z~12;%Ve+J=sH{8b)mN(ewfXdv@PN?w%$8&fBpK8xL z@i$|2w1c-i9PzHhc2{nqA6OF3_2g#NS~sf6Tkhub=4J)Rvy+6QpATaqUqY##q#%Mr zS+Zj55Pa>_lsl=QGwn;3o;#UNdu!4x*x^X!O38M@eQz`R252FP_@Kh-MuTlxHn083 zUY(u9_|*F|ir#u_F!L>V*j4B-MBm9I`13Ed_$PM{C(`YS^ixf5Wjj*by<{9p3vFrP zK>86zFS+!&H1j<}&p%!fS0o{I&QaEzl`5nLbJXR=PSJZiS8VK{A9T1Do79F%277u4 zg}`*ep2`XJbrG11@S;H-*%h6*$H=-(E9*L5k|<`1b78=UzGX1rEOQUdI6A%v|1)YP z{JGiIaczD<6Na zm^um&ajc6{SMl^gLGI~6>W=-OJ|K+u@E~N!Jv~U>Sx+wt6TS68 kf{Os!|9Ey!IuK^Q5rgi>XS*o*j137XKIvN#B<{2S0$9>h4FCWD literal 0 HcmV?d00001 diff --git a/tests/__pycache__/helpers.cpython-314.pyc b/tests/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..de99d21dee82b3740fd828e3c4eb4557ce72b08d GIT binary patch literal 3944 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 literal 0 HcmV?d00001 diff --git a/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1fe052063626d740af96f3948a7ca6fc622498f4 GIT binary patch literal 33747 zcmeHwdu$v>nqSYbXE-xGe81l>y+}zU^)mIMBuXMp%9cFWl83u%+LIwUqQ)YJ!=9mR zanCvK;Sf{VP3$C>M4JarmjF3Bhu{<-5B_lhy!q!ANd6c;hv}2B2@)W<9C9b0d~jd{ zx%|HBuIirA&?I*)oY%)@v-+#5ufBSH^;LEC_thLOEbvLVe)l_*@BXa@Ng5*$zho15 z{v(GZ%}eh{s$)n>I@r@Wm^bTek_P3ZtBGQqL+-)+WIluQhCGAbq?f^tAz#vmczH+} z^e6o+%r#UnSePtiuzRRzusB)F;QXPI!O~80;UaA8bfA3^pbknZIDDX|Orj%;3VImciC!D}#%M+6MO}_YLk( z?jJmmJix+=hYk)NN*-cx$x!=XN3vt^aPsirk>n8;RyuTaurt}o;Ig5v!R};tlk~Q~ zMLKCVQu5f_zQ8zdI_nv@ZNVH$o_srBEw8fDo_f2yMN%u&${w^dU22sr_7%0dN=o*q zHE>U>jy9>i_KyhZM!Q2V9Gi|MBkw1t!m;o~L_>%?H9dBhVkRQV&}<^2g{G#*Ba;Ym zjfKbVL;xsBEgVaP$CATWf1Z~AUTm(}S> zs#tm7Z1T>u7M%-I)hzVlH)fNg)3dShtC2~n=zU#;^vA~I(^1eW=QuZKM-yXOG;X#| z0jrm-Grpz zN60bjoXraf9%+(P>3wP5(PXE=N2oOZ4Ve5I)lH`>dQ8xNJnP*^9S=*#$>9BOrOge$u)=N=H zj0Us={Sn5wyap-iIF%Q5v^#FJyEHejb$2*9Jrx}@_!rzAi!eUbxEUZBm!LesI( z*!0v?G}$ieJ{wAYJQ5oR&+0Dl9XeUQJsTTKP_>$I>6g0hOix9+rlz&9KlBtlsc24Dbgfi$tyUac@I5K5 z{CF;1)wNvJwOZP}kiXfsf5ErrY5v2C`i1_@vg(H=fB*Et)&Jrz{@VkexunX5FBHkw z2wJ#Tybb?10dXz;dgJ8+$D_WYzB8_$bol`Pn+a)u=MFYX*Md)F40H{wDnc5yiv zmxFRZ@Ewnpq?_n*F5Q|W;CQV#4yg{ok*=UCz-A^MXFTKXcYZo^FPMLNP?BaA1b6)b zao32S;|2;jWIZ8YUk!RrIMl)6i4)F~#1yzxrVu899|( zm>g=6f-YVUUc&LJtaOze+j`p5U3aK?s%)!KZhSY-M|EwBo6m8(@{Z6zGq1Q;b*uS7 zZI7=l7Ppe)ILs`KkqnH&pAijea@kMh<+qS&AK?ZsNM|eqTG}MjajJh z&V1kO@&C-*b7H4G?njRg4gY&vkNa%($=#|7uZOCr{;!`qZc3a!ug%W5!%7G4@P8wF zTtScj!ALXj!9XjAtY=`q1#?I(Pz%+fPKR3DW6GF#L9ifjfX7?UK)VHV$Z8kNjU7C6 zr+Gszm8hjXrZj|Pat0s+v#+>YEt39TR<7 zV?DPtx)v-3Z@xDCr#9Yns^wolzXhE?AHP}Yz;ETdl1AT>aiivMD=P|l+o~1Y;udk- zE_a5kEm14gs$fZ$1TNi$1TN*2`O}iX@q9?)Li`uUi?-zLl0Dw$BR-IOPOxU$J>KR` zC7xIVd~uJ3eVa1deIBmsn2rH)AnZE(<@~}6*uXD}xde)k$ z@g=O8^7u^3*L6EvqX$JgteF~jwPtGCcKzVtWl~+=;P;Ym!n&!M7PVDvQ}=Z_)cv;E<-AC>(>MGB>Ou98+Rkaj%SJBj zmPW8lZBaY;3>7aQ!F#rGja=psmykgT@79N_q)88-#^M!5J>r!kS2=cIfJ4?3v*{5| z<;7k2c-1>U4ORuP`#os_e-3$kL{`rcDC1C z{zBb%w%07uq0Yy4wbOBY+m%W&FOzy=Tij}n+m)ZOD^jzI{f`<>gFkKiAC|PMw)#8Q z7|8WS!p{Bi+F)%UXz?G1;%*z=@9p5$upw)Yuw)lwq=ZxE&#W<02Y###SRbf4n@V_-^Uyc9YmQxial663xV@3}#qHa{ zqu<;wZnc4TMCS?MYxr^+*z3oPG5M0eoR0i8-g&uZtK2KLxJNyW`No5FTb+7_uiG%P z>vOH#X8yvqZfguSa`}!~2;bqbZaa&0+qqrMC_7uXiJEa)#B<)^y6uJZm|@QCZid-b z5?i}1FG<4h=8KV9wQYGDd0S?!qrR^1NVJZs!+$;gi{g!6&7HZ{QtD|NM>YkU0;8P2 zt>?_`o6-v_B1G?aMf58{_f08dMRa~w{JXO?cN1@G!M8hG5s7r**YmsM*Kh8QUzR>e zv)Zek3N~l0R9beiQfc9>%b&(dMY{u@8Mk=$;U$}_*o|h;E_Um?oEfWzmo`4) z)N()FqUA1Swj8wR_F4|Y!F&f|EjJS7(qiOY4&_>T1*Ukc^;G-t1@1E6o8)pU-r6at zx888X+k$PyQomyW`g8Gp!F|D2(j}a^&1Vm+x1gQ7gE{Wapc`BpBnmdZb*fih!W_4c z*MgUFhhM-%I?QobR9U_H63W*4bCyk{L)orojm!PE6;{mZSq@>pwLiGeFC7=!RO*0T zQA}+L?$4U@ns+hhHS-$rCvj%n-S2n}D-zPMw)VIKab70tiS>R3Pf2^h+i73-E3cOr z+SgyLr476W1FwDf6WA7zJ;7XYS2!N+3PaVt3-$zKcf0hw`2B8O)*^{Gfyc=?HCGr; z06W>d|4{sXPkVv_dv55?=r}>g$T^`MLKWtUh9je-L*0DcFk+Z1dd~x$LIXBvAeP9_hOwydtlB(y5OUcWO&Zg zf4sl>G9Xws04L{)IJMh`Da2eM(IX}E zPG&AKS4dRJ-^~2Fhbf@njojCL47wMdoQ;qz#dt)RDd3fo7E+sQhw*SSe4|~~NFN=131kgs5|W;W&Oj5tPO$s#d+3}5W5@(4 zy&gC+ueDK3%_z)oCL$qpNH`e@F&;K7X>^q_0Q!sAIT{OD%pNkiDa5N|u>SeU>9O!+ z;zH+^G(*TTF$CxTz+l1Cl=kdf@$7q2*0x#Iu~}OAUyB#&R}I>q1}k0#>b9miEFNo5_`XkK?e)j!q^cn(u={_5$@%_NUqt?LdgU4@;B;5a2O;7N{i9&M=X2 zO!sgWqQFb{brx(Z6GI}?!%=?+Bg8Y0+e(Ox4Ig&CcNCc~r5^h~}z8BXdh=GEQok(jHyPLet{ z(VS^f)_i+fYo06iU9hnLs`ZKEOJ{FyyJPMIb%kgntBI%=Z3ET!L36LcD%5R`xemra z)KDVSXi!9(jdubR;&*)?V5W0Mk>T7slqLsTZISz(aKbhw!61|zM2|3zHMplanqWTa zP~;HWT@IK~FmsJgM#fF*XqY3-G%MQ})Sd+@K0#S*QDURLtftDrQRrNwod8%&)_IeO zM2v=;Z(h05fAgmAgLcQ4Zd5Kd4I{v>>?CuJLaqk7XdTB~&sUU{mKT`TVVP)VZR<(o zc=RihGU0615{-rgJ7kC`ni|-pM4pKsgMEmGYp&W7b1;}R>>xYI7;&y9R}8aSoZDxM z60(t7ANHFoo4G?@g=M!<{AzmK;*s$*tUZm2kPL~&Si^%e+Rte8eO)6%D9ua5w-jS= zj3%Gt9hlNZ$1oFVze2HWaQiOB@3W~Wtm!2o%rKK7EfUwTtcZuTi0(&pXml20G#X1_ zw6i%#uMQ>dOwUfjat^4WiOK2F@MMTB-Mpbh_;!SX^};^3X2kNXU(+z3mW5D|sW1%i z-j7dWbx1auy8Lbet}}jDgN>r3=bNHicNN4328gqo(m{)YSF@0ba!Pr=L8zV{2HwTiQZ;lVWD2wAY622Pgf@p7$(@7=B zgPpGCBS>MnCqm3lvV=FBAw;JV6S@!6uW2mVe!y4>S5kdrV)0&fjZV8cuZF;&q=%IgyAvz?G?M{v>mP6?m3Q^2Mii}keSlR?TkIIi}Gn z${uf~G?06=F^a|#Jq~lz$ha{nCN$dqwkJA~AcLpd2gp`+yUSQA3ft5jt_QVGkilX6 zC*oL}(u&XDyy|bA?| zNOk}Tkn2Zh9-R5%xsQ|UN_|Q`_(=Yp{NK(eAb(jon3m0ZRX&(f>J9I@4HgI+lvMau zEFVSWiwHXaB(#pd_@na=&Zm{eWuQN$Pk0;vH^g23iJ!amSv#H`u! z0sQH>NRS0=D#g4Gmr@6MQt~CU4n3>NY510|8jf-)Et~f`;hYv;0Z^$;lok0B_iZqW zBE|p^NTs&0xsR|9^cXQ~wtN6DT@?wk09zd{Jc^~{3#`C>|WMR)~31vXJu zK*jWJ5J4jXEe6U0$p8@l7!7VE;wkzd*_YsLM*za_x#-yXmRkEZ?_T)And2 zRek)=^JSO&bDvb)oGv=JTy!v9)VW;Lxmi%N>F?Mqs)MvC?t-)_cFzw2OKD2`4lMf) zq?H^Pl-5Pb%#AB46@dc6?qa`Rmh4+p^M@lKaz2$FhQM>Q5;h zOL=R`c@}nMS-AoL?yAzyLJ;4d2Dyn0D!QRC&G}F z`&i2Jwv<tHBu^ zkFbJ(1MW!6=DjL+ux_aTweB!N4dmER%I0~e7+1r}x>`QrDSG||#FOxVt{Qwhg z-N#8R&C;@YugP@_r$0^#zW_+mnK&!JF?}0^E+Py7VKxFlw)yO+h@n!PdvNaK57re( z;FF8hzgPDOmWd}9v0MZn* z9YSl6yG&~h^Ik=Wf{BN`>o$0eVG1-mup*!4l#OZ~Su9J*aF&%LY1zD2v-4^ru>Tmm*8U zsb(`LkhM*_)GvA=|1meD3fjLkrUA?+DR1AGiKBS)_+N#m z`i6U4f1Fp{H{9m^<2n!delpyq^bMaZ_{ngm*YNi!ec$nTGtQ`?mv(yRe(uNX_ZBDA zI1$gTfcU<^LvXqay}M^!IJL+@XTWqi@oj*p~&uX{P`Sf7-;!y5h3xoVnE^4ZFC#2y}Y$nNF|m6dinphD8m& zLW@&y0y(WN)MlpD+mfLT>QTKT!p0&%nl?I{ zu0A`JB-zv9JFi#u*=m#~A-=4R{ZwFUbZ!3SrnW$NI$$qs)EeiUxG@rdAJAVW8@^^3k zgyysD4N$v_J&iUSdzzrN^Aaz>?#7=43(i0zb>=U)B$)WuJL1lL!6InX?;rl(Htww5 zwl%C%9~HIkCCIe`-WI$zrtbZA`yL`4-Imu+W+8Q`%CtHx8@i59Wr_A_IK^IKil{FOXB|#ak5WPlptR8jx$~wEDDyg zdGk#^lCjH*6RvkOZ@M6v4iN>LZ1SiLyOKACJ+>t`a_+N^?Bo3Qg4{9A(XrC!T%Es| z9`dIFCklRJWuvNWE91qfk80z#xZ?cXE;+;2uS{)HtAk}(GS|Ngnd|30!k^~sSVa4y z@lv%ERt)XB`xdn6VUge_txQsB5Qz*ua%f2Fd6SWtHUfl<80aNyWYnwOA!i&8G+CMM zG}2_Nac`mPOuvT`xqX{{PU0R6Wh_G>OHmsN{amRSIg&y%w3(qOi_9vy{;v_jRA-^u zt=%=sBGiLTty)AOQ8y(5Xp|6usXz_&Tc#ThePUadq;FqlXyTG$tM)!Q_sN+f=lkT4 zKC4E9?VauVbu$(ewSdKUZyd&sz8OJ zcgbZqnfe|n`T(A-hd~-*Hf;~nN9r5U>)|m5fI!j^v#`03BBqQ8()Hlk0A6wld^>;y zHiftIRQ&T+@y{>G@WWA{9%J69GNWsS7*SMwGi*gZ&wU$=oQN?11X6)5Z0@576rUpO z*#KU$@I^uZNMI{|FX`*?)+fE2rEK4}eW;WFNr(5e^?@L?7+HQtG$UyE4O0H2oy|y9>o@zrZY7bOOXUyCYTkouXvs+ z&0Mj6Y{2H8VeTB!dSL0mUY&iBJo`-)^*+RQFA{2K5IbIZV z{g4wl4~i+A^Wwe{yxbaBpqWF~)9QcrLpwX=QU4x~QX66{?D6Qm5%I}lr#w3R2ugm1 zT)l5Q&Ktg@772bDaoxufvVB#yDThzp7lDzxtwKXg3wVTNJ;05j$+; zDBWqWcI*MyAoL^=&o zi|u`!5cM8@Q$C`5MUb9vhOv(x?03KEoQ=uosOH3YguskNlQfKtlR4Z5Nxf|RUHnr0 zG|oK3Pl88BJLyOhO{N4TB1VgJj)ifQopA_=*9;#YHGYu1i2KdctJoPJI3>)W%W|Ye zr(TAy6Uh*MN_;8;F@2Yv>1Ld!Qe_?Gg8b=BI(9lzf}PvJPW8}j2g#TyiReTOXLlK= zh}GICm=X$S;o&$kAQT{TL!{Q{!V-sikQCQ*qCo84i;hJSOxo-Ayu_I{W?m(f7cG>H zqgU+I1gfA$pLsH zT9@`em1_RGRPa>ld@8j)m0F%k`<_bexc{fr_f$InGwIc*(&?vC&(EZ@%hK7WQtuZ| z+2Q<5g7fD-$`^KiT@ARU^PVm literal 0 HcmV?d00001 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..013e43e --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,99 @@ +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from rest_framework.test import APIClient + +UserModel = get_user_model() + + +@pytest.fixture +def api_client(db): + return APIClient() + + +@pytest.fixture +def user(db): + return UserModel.objects.create_user( + username="testuser", + email="test@example.com", + password="testpass123", + ) + + +@pytest.fixture +def admin_user(db): + return UserModel.objects.create_superuser( + username="admin", + email="admin@example.com", + password="adminpass123", + ) + + +@pytest.fixture +def authenticated_client(user, db): + client = APIClient() + client.force_authenticate(user=user) + return client + + +@pytest.fixture +def admin_client(admin_user, db): + client = APIClient() + client.force_authenticate(user=admin_user) + return client + + +@pytest.fixture +def user_factory(): + def create_user(**kwargs): + defaults = { + "username": "factory_user", + "email": "factory@example.com", + "password": make_password("factorypass123"), + } + defaults.update(kwargs) + return UserModel.objects.create(**defaults) + + return create_user + + +@pytest.fixture +def role_factory(): + from infrasynth.security.models import Role + + def create_role(**kwargs): + defaults = { + "name": "Test Role", + "slug": "test-role", + "permissions": [], + } + defaults.update(kwargs) + return Role.objects.create(**defaults) + + return create_role + + +@pytest.fixture +def media_root(tmp_path, settings): + settings.MEDIA_ROOT = str(tmp_path) + return tmp_path + + +@pytest.fixture(autouse=True) +def clear_feature_cache(): + from django.core.cache import cache + + cache.clear() + yield + cache.clear() + + +@pytest.fixture +def clean_feature_registry(): + """Clears the global FeatureRegistry for the test, then restores it.""" + from infrasynth.features.registry import FeatureRegistry + + snapshot = dict(FeatureRegistry._features) + FeatureRegistry._features.clear() + yield + FeatureRegistry._features = snapshot diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..75dd746 --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,54 @@ +from infrasynth.billing.gateways.base import ( + BasePaymentGateway, + CheckoutSessionResult, + WebhookResult, +) + + +class FakeGateway(BasePaymentGateway): + """Deterministic in-memory gateway for tests and demos.""" + + gateway_slug = "fake" + + def __init__(self, config: dict | None = None): + self.config = config or {} + self.create_should_fail = self.config.get("create_should_fail", False) + + def create_checkout_session(self, plan, user, **kwargs) -> CheckoutSessionResult: + if self.create_should_fail: + raise ValueError("Fake gateway failure") + return CheckoutSessionResult( + session_id="cs_fake_123", + checkout_url="https://checkout.example.com/cs_fake_123", + client_secret="secret_123", + ) + + def handle_webhook(self, payload, headers) -> WebhookResult: + if payload.get("event") == "ignored": + return WebhookResult(event_type="ignored", is_handled=False, data={}) + return WebhookResult( + event_type=payload.get("event", "checkout.session.completed"), + is_handled=True, + data={"id": payload.get("data", {}).get("id")}, + ) + + def cancel_subscription(self, subscription) -> bool: + return True + + def sync_subscription(self, subscription) -> dict: + return { + "status": "active", + "current_period_end": "2026-12-31T00:00:00Z", + "metadata": {"synced": True}, + } + + def get_invoice(self, invoice) -> dict: + return { + "external_id": "inv_fake_1", + "status": "paid", + "amount": 10.0, + "currency": "USD", + } + + def health_check(self) -> bool: + return True diff --git a/tests/test_audit/__init__.py b/tests/test_audit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_audit/__pycache__/__init__.cpython-314.pyc b/tests/test_audit/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..abaa1192c8d055502e24613c685c610f9f18b705 GIT binary patch literal 174 zcmdPqc;CSs?l`h=2h`DC095kkP;p#GuTe$>_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITiKQu- tCHnF4nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=7PY%c~eJ~1;gG8QodSpZ&GE8hSB literal 0 HcmV?d00001 diff --git a/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e01fa791b102711f5ebf9bfb8d5af6ebcb718c1 GIT binary patch literal 20085 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 sC%n^f>Up5Wap)NbVLT=mt0u9u~2LJ#7 literal 0 HcmV?d00001 diff --git a/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..212e6afe29c092101c4fe802f13738cdf04e58a4 GIT binary patch literal 25846 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 literal 0 HcmV?d00001 diff --git a/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1467bd353d5ab4a903c187de6589111d44e97871 GIT binary patch literal 22060 zcmeG^S#TRib~88!=kSuK%amkFG@yg`A+HWfHYt;)L~%zn+XZ$ke`9)e_zYI6>Mdg1bXzxr8(AIEW_MnE-?Z-$;d2y zJ0_fqj@`_JOLp&8V?444VjPp+2~Or{m~+xM;g|gr0XZ-cl!LpOH$5{u^vun-;A&?E zHLo15gX6Z~@O5xJ7M!Ln%w9(Dwle}Z=uk74n`xL2!u&>93k?fESkMS-rC}im3mai= z0^7+%`7bf6=_spsBsnfGN>QiMd}?NLGApM@JRzrZ*)zF0C44rQOl4la8qdz9&>uRV zN-UDJyfpq!Dk~?n^3YQG06O7+Z5F^y<{}M5Fk{8iLTB7YwBZn-R|JRP6kLM4+tI_u zSivKBM?5M5l$m?9EuFnW;?h!9zA8#uNxFqxCZ(`_iYGx*aXF>9vhn%UwBnjgN%A8% zNi7`ii+Ys6Jk~_a#4o2ZiZ6HhtyDr5(@DjnR#x#QXeA}Z4FNf#_#xpUQc`3T+Pj=dggN^{!aYuo*9zEuSh*+sSjU{OR1jA z@xKO2b+5E+S>fAPn%h6!cgyo>N1-XQ5^h;(-LV>Cg3&d`8}zI)Ubklrk~8-G zc82EsWc_yOUW~mI#NM;?mrHTJGCun#+!N2Id#*s=ERvMebAxtCKXi#_J|$nxB^6gb zE?-pw>Npe=&`+cp%Ay34izJRCQJj}Z3xq&N;0Plyy&2jGqzznJ>yvhHD_-~(ZLq!I zLp-S;05;gJa$sLEu&*3ASPUFo;oDY1&#knzVfwM9wi8!oX(0@;^|B(dBfM&KhoU`e=w?HP)oV261*Hwiut%C zUCWWA+J*yBx8hIXL=@$vyw+&`bY6@nlhD$V!a)PalW{q&w;q0Rz19oslS-u7k|^Ns zhJOjE8aCp#a$rv}u%{e&p%{2!rFAbIV;w6kJ3gH*G#|uf3^2jS8sov?=5c$}W`xAX z0|b~pYe(P}hfLoDLM$+{C7MuV*NLFR9;Fh@oKrdqwrcOl(ak5oC)^um-__fr? z9^a*Z2kH1@)dMDPMR{!NVfKXn9iu)wtJ2$+uQol@%PVjV>*dzJ*W)^583TE*;1m2Y zZx34$%3a)|H>D-#t$LXGh8E`Zob~rrRtOwr^RA;TNx_IjqEz+h@`eyRWRXbnzDw*v zI_9Bc@$dC^nOmxAmwF@747)h9GreR-eIyxF=q5Us5%1b{GadDKeTy(-UW zGF1=dKLjb6;><7gDlTA8d35$87$hh^p=^$?CsyFNrwlL5}{km8(6$ppEjTAhF;mEu2{UqWe5 zj1#3Qe1C=2yr{8xTI9(iqaNe zl&^x+kWPTOoD>t8GQ6;!$+D@4?G?p%sA6|#och{gFglwgEfW_TpR`|{`|3Z;}p%6L#dGOv`VKi3a zFMQ-$;aki6?jpatzzzLkxZF8Z>>L7T0UZ2LnbUsvA%x#;cn!x#vf=!K8`6Cb?64sa z^4Bv2Y~6?0o#v+u6mS3*l<%iYA1{6K?x!Qmd}o1++*+WZ$VbYY_A7Cb0#uKBmo4u5 zh;3k59ba$}GvgPR`Te)z%Y5XsZOeRrfje>MDg{OUM48imCGJFl?^nIc7WaL`Hn6M? zh*3p8TM29r$TjsPu$aS6;tGO5*l$QIR4&t`Bj{%10lCcJ#50DZWkjzhml-^8#*nnk zbRd^CQMs(SLN0@K&P78So3WKnbiQnn%gis?b5Ns}I=|NBGBX`6cdDK$xeO)7M);;& z<_2E4rDuNTe5CeF#ueApHmi5MV1$6P$nq zTaDmij|?LiL4cEwpvHt818_S`PQXiX
  • =G#SO{lL$^BcnQJF2*wbMBRGxV6$G%f zW5^_cI?St$aw7tQKpIxi8Us^$8}hHJ6j)VhUlj{GaQyCDzn%Zh{9WIu-NxgrV^-NDxeyAtVMFQTEUhLeJhK7WOoKrov|hQF_Wo)YU{D;p)wUSf7^Q0 zF4n7cNXe*4LX|CasZ)))QKB04kyoMMiXT_X`~Z zC4R7i)Q;ksVYJveTHrpsNp ztj<_PgwNJ$tg(ft=5u&mGKe~9&`5*iIHF1Id566$o_Ctl$WHrdi8ZDbltFt291AbU zTsTG-UehP<+&@!|R3nKe7;}Tf6S^>H(MUb6?5dJW^Gmj5BIeMUf?mQ6%HT%24a%y9 ztPlD|vrP7qn?YK8oE6$K57jq%YP4lfCyk-L(FxkJS*p(ot0X7{y{53&sr-XBLv`6- zMVU}PGjzkM!5L9CI3s9hbfmLU_Qt@5X;V5&bu~j#DH;M3QBJZdS9WR?XGlCd4wPw3 zoIW=zo;q{p+=cNmrA3Pz5Ko^IUYZ;m8=u}VE#fFGB2!RbRrEyqW7S~{QaWT+WDHw9 zM7?WOS>sqq8g244(L)g2YR4PCU;HLES5(b(y020e*mA8e5R-v0UbdhdgAlv z3Nz;mCua+9CJKC{#3wBTY?X2P{J#sfl!M*HU^jK_F9!Fox}E#{G!DQ%|5uE&$^Q^h zzd`Vyo-dhiyx;)xMnl&y^r44w`9Bzbh!Ov#qphOvBbj2Uz{9Zq4}8{eJ+%?8XA7UD z*%}eFFqXV%JOWx&EEfGb#h{5PA-oN?ZNI{aR9_~FieD7L0KAwpG|o66=yk<4P>zkwi*AO}F%B8sRmOee&+46^;@Md(sU+bjaZ0!>*mosbhL zQ0j@dStVo%gk;b)%t<;B2x_J%H~!8#968p8^;XcVe=QDYV7?DENE}6YeCZ~=l38^ z+v#XF#c4W3?6p%Sr`CJT;CD}MJNrErryE#){FOSIr?8zh#cFjkfrg6=({|=+h+AJb z6BIl)x|xkHNbGboxFFex?JQ(%!7AHX*cu16Grq3o%a9uD*vS9iwk}=nwLWUI*v?=z zbQm^;NROch8-f{c(SG3$3j$&DJMca%cDShj+8;cP~Pl75qDF=+M!isaE#E)7enN9PJZoWBsCsp786#1iN zPWzR(qttwZ-ersXJ|Y@eRtLnaqfOi9T2Gs1zhwO0%{M&Nv^7;|+BVHMFu7o3rtK2A z*8EZ>8Z=n0)~4U!lK2@h-_&JZX1my%ZFnjgG*_5yLJb%HrfBdqW}EPLZnkM5@4=8F z7Z7aNaMOX_Zvc2*asPMGZTYFwuD&ye< z!^VMzk!qX8V2K~9pi{rUHVd)<<)4P;BiM2h0vW*~f>~@3O^xAF&NAs{>-zSwii47IkcP=C@598%(ZE9h-4<4JGob zXHfIoY>^IaZjV+Q2M$MVjfe^7QrO@5bQ^=zOwC40HuzyZC9nTt&*K6BlQnDa@?&LA`;{Ps?^3+9}^;5A0_13NF~q=2k+;c}~Oe*%V1<=PIs$K<$$guYX|Z zCg>-*wu5evX6OcK*Z_ym?kBn42DV4RMr*jr2R2$8`M`A)UNuiE6yU&`z*JpF;j?0> zZbO2q+`#054NNr`QD}$%8(AssWch%_N*S?V}9Ey_Z$A+BlXtvvki6+%MT=0rua z{zXg9ww7z#>l1k6LCb+wqV8zrk~$bE%qyAEIj-fq(#~9z7gkL)erZRBXFK=ZAQC33tL8S0P0zrj9D7 z*8whEvkDk`&G`N-J9ED*W4`6?Nt~(gV0G z#JqAw;M(DGmYMbDxzDTD)8)3<4bw7kGrSd$)%g+U=?`L1m`v!+70A+AYvst!?H zG>=P|<+MKrY-lp|dcx`q=nENNr-$Kajira1HhWm@`e&_&1>_#h8uu6(Q9M9FQ*uhZ zh-;AiCDck~1w6WN3MW{#rW68IVFuuz-g@;s$PYJ}S>`m1w}ob*@}e0Quk5+b+=R;r zr+~}(m=3s#u#*{brdhcD%*cJ3T21j1y6#?u)o!5Mqn9sQ*2>KS*)H5g7Ae_w;wyTz?G(RGW+30;hUioZ3|6 zZyt9-??u`B8 zEefY!07OTZvRzcK4C+>=7F_VtO;Xq3?oGJ<#|XmPk7yV<37IG!HHi|2ZjQ_%(Ki5oNVUP)ias~XiCfj|OaNEE?A1h{8{uJvg3 zC=pUCfm~GKj;V*NPLN-N7YU&B34m26%d-E*Fn`SaB=oP$-v3}`i_Gk*hjDNpc6`wB zm4jnFYfi?|w2H3PtqsGy20pkRwryeIuo{4ey= 0 + + def test_logs_request_body(self, middleware, db): + request = RequestFactory().post( + "/api/auth/login/", + data=json.dumps({"username": "a@b.com"}), + content_type="application/json", + ) + response = JsonResponse({"detail": "ok"}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/auth/login/") + assert log.request_body == {"username": "a@b.com"} + + def test_logs_response_body(self, middleware, db): + request = RequestFactory().get("/api/audit/changes/") + response = Response({"count": 3, "results": []}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/audit/changes/") + assert log.response_body == {"count": 3, "results": []} + + def test_captures_ip_and_user_agent(self, middleware, db): + request = RequestFactory().get("/api/audit/changes/", HTTP_USER_AGENT="pytest-agent", REMOTE_ADDR="10.0.0.5") + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/audit/changes/") + assert log.ip_address == "10.0.0.5" + assert log.user_agent == "pytest-agent" + + +class TestSensitiveData: + def test_sensitive_request_body_not_stored(self, middleware, db): + request = RequestFactory().post( + "/api/auth/login/", + data=json.dumps({"username": "a@b.com", "password": "supersecret"}), + content_type="application/json", + ) + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/auth/login/") + assert log.request_body is None + + @override_settings(INFRASYNTH_AUDIT=audit_config(SENSITIVE_KEYS=["password", "my_secret"])) + def test_custom_sensitive_keys(self, db): + middleware = AuditAPIMiddleware(get_response=lambda request: None) + request = RequestFactory().post( + "/api/test/", + data=json.dumps({"my_secret": "abc", "other": 1}), + content_type="application/json", + ) + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/test/") + assert log.request_body is None + + +class TestConfiguration: + def test_admin_path_not_logged(self, middleware, db): + request = RequestFactory().get("/admin/auth/user/") + response = JsonResponse({}) + run_request(middleware, request, response) + + assert not APIInteractionLog.objects.filter(path="/admin/auth/user/").exists() + + @override_settings(INFRASYNTH_AUDIT=audit_config(ENABLE_API_LOGGING=False)) + def test_api_logging_disabled(self, db): + middleware = AuditAPIMiddleware(get_response=lambda request: None) + request = RequestFactory().get("/api/features/active/") + response = JsonResponse({}) + run_request(middleware, request, response) + + assert not APIInteractionLog.objects.exists() + + @override_settings(INFRASYNTH_AUDIT=audit_config(MAX_BODY_SIZE_BYTES=10)) + def test_large_body_truncated(self, db): + middleware = AuditAPIMiddleware(get_response=lambda request: None) + big_payload = {"data": "x" * 100} + request = RequestFactory().post( + "/api/test/", + data=json.dumps(big_payload), + content_type="application/json", + ) + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/test/") + assert log.request_body is None + + +class TestActorCapture: + def test_captures_authenticated_actor(self, middleware, db, user): + request = RequestFactory().get("/api/audit/changes/") + request.user = user + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/audit/changes/") + assert log.actor == user + + def test_anonymous_actor_null(self, middleware, db, user): + request = RequestFactory().get("/api/audit/changes/") + request.user = type("AnonUser", (), {"is_authenticated": False})() + response = JsonResponse({}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/audit/changes/") + assert log.actor is None diff --git a/tests/test_audit/test_receivers.py b/tests/test_audit/test_receivers.py new file mode 100644 index 0000000..3c810f8 --- /dev/null +++ b/tests/test_audit/test_receivers.py @@ -0,0 +1,129 @@ +import pytest +from django.conf import settings +from django.test import override_settings + +from infrasynth.audit.models import ModelChangeLog, SecurityEvent +from infrasynth.audit.signals import security_event_occurred +from infrasynth.security.models import Role + + +def audit_config(**overrides): + return {**settings.INFRASYNTH_AUDIT, **overrides} + + +@pytest.fixture +def role(db): + return Role.objects.create(name="Test Role", slug="test-role", permissions=[]) + + +class TestModelCreateTracking: + def test_create_logs_entry(self, role): + log = ModelChangeLog.objects.get(model_label="infrasynth_security.Role") + assert log.object_id == str(role.pk) + assert log.action == "create" + assert "name" in log.changes + assert log.changes["name"] == [None, "Test Role"] + + def test_create_changes_include_serialized_fields(self, role): + log = ModelChangeLog.objects.get(model_label="infrasynth_security.Role") + assert log.changes["slug"] == [None, "test-role"] + assert log.changes["permissions"] == [None, "[]"] + + def test_create_actor_none_without_actor_field(self, role): + log = ModelChangeLog.objects.get(model_label="infrasynth_security.Role") + assert log.actor is None + + +class TestModelUpdateTracking: + def test_update_logs_field_changes(self, role): + role._previous_state = Role.objects.get(pk=role.pk) + role.name = "Renamed Role" + role.save() + + logs = ModelChangeLog.objects.filter( + model_label="infrasynth_security.Role", + object_id=str(role.pk), + action="update", + ) + assert logs.count() == 1 + assert logs.first().changes["name"] == ["Test Role", "Renamed Role"] + + def test_no_changes_does_not_log(self, role): + role._previous_state = Role.objects.get(pk=role.pk) + role.save() + + assert not ModelChangeLog.objects.filter( + model_label="infrasynth_security.Role", + object_id=str(role.pk), + action="update", + ).exists() + + def test_no_previous_state_does_not_log(self, role): + role.name = "Changed Without Snapshot" + role.save() + + assert not ModelChangeLog.objects.filter( + model_label="infrasynth_security.Role", + object_id=str(role.pk), + action="update", + ).exists() + + +class TestModelDeleteTracking: + def test_delete_logs_entry(self, role): + pk = role.pk + role.delete() + + log = ModelChangeLog.objects.get( + model_label="infrasynth_security.Role", + object_id=str(pk), + action="delete", + ) + assert log.action == "delete" + + +class TestConfiguration: + @override_settings(INFRASYNTH_AUDIT=audit_config(EXCLUDED_MODELS=["infrasynth_security.Role"])) + def test_excluded_model_not_logged(self, db): + Role.objects.create(name="Excluded", slug="excluded") + assert not ModelChangeLog.objects.filter(model_label="infrasynth_security.Role").exists() + + @override_settings(INFRASYNTH_AUDIT=audit_config(EXCLUDED_FIELDS=["name", "slug"])) + def test_excluded_fields_not_in_changes(self, db): + Role.objects.create(name="Field", slug="field") + log = ModelChangeLog.objects.get(model_label="infrasynth_security.Role") + assert "name" not in log.changes + assert "slug" not in log.changes + assert "is_system" in log.changes + + @override_settings(INFRASYNTH_AUDIT=audit_config(ENABLE_MODEL_CHANGE_TRACKING=False)) + def test_tracking_disabled(self, db): + Role.objects.create(name="No Track", slug="no-track") + assert not ModelChangeLog.objects.filter(model_label="infrasynth_security.Role").exists() + + @override_settings(INFRASYNTH_AUDIT=audit_config(ENABLE_SECURITY_EVENTS=False)) + def test_security_events_disabled(self, db): + security_event_occurred.send( + sender=Role, + event_type="test", + actor=None, + ip_address=None, + metadata={}, + ) + assert not SecurityEvent.objects.exists() + + +class TestSecurityEvents: + def test_security_event_creates_log(self, db, user): + security_event_occurred.send( + sender=Role, + event_type="login_failed", + actor=user, + ip_address="127.0.0.1", + metadata={"reason": "bad_password"}, + ) + event = SecurityEvent.objects.get(event_type="login_failed") + assert event.actor == user + assert event.ip_address == "127.0.0.1" + assert event.metadata == {"reason": "bad_password"} + assert event.request_id diff --git a/tests/test_audit/test_views.py b/tests/test_audit/test_views.py new file mode 100644 index 0000000..0c72b34 --- /dev/null +++ b/tests/test_audit/test_views.py @@ -0,0 +1,130 @@ +import pytest +from rest_framework import status + +from infrasynth.audit.models import APIInteractionLog, ModelChangeLog, SecurityEvent + + +@pytest.fixture +def change_log(db, user): + return ModelChangeLog.objects.create( + model_label="infrasynth_security.Role", + object_id="1", + action="create", + changes={"name": [None, "Test"]}, + actor=user, + request_id="req-1", + ) + + +@pytest.fixture +def api_log(db, user): + return APIInteractionLog.objects.create( + method="GET", + path="/api/features/", + status_code=200, + actor=user, + duration_ms=12, + request_id="req-2", + ) + + +@pytest.fixture +def security_event(db, user): + return SecurityEvent.objects.create( + event_type="login_failed", + actor=user, + ip_address="127.0.0.1", + metadata={"reason": "bad_password"}, + request_id="req-3", + ) + + +class TestModelChangeLogEndpoints: + def test_list_changes(self, authenticated_client, change_log): + resp = authenticated_client.get("/api/audit/changes/", {"request_id": "req-1"}) + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + assert resp.json()["results"][0]["action"] == "create" + + def test_retrieve_change(self, authenticated_client, change_log): + resp = authenticated_client.get(f"/api/audit/changes/{change_log.pk}/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["changes"] == {"name": [None, "Test"]} + + def test_requires_auth(self, api_client, change_log): + resp = api_client.get("/api/audit/changes/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_list_does_not_allow_create(self, authenticated_client): + resp = authenticated_client.post("/api/audit/changes/", {"model_label": "x"}, format="json") + assert resp.status_code in ( + status.HTTP_405_METHOD_NOT_ALLOWED, + status.HTTP_403_FORBIDDEN, + ) + + +class TestAPIInteractionLogEndpoints: + def test_list_api_logs(self, authenticated_client, api_log): + resp = authenticated_client.get("/api/audit/api-logs/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + assert resp.json()["results"][0]["method"] == "GET" + + def test_retrieve_api_log(self, authenticated_client, api_log): + resp = authenticated_client.get(f"/api/audit/api-logs/{api_log.pk}/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["path"] == "/api/features/" + + def test_requires_auth(self, api_client, api_log): + resp = api_client.get("/api/audit/api-logs/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestSecurityEventEndpoints: + def test_list_security_events(self, authenticated_client, security_event): + resp = authenticated_client.get("/api/audit/security-events/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + assert resp.json()["results"][0]["event_type"] == "login_failed" + + def test_retrieve_security_event(self, authenticated_client, security_event): + resp = authenticated_client.get(f"/api/audit/security-events/{security_event.pk}/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["metadata"] == {"reason": "bad_password"} + + def test_requires_auth(self, api_client, security_event): + resp = api_client.get("/api/audit/security-events/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestFiltering: + def test_filter_by_model_label(self, authenticated_client, db): + ModelChangeLog.objects.create( + model_label="infrasynth_security.Role", + object_id="10", + action="create", + changes={}, + request_id="x1", + ) + ModelChangeLog.objects.create( + model_label="infrasynth_security.Grant", + object_id="99", + action="create", + changes={}, + request_id="x2", + ) + resp = authenticated_client.get("/api/audit/changes/", {"model_label": "infrasynth_security.Role"}) + assert resp.json()["count"] == 1 + + def test_filter_by_action(self, authenticated_client, change_log): + ModelChangeLog.objects.create( + model_label="infrasynth_security.Role", + object_id="77", + action="delete", + changes={}, + request_id="req-4", + ) + resp = authenticated_client.get("/api/audit/changes/", {"action": "create"}) + assert all(r["action"] == "create" for r in resp.json()["results"]) + resp = authenticated_client.get("/api/audit/changes/", {"action": "delete"}) + assert all(r["action"] == "delete" for r in resp.json()["results"]) diff --git a/tests/test_billing/__init__.py b/tests/test_billing/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_billing/__pycache__/__init__.cpython-314.pyc b/tests/test_billing/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7bae2399724f265010864420dc3da9537ac5a6f2 GIT binary patch literal 176 zcmdPq_I|p@<2{{|u76<*T2OpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITNtro0 vnR)5@@$s2?nI-Y@dIgoYIBatBQ%ZAE?TT1|ri1J+1~EP{Gcqz3F#}lu$mJ}q literal 0 HcmV?d00001 diff --git a/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1d44ed51b9c01835e5b3e95f8fa4ead4aaeb8cb9 GIT binary patch literal 52079 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 literal 0 HcmV?d00001 diff --git a/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..61423a620fcf123984e4a3ef34f689f22d9c3bcd GIT binary patch literal 28357 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ð8S+d{uTR4Nao0*P^dTi(*7MIM|0Z z>_u_{$pDhiBY6u6&Sqp-`hWxSsKaeI`4G63*We!k#LfU@ literal 0 HcmV?d00001 diff --git a/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dea3bd5a6c3668aeaee00578166535ed561cf5a9 GIT binary patch literal 10709 zcmeG?TWlNGl{0(}pCeH(%l2CG+e{@=v=vKU+lrkyvE)r$cI?vIMDbumj^v3clDadD zEfwy%Xn?qAfwt&Jx7Z*5)&0w-``5osfqsydD-whB0qpjpKZUwlAd7tMId|sHj3_#a z6Koq4dqJK#_ug~QeVw`Iapx<^M3jScFIj%)dw>-&!&1OTwl)E|&)wuCevUJE`t;5E z>%I|gHedutteTJ!f*Rjkcvdh3TIQdN%tnpqY|Mzw#*H{F3(O^ElSXocdpndrz&diD z`B4{Vn9&QgM_A5%O2Hv+(#{%v&!7o;X!-}ZgPar|;v`|xXEihiXjufxqE6YM#2@C; zu|FZn)iiIW>da(bSzT5u#%0A& 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 literal 0 HcmV?d00001 diff --git a/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..83a84b9ba1b7efc1f48bda5bc7b7c320b23e7075 GIT binary patch literal 43285 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

    6@0!0%K9^1pVVw@09;V#GahAu~ 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 Y=U#Wj_dE#5y|;0%ujj4;hgJCh18n$lvH$=8 literal 0 HcmV?d00001 diff --git a/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f9bd1f370d001f884cf646784513f47a6861aeef GIT binary patch literal 37675 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 YK_0GCe8bpJ^x;=F`8GX}Kv?Jee~WBWivR!s literal 0 HcmV?d00001 diff --git a/tests/test_billing/test_gateways.py b/tests/test_billing/test_gateways.py new file mode 100644 index 0000000..737059c --- /dev/null +++ b/tests/test_billing/test_gateways.py @@ -0,0 +1,302 @@ +from unittest import mock + +import pytest + +from infrasynth.billing.gateways.base import ( + BasePaymentGateway, + CheckoutSessionResult, + WebhookResult, +) +from infrasynth.billing.gateways.mercadopago import MercadoPagoGateway +from infrasynth.billing.gateways.stripe import StripeGateway +from infrasynth.billing.gateways.wompi import WompiGateway + +pytestmark = pytest.mark.django_db + + +class _FakePlan: + slug = "pro" + name = "Pro" + price_amount = 100 + price_currency = "USD" + external_id = "price_123" + + +class _FakeUser: + email = "user@example.com" + pk = 7 + + +class _FakeSubscription: + external_id = "sub_123" + + +class _FakeInvoice: + external_id = "inv_123" + + +class TestBasePaymentGateway: + def test_is_abstract(self): + with pytest.raises(TypeError): + BasePaymentGateway() + + def test_result_dataclasses(self): + result = CheckoutSessionResult("s1", "https://x", "cs") + assert result.session_id == "s1" + webhook = WebhookResult("evt", True, {}) + assert webhook.is_handled is True + + +class TestStripeGateway: + def test_missing_credentials_raise(self): + gateway = StripeGateway({}) + with pytest.raises(ValueError, match="API key"): + gateway.create_checkout_session(_FakePlan(), _FakeUser()) + + def test_create_checkout_session(self): + gateway = StripeGateway({"api_key": "sk_test"}) + with mock.patch("stripe.checkout.Session.create") as create: + create.return_value = mock.Mock(id="cs_1", url="https://stripe.com/checkout", client_secret="cs_sec") + result = gateway.create_checkout_session(_FakePlan(), _FakeUser()) + assert result.session_id == "cs_1" + assert result.checkout_url == "https://stripe.com/checkout" + create.assert_called_once() + kwargs = create.call_args.kwargs + assert kwargs["mode"] == "subscription" + assert kwargs["line_items"] == [{"price": "price_123", "quantity": 1}] + assert kwargs["customer_email"] == "user@example.com" + + def test_create_checkout_without_external_id_raises(self): + gateway = StripeGateway({"api_key": "sk_test"}) + plan = _FakePlan() + plan.external_id = "" + with pytest.raises(ValueError, match="external price ID"): + gateway.create_checkout_session(plan, _FakeUser()) + + def test_handle_webhook(self): + gateway = StripeGateway({"api_key": "sk_test", "webhook_secret": "whsec_1"}) + with mock.patch("stripe.Webhook.construct_event") as construct: + construct.return_value = { + "type": "checkout.session.completed", + "data": {"object": {"id": "cs_1"}}, + } + result = gateway.handle_webhook( + {"id": "cs_1"}, + {"Stripe-Signature": "t=1,v1=sig"}, + ) + assert result.event_type == "checkout.session.completed" + assert result.is_handled is True + assert result.data["id"] == "cs_1" + + def test_handle_webhook_without_secret_raises(self): + gateway = StripeGateway({"api_key": "sk_test"}) + with pytest.raises(ValueError, match="webhook secret"): + gateway.handle_webhook({}, {}) + + def test_cancel_subscription(self): + gateway = StripeGateway({"api_key": "sk_test"}) + with mock.patch("stripe.Subscription.cancel") as cancel: + assert gateway.cancel_subscription(_FakeSubscription()) is True + cancel.assert_called_once_with("sub_123") + + def test_cancel_without_external_id(self): + gateway = StripeGateway({"api_key": "sk_test"}) + subscription = _FakeSubscription() + subscription.external_id = "" + assert gateway.cancel_subscription(subscription) is False + + def test_sync_subscription_maps_status(self): + gateway = StripeGateway({"api_key": "sk_test"}) + with mock.patch("stripe.Subscription.retrieve") as retrieve: + retrieve.return_value = { + "status": "past_due", + "current_period_start": 1700000000, + "current_period_end": 1702592000, + "cancel_at_period_end": True, + "canceled_at": None, + "trial_end": None, + "metadata": {"x": "1"}, + } + data = gateway.sync_subscription(_FakeSubscription()) + assert data["status"] == "past_due" + assert data["cancel_at_period_end"] is True + assert data["current_period_end"] is not None + assert data["metadata"] == {"x": "1"} + + def test_get_invoice(self): + gateway = StripeGateway({"api_key": "sk_test"}) + with mock.patch("stripe.Invoice.retrieve") as retrieve: + retrieve.return_value = { + "id": "inv_1", + "status": "paid", + "amount_due": 12000, + "currency": "usd", + "paid_at": 1700000000, + "lines": {"data": [{"description": "Pro", "amount": 12000, "quantity": 1}]}, + } + data = gateway.get_invoice(_FakeInvoice()) + assert data["status"] == "paid" + assert data["amount"] == 120.0 + assert data["currency"] == "USD" + assert data["line_items"][0]["description"] == "Pro" + + def test_health_check(self): + assert StripeGateway({"api_key": "k"}).health_check() is True + assert StripeGateway({}).health_check() is False + + +class TestMercadoPagoGateway: + def test_missing_credentials_raise(self): + gateway = MercadoPagoGateway({}) + with pytest.raises(ValueError, match="access token"): + gateway.create_checkout_session(_FakePlan(), _FakeUser()) + + def test_create_checkout_session(self): + gateway = MercadoPagoGateway({"access_token": "APP_USR-token"}) + with mock.patch("mercadopago.SDK") as sdk_cls: + sdk_cls.return_value.preference().create.return_value = { + "status": 201, + "response": { + "id": "pref_1", + "init_point": "https://mercadopago.com/checkout", + }, + } + result = gateway.create_checkout_session(_FakePlan(), _FakeUser()) + assert result.session_id == "pref_1" + assert result.checkout_url == "https://mercadopago.com/checkout" + preference = sdk_cls.return_value.preference().create.call_args.args[0] + assert preference["items"][0]["title"] == "Pro" + assert preference["items"][0]["unit_price"] == 100 + + def test_create_checkout_error_status_raises(self): + gateway = MercadoPagoGateway({"access_token": "tok"}) + with mock.patch("mercadopago.SDK") as sdk_cls: + sdk_cls.return_value.preference().create.return_value = { + "status": 400, + "response": {"message": "bad"}, + } + with pytest.raises(ValueError, match="MercadoPago error"): + gateway.create_checkout_session(_FakePlan(), _FakeUser()) + + def test_handle_webhook(self): + gateway = MercadoPagoGateway({"access_token": "tok"}) + result = gateway.handle_webhook({"type": "payment", "data": {"id": "pay_1"}}, {}) + assert result.event_type == "payment" + assert result.is_handled is True + assert result.data == {"id": "pay_1"} + + def test_cancel_subscription(self): + gateway = MercadoPagoGateway({"access_token": "tok"}) + with mock.patch("mercadopago.SDK") as sdk_cls: + sdk_cls.return_value.preapproval().update.return_value = {"status": 200} + assert gateway.cancel_subscription(_FakeSubscription()) is True + sdk_cls.return_value.preapproval().update.assert_called_once_with("sub_123", {"status": "cancelled"}) + + def test_sync_subscription(self): + gateway = MercadoPagoGateway({"access_token": "tok"}) + with mock.patch("mercadopago.SDK") as sdk_cls: + sdk_cls.return_value.preapproval().get.return_value = { + "status": 200, + "response": {"status": "cancelled", "metadata": {"a": 1}}, + } + data = gateway.sync_subscription(_FakeSubscription()) + assert data["status"] == "cancelled" + assert data["metadata"] == {"a": 1} + + def test_health_check(self): + assert MercadoPagoGateway({"access_token": "t"}).health_check() is True + assert MercadoPagoGateway({}).health_check() is False + + +class TestWompiGateway: + def test_missing_credentials_raise(self): + gateway = WompiGateway({}) + with pytest.raises(ValueError, match="public key"): + gateway.create_checkout_session(_FakePlan(), _FakeUser()) + + def test_create_checkout_session(self): + gateway = WompiGateway({"public_key": "pub_test"}) + with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post: + post.return_value.status_code = 201 + post.return_value.json.return_value = {"data": {"id": "plink_1", "url": "https://checkout.wompi.co/link"}} + result = gateway.create_checkout_session(_FakePlan(), _FakeUser()) + assert result.session_id == "plink_1" + assert result.checkout_url == "https://checkout.wompi.co/link" + payload = post.call_args.kwargs["json"] + assert payload["amount_in_cents"] == 10000 + assert payload["currency"] == "usd" + + def test_create_checkout_http_error_raises(self): + gateway = WompiGateway({"public_key": "pub_test"}) + with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post: + post.return_value.status_code = 400 + post.return_value.text = "invalid" + with pytest.raises(ValueError, match="400"): + gateway.create_checkout_session(_FakePlan(), _FakeUser()) + + def test_handle_webhook_unverified_without_secret(self): + gateway = WompiGateway({}) + result = gateway.handle_webhook({"event": "transaction.updated", "data": {"id": "t_1"}}, {}) + assert result.event_type == "transaction.updated" + assert result.is_handled is True + assert result.data == {"id": "t_1"} + + def test_handle_webhook_signature_verification(self): + import hashlib + import hmac + import json + + payload = {"event": "transaction.updated", "data": {"id": "t_1"}} + gateway = WompiGateway({"webhook_secret": "whsec"}) + raw = json.dumps(payload, separators=(",", ":")) + signature = hmac.new(b"whsec", raw.encode(), hashlib.sha256).hexdigest() + result = gateway.handle_webhook(payload, {"x-signature": signature}) + assert result.is_handled is True + + result = gateway.handle_webhook(payload, {"x-signature": "tampered"}) + assert result.is_handled is False + + def test_cancel_subscription_voids_transaction(self): + gateway = WompiGateway({"secret_key": "sk_test"}) + with mock.patch("infrasynth.billing.gateways.wompi.requests.post") as post: + post.return_value.status_code = 200 + assert gateway.cancel_subscription(_FakeSubscription()) is True + post.assert_called_once_with( + "https://sandbox.wompi.co/v1/transactions/sub_123/void", + headers={"Authorization": "Bearer sk_test"}, + timeout=30, + ) + + def test_cancel_without_secret_returns_false(self): + gateway = WompiGateway({}) + assert gateway.cancel_subscription(_FakeSubscription()) is False + + def test_sync_subscription(self): + gateway = WompiGateway({"public_key": "pub"}) + with mock.patch("infrasynth.billing.gateways.wompi.requests.get") as get: + get.return_value.status_code = 200 + get.return_value.json.return_value = {"data": {"status": "APPROVED", "metadata": {"m": 1}}} + data = gateway.sync_subscription(_FakeSubscription()) + assert data["status"] == "active" + assert data["metadata"] == {"m": 1} + + def test_get_invoice(self): + gateway = WompiGateway({"public_key": "pub"}) + with mock.patch("infrasynth.billing.gateways.wompi.requests.get") as get: + get.return_value.status_code = 200 + get.return_value.json.return_value = { + "data": { + "id": "t_1", + "status": "VOIDED", + "amount_in_cents": 9900, + "currency": "cop", + } + } + data = gateway.get_invoice(_FakeInvoice()) + assert data["status"] == "VOIDED" + assert data["amount"] == 99.0 + assert data["currency"] == "COP" + + def test_health_check(self): + assert WompiGateway({"public_key": "p"}).health_check() is True + assert WompiGateway({}).health_check() is False diff --git a/tests/test_billing/test_integration.py b/tests/test_billing/test_integration.py new file mode 100644 index 0000000..14c46f7 --- /dev/null +++ b/tests/test_billing/test_integration.py @@ -0,0 +1,204 @@ +import uuid + +import pytest +from django.dispatch import receiver +from django.test import override_settings + +from infrasynth.audit.models import SecurityEvent +from infrasynth.billing.models import BillingPlan, PaymentGateway +from infrasynth.billing.services import BillingService +from infrasynth.billing.signals import ( + invoice_generated, + subscription_cancelled, + subscription_created, +) +from infrasynth.notifications.models import ChannelConfig, NotificationDispatch, NotificationTemplate +from infrasynth.notifications.services import NotificationService +from infrasynth.shared.enums import ChannelType +from infrasynth.webhooks.signals import outbound_delivery_succeeded + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def gateway(): + return PaymentGateway.objects.create( + slug="fake", + display_name="Fake", + gateway_class="tests.helpers.FakeGateway", + is_active=True, + ) + + +@pytest.fixture +def plan(gateway): + return BillingPlan.objects.create( + slug="pro", + name="Pro", + price_amount="49.00", + price_currency="USD", + interval="monthly", + gateway=gateway, + external_id="price_123", + ) + + +@pytest.fixture +def notification_template(): + return NotificationTemplate.objects.create( + slug="sub-created", + name="Subscription Created", + channel=ChannelType.EMAIL, + subject_template="Your subscription is active", + body_template="Hello, your {{ plan }} plan is now active.", + is_html=False, + ) + + +@pytest.fixture +def channel_config(): + return ChannelConfig.objects.create( + slug="email", + channel_type=ChannelType.EMAIL, + display_name="Email Channel", + config={}, + is_active=True, + ) + + +class TestSubscriptionCreatedSignalIntegration: + def test_subscription_created_signal_integration(self, user, plan, gateway): + sent = [] + receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 + subscription_created.connect(receiver_fn, weak=False) + + try: + BillingService().create_subscription(plan, user, gateway, external_id="sub_int_1") + + assert len(sent) == 1 + assert sent[0]["user"] == user + assert sent[0]["plan_slug"] == "pro" + assert sent[0]["gateway"] == "fake" + assert sent[0]["external_id"] == "sub_int_1" + assert sent[0]["signal"] is subscription_created + finally: + subscription_created.disconnect(receiver_fn) + + +class TestBillingSignalTriggersNotificationDispatch: + @override_settings( + INFRASYNTH_NOTIFICATIONS={ + "DISPATCH_BACKEND": "celery", + "CHANNELS": { + "email": { + "primary": "infrasynth.notifications.channels.email_smtp.SMTPChannel", + }, + }, + } + ) + def test_billing_signal_triggers_notification_dispatch( + self, user, plan, gateway, notification_template, channel_config + ): + dispatch_created = [] + + @receiver(subscription_created, weak=False) + def handle_sub_created(**kwargs): + svc = NotificationService() + d = svc.send( + recipient=kwargs["user"].email, + subject="Subscription Active", + body="Your subscription is active.", + channel=ChannelType.EMAIL, + template=notification_template, + context={"plan": kwargs["plan_slug"]}, + ) + dispatch_created.append(d) + + try: + BillingService().create_subscription(plan, user, gateway, external_id="sub_notif_1") + + assert len(dispatch_created) == 1 + dispatch = dispatch_created[0] + assert isinstance(dispatch, NotificationDispatch) + assert dispatch.recipient == user.email + assert dispatch.channel == ChannelType.EMAIL + assert dispatch.template == notification_template + assert dispatch.status == NotificationDispatch.Status.PENDING + finally: + subscription_created.disconnect(handle_sub_created) + + +class TestWebhookSignalTriggersAuditLog: + def test_webhook_signal_triggers_audit_log(self, user): + events_created = [] + + @receiver(outbound_delivery_succeeded, weak=False) + def create_security_event(**kwargs): + event = SecurityEvent.objects.create( + event_type="webhook_delivery_succeeded", + actor=user, + metadata={ + "delivery_id": kwargs["delivery_id"], + "event_name": kwargs.get("event_name", ""), + "status_code": kwargs.get("status_code", 0), + }, + request_id=str(uuid.uuid4()), + ) + events_created.append(event) + + try: + outbound_delivery_succeeded.send( + sender=None, + delivery_id=42, + event_name="subscription.created", + status_code=200, + ) + + assert len(events_created) == 1 + event = SecurityEvent.objects.get(pk=events_created[0].pk) + assert event.event_type == "webhook_delivery_succeeded" + assert event.actor == user + assert event.metadata["delivery_id"] == 42 + assert event.metadata["event_name"] == "subscription.created" + assert event.metadata["status_code"] == 200 + finally: + outbound_delivery_succeeded.disconnect(create_security_event) + + +class TestSubscriptionCancelledSignal: + def test_subscription_cancelled_signal(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_to_cancel") + + sent = [] + receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 + subscription_cancelled.connect(receiver_fn, weak=False) + + try: + BillingService().cancel_subscription(subscription) + + assert len(sent) == 1 + assert sent[0]["user"] == user + assert sent[0]["plan_slug"] == "pro" + assert sent[0]["reason"] == "user_requested" + assert sent[0]["signal"] is subscription_cancelled + finally: + subscription_cancelled.disconnect(receiver_fn) + + +class TestInvoiceGeneratedSignal: + def test_invoice_generated_signal(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_inv_1") + + sent = [] + receiver_fn = lambda **kw: sent.append(kw) # noqa: E731 + invoice_generated.connect(receiver_fn, weak=False) + + try: + invoice = BillingService().generate_invoice(subscription) + + assert len(sent) == 1 + assert sent[0]["invoice_id"] == invoice.id + assert sent[0]["amount"] == 49.0 + assert sent[0]["signal"] is invoice_generated + finally: + invoice_generated.disconnect(receiver_fn) diff --git a/tests/test_billing/test_invoice_generator.py b/tests/test_billing/test_invoice_generator.py new file mode 100644 index 0000000..abb0d03 --- /dev/null +++ b/tests/test_billing/test_invoice_generator.py @@ -0,0 +1,80 @@ +import pytest + +from infrasynth.billing.invoice_generator import _build_invoice_pdf +from infrasynth.billing.models import BillingPlan, Invoice, PaymentGateway +from infrasynth.billing.services import BillingService + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def gateway(): + return PaymentGateway.objects.create( + slug="fake", + display_name="Fake", + gateway_class="tests.helpers.FakeGateway", + is_active=True, + ) + + +@pytest.fixture +def plan(gateway): + return BillingPlan.objects.create( + slug="pro", + name="Pro", + price_amount="49.00", + price_currency="USD", + interval="monthly", + gateway=gateway, + ) + + +class TestBuildInvoicePdf: + def test_generates_pdf_bytes(self, user, gateway, plan): + subscription = BillingService().create_subscription(plan, user, gateway) + invoice = Invoice.objects.create( + subscription=subscription, + user=user, + gateway=gateway, + invoice_number="INV-2026-000001", + amount=49, + currency="USD", + status="open", + line_items=[{"description": "Pro", "amount": 49.0, "quantity": 1}], + ) + pdf_bytes = _build_invoice_pdf(invoice) + assert pdf_bytes.startswith(b"%PDF") + assert len(pdf_bytes) > 100 + + def test_pdf_includes_tax(self, user, gateway, plan): + subscription = BillingService().create_subscription(plan, user, gateway) + invoice = Invoice.objects.create( + subscription=subscription, + user=user, + gateway=gateway, + invoice_number="INV-2026-000002", + amount=100, + tax_amount=19, + tax_name="IVA", + currency="USD", + status="open", + line_items=[{"description": "Plan", "amount": 100.0, "quantity": 1}], + ) + pdf_bytes = _build_invoice_pdf(invoice) + assert pdf_bytes.startswith(b"%PDF") + + +class TestGenerateInvoicePdfTask: + def test_task_generates_and_attaches_pdf(self, user, gateway, plan, media_root): + subscription = BillingService().create_subscription(plan, user, gateway) + invoice = BillingService().generate_invoice(subscription) + invoice.refresh_from_db() + assert invoice.pdf_file_id is not None + assert invoice.pdf_file.original_filename.startswith("invoice_") + assert invoice.pdf_file.mime_type == "application/pdf" + assert invoice.pdf_file.size_bytes > 100 + + def test_task_missing_invoice_returns_none(self): + from infrasynth.billing.invoice_generator import generate_invoice_pdf + + assert generate_invoice_pdf.run(999999) is None diff --git a/tests/test_billing/test_models.py b/tests/test_billing/test_models.py new file mode 100644 index 0000000..dd77187 --- /dev/null +++ b/tests/test_billing/test_models.py @@ -0,0 +1,109 @@ +import pytest + +from infrasynth.billing.models import ( + BillingPlan, + Invoice, + PaymentGateway, + Subscription, +) +from infrasynth.shared.enums import BillingInterval, InvoiceStatus, SubscriptionStatus + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def gateway(): + return PaymentGateway.objects.create( + slug="stripe", + display_name="Stripe", + gateway_class="infrasynth.billing.gateways.stripe.StripeGateway", + supported_currencies=["USD"], + ) + + +class TestPaymentGateway: + def test_create(self): + gateway = PaymentGateway.objects.create( + slug="fake", + display_name="Fake", + gateway_class="tests.helpers.FakeGateway", + ) + assert gateway.is_active is True + assert str(gateway) == "Fake" + assert gateway.supported_currencies == [] + assert gateway.config == {} + + def test_pk_is_slug(self, gateway): + assert gateway.pk == "stripe" + + +class TestBillingPlan: + def test_create(self, gateway): + plan = BillingPlan.objects.create( + slug="pro", + name="Pro", + price_amount="49.00", + price_currency="USD", + interval=BillingInterval.MONTHLY, + gateway=gateway, + ) + assert plan.is_active is True + assert plan.trial_days == 0 + assert plan.features == [] + assert str(plan) == "Pro" + + def test_unique_slug(self, gateway): + BillingPlan.objects.create(slug="pro", name="A", price_amount=1, price_currency="USD", interval="monthly") + with pytest.raises(Exception): + BillingPlan.objects.create(slug="pro", name="B", price_amount=1, price_currency="USD", interval="monthly") + + +class TestSubscription: + def test_create(self, user, gateway): + subscription = Subscription.objects.create( + user=user, + gateway=gateway, + status=SubscriptionStatus.ACTIVE, + ) + assert subscription.cancel_at_period_end is False + assert subscription.metadata == {} + + def test_status_choices(self, user, gateway): + subscription = Subscription.objects.create( + user=user, + gateway=gateway, + status=SubscriptionStatus.TRIALING, + ) + assert subscription.status == "trialing" + + +class TestInvoice: + def test_create(self, user, gateway): + invoice = Invoice.objects.create( + user=user, + gateway=gateway, + invoice_number="INV-2026-000001", + amount="49.00", + currency="USD", + status=InvoiceStatus.DRAFT, + ) + assert invoice.tax_amount == 0 + assert invoice.tax_name == "" + assert invoice.line_items == [] + assert invoice.metadata == {} + assert invoice.pdf_file_id is None + assert str(invoice) == "INV-2026-000001" + + def test_no_default_status(self, user, gateway): + invoice = Invoice.objects.create( + user=user, + invoice_number="INV-2", + amount=10, + currency="USD", + ) + assert invoice.status == "" + + def test_unique_invoice_number(self, user, gateway): + Invoice.objects.create(user=user, invoice_number="INV-1", amount=1, currency="USD", status="open") + with pytest.raises(Exception): + Invoice.objects.create(user=user, invoice_number="INV-1", amount=1, currency="USD", status="open") diff --git a/tests/test_billing/test_services.py b/tests/test_billing/test_services.py new file mode 100644 index 0000000..e8bae87 --- /dev/null +++ b/tests/test_billing/test_services.py @@ -0,0 +1,191 @@ +import pytest + +from infrasynth.billing.models import ( + BillingPlan, + PaymentGateway, +) +from infrasynth.billing.services import BillingService +from infrasynth.shared.enums import InvoiceStatus, SubscriptionStatus + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def gateway(): + return PaymentGateway.objects.create( + slug="fake", + display_name="Fake", + gateway_class="tests.helpers.FakeGateway", + is_active=True, + ) + + +@pytest.fixture +def plan(gateway): + return BillingPlan.objects.create( + slug="pro", + name="Pro", + price_amount="49.00", + price_currency="USD", + interval="monthly", + gateway=gateway, + external_id="price_123", + ) + + +class TestCreateCheckoutSession: + def test_returns_session_and_gateway(self, user, plan): + result, gateway = BillingService().create_checkout_session( + "pro", user, success_url="https://x/success", cancel_url="https://x/cancel" + ) + assert result.session_id == "cs_fake_123" + assert result.checkout_url == "https://checkout.example.com/cs_fake_123" + assert result.client_secret == "secret_123" + assert gateway.slug == "fake" + + def test_inactive_plan_raises(self, user, plan): + plan.is_active = False + plan.save(update_fields=["is_active"]) + with pytest.raises(BillingPlan.DoesNotExist): + BillingService().create_checkout_session("pro", user) + + def test_no_gateway_raises(self, user, plan, gateway): + plan.gateway = None + plan.save(update_fields=["gateway"]) + gateway.is_active = False + gateway.save(update_fields=["is_active"]) + with pytest.raises(ValueError, match="No active payment gateway"): + BillingService().create_checkout_session("pro", user) + + def test_gateway_failure_propagates(self, user, plan): + plan.gateway.config = {"create_should_fail": True} + plan.gateway.save(update_fields=["config"]) + with pytest.raises(ValueError, match="Fake gateway failure"): + BillingService().create_checkout_session("pro", user) + + +class TestCreateSubscription: + def test_creates_active_subscription(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + assert subscription.user_id == user.id + assert subscription.plan_id == plan.id + assert subscription.gateway_id == gateway.slug + assert subscription.external_id == "sub_1" + assert subscription.status == SubscriptionStatus.ACTIVE + assert subscription.current_period_start is not None + assert subscription.current_period_end is not None + + def test_trial_subscription(self, user, plan, gateway): + plan.trial_days = 14 + plan.save(update_fields=["trial_days"]) + subscription = BillingService().create_subscription(plan, user, gateway) + assert subscription.status == SubscriptionStatus.TRIALING + assert subscription.trial_end is not None + + def test_emits_subscription_created_signal(self, user, plan, gateway): + from infrasynth.billing.signals import subscription_created + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + subscription_created.connect(receiver, weak=False) + BillingService().create_subscription(plan, user, gateway) + assert sent + assert sent[0]["user"] == user + assert sent[0]["plan_slug"] == "pro" + assert sent[0]["gateway"] == "fake" + subscription_created.disconnect(receiver) + + +class TestCancelSubscription: + def test_cancels_local_and_gateway(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + assert BillingService().cancel_subscription(subscription) is True + subscription.refresh_from_db() + assert subscription.status == SubscriptionStatus.CANCELLED + assert subscription.cancelled_at is not None + assert subscription.cancel_at_period_end is False + + def test_emits_cancelled_signal(self, user, plan, gateway): + from infrasynth.billing.signals import subscription_cancelled + + subscription = BillingService().create_subscription(plan, user, gateway) + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + subscription_cancelled.connect(receiver, weak=False) + BillingService().cancel_subscription(subscription) + assert sent + assert sent[0]["reason"] == "user_requested" + assert sent[0]["plan_slug"] == "pro" + subscription_cancelled.disconnect(receiver) + + +class TestSyncSubscription: + def test_syncs_fields_from_gateway(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + data = BillingService().sync_subscription(subscription) + assert data["status"] == "active" + subscription.refresh_from_db() + assert subscription.status == SubscriptionStatus.ACTIVE + assert subscription.metadata.get("synced") is True + + def test_without_gateway_returns_empty(self, user, plan): + subscription = BillingService().create_subscription(plan, user, None, external_id="") + assert BillingService().sync_subscription(subscription) == {} + + +class TestGenerateInvoice: + def test_creates_invoice_and_pdf(self, user, plan, gateway, media_root): + subscription = BillingService().create_subscription(plan, user, gateway, external_id="sub_1") + invoice = BillingService().generate_invoice(subscription) + invoice.refresh_from_db() + assert invoice.status == InvoiceStatus.OPEN + assert invoice.amount == 49 + assert invoice.currency == "USD" + assert invoice.invoice_number.startswith("INV-") + assert invoice.due_date is not None + assert invoice.subscription_id == subscription.id + assert invoice.user_id == user.id + + def test_invoice_numbers_are_sequential(self, user, plan, gateway): + service = BillingService() + subscription = service.create_subscription(plan, user, gateway) + first = service.generate_invoice(subscription) + second = service.generate_invoice(subscription) + assert first.invoice_number != second.invoice_number + assert first.invoice_number < second.invoice_number + + def test_tax_applied(self, user, plan, gateway, settings): + settings.INFRASYNTH_BILLING = { + **settings.INFRASYNTH_BILLING, + "TAX_PERCENTAGE": 19, + "TAX_NAME": "IVA", + } + subscription = BillingService().create_subscription(plan, user, gateway) + invoice = BillingService().generate_invoice(subscription) + from decimal import Decimal + + assert invoice.tax_amount == Decimal("9.31") + assert invoice.tax_name == "IVA" + + def test_custom_line_items(self, user, plan, gateway): + subscription = BillingService().create_subscription(plan, user, gateway) + invoice = BillingService().generate_invoice( + subscription, + amount=99, + line_items=[{"description": "Extra", "amount": 99.0, "quantity": 1}], + ) + assert invoice.amount == 99 + assert invoice.line_items[0]["description"] == "Extra" + + def test_emits_invoice_generated_signal(self, user, plan, gateway): + from infrasynth.billing.signals import invoice_generated + + subscription = BillingService().create_subscription(plan, user, gateway) + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + invoice_generated.connect(receiver, weak=False) + invoice = BillingService().generate_invoice(subscription) + assert sent + assert sent[0]["invoice_id"] == invoice.id + assert sent[0]["amount"] == 49.0 + invoice_generated.disconnect(receiver) diff --git a/tests/test_billing/test_views.py b/tests/test_billing/test_views.py new file mode 100644 index 0000000..2a8396e --- /dev/null +++ b/tests/test_billing/test_views.py @@ -0,0 +1,222 @@ +import pytest + +from infrasynth.billing.models import ( + BillingPlan, + Invoice, + PaymentGateway, + PaymentTransaction, + Subscription, +) +from infrasynth.features.models import FeatureFlag +from infrasynth.shared.enums import SubscriptionStatus + +pytestmark = pytest.mark.django_db + +GATEWAYS_URL = "/api/billing/gateways/" +PLANS_URL = "/api/billing/plans/" +SUBSCRIPTIONS_URL = "/api/billing/subscriptions/" +INVOICES_URL = "/api/billing/invoices/" +TRANSACTIONS_URL = "/api/billing/transactions/" +WEBHOOK_URL = "/api/billing/webhook/receive/" + + +@pytest.fixture +def billing_enabled(): + return FeatureFlag.objects.create(slug="billing", name="Billing", is_active=True) + + +@pytest.fixture +def gateway(billing_enabled): + return PaymentGateway.objects.create( + slug="fake", + display_name="Fake", + gateway_class="tests.helpers.FakeGateway", + is_active=True, + ) + + +@pytest.fixture +def plan(gateway): + return BillingPlan.objects.create( + slug="pro", + name="Pro", + price_amount="49.00", + price_currency="USD", + interval="monthly", + gateway=gateway, + external_id="price_123", + ) + + +@pytest.fixture +def subscription(user, plan, gateway): + return Subscription.objects.create( + user=user, + plan=plan, + gateway=gateway, + external_id="sub_1", + status=SubscriptionStatus.ACTIVE, + ) + + +class TestFeatureGate: + def test_disabled_feature_returns_404(self, authenticated_client): + assert authenticated_client.get(GATEWAYS_URL).status_code == 404 + assert authenticated_client.get(PLANS_URL).status_code == 404 + assert authenticated_client.get(SUBSCRIPTIONS_URL).status_code == 404 + + +class TestPaymentGatewayViewSet: + def test_list_gateways(self, authenticated_client, gateway): + response = authenticated_client.get(GATEWAYS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "fake" + + def test_create_gateway(self, authenticated_client, billing_enabled): + response = authenticated_client.post( + GATEWAYS_URL, + { + "slug": "stripe", + "display_name": "Stripe", + "gateway_class": "infrasynth.billing.gateways.stripe.StripeGateway", + }, + format="json", + ) + assert response.status_code == 201 + assert PaymentGateway.objects.count() == 1 + + def test_requires_auth(self, api_client, gateway): + assert api_client.get(GATEWAYS_URL).status_code == 401 + + +class TestBillingPlanViewSet: + def test_list_plans_public(self, api_client, plan): + response = api_client.get(PLANS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "pro" + + def test_retrieve_plan(self, api_client, plan): + response = api_client.get(f"{PLANS_URL}{plan.id}/") + assert response.status_code == 200 + assert response.data["price_amount"] == "49.00" + assert response.data["gateway_info"]["slug"] == "fake" + + def test_retrieve_inactive_plan_404(self, api_client, plan): + plan.is_active = False + plan.save(update_fields=["is_active"]) + assert api_client.get(f"{PLANS_URL}{plan.id}/").status_code == 404 + + +class TestSubscriptionViewSet: + def test_list_subscriptions(self, authenticated_client, subscription): + response = authenticated_client.get(SUBSCRIPTIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["status"] == "active" + + def test_requires_auth(self, api_client, subscription): + assert api_client.get(SUBSCRIPTIONS_URL).status_code == 401 + + def test_subscribe_action(self, authenticated_client, plan): + response = authenticated_client.post( + f"{SUBSCRIPTIONS_URL}subscribe/", + {"plan_slug": "pro"}, + format="json", + ) + assert response.status_code == 200 + assert response.data["checkout_url"] == "https://checkout.example.com/cs_fake_123" + assert response.data["session_id"] == "cs_fake_123" + assert response.data["gateway"] == "fake" + + def test_subscribe_invalid_plan(self, authenticated_client): + response = authenticated_client.post( + f"{SUBSCRIPTIONS_URL}subscribe/", + {"plan_slug": "nope"}, + format="json", + ) + assert response.status_code == 404 + + +class TestInvoiceViewSet: + def test_list_invoices(self, authenticated_client, subscription, user): + Invoice.objects.create( + subscription=subscription, + user=user, + invoice_number="INV-1", + amount=49, + currency="USD", + status="open", + ) + response = authenticated_client.get(INVOICES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["invoice_number"] == "INV-1" + + def test_requires_auth(self, api_client, subscription, user): + assert api_client.get(INVOICES_URL).status_code == 401 + + +class TestPaymentTransactionViewSet: + def test_list_transactions(self, authenticated_client, subscription, user): + PaymentTransaction.objects.create( + invoice=None, + gateway=subscription.gateway, + amount=49, + currency="USD", + status="approved", + ) + response = authenticated_client.get(TRANSACTIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["status"] == "approved" + + def test_requires_auth(self, api_client, billing_enabled): + assert api_client.get(TRANSACTIONS_URL).status_code == 401 + + +class TestWebhookReceiveView: + def test_receive_processed(self, api_client, gateway): + response = api_client.post( + WEBHOOK_URL, + { + "gateway_slug": "fake", + "event": "checkout.session.completed", + "data": {"id": "cs_1"}, + }, + format="json", + ) + assert response.status_code == 200 + assert response.data["status"] == "processed" + assert response.data["event_type"] == "checkout.session.completed" + + def test_receive_ignored_event(self, api_client, gateway): + response = api_client.post( + WEBHOOK_URL, + {"gateway_slug": "fake", "event": "ignored", "data": {}}, + format="json", + ) + assert response.status_code == 200 + assert response.data["status"] == "ignored" + + def test_receive_uses_first_active_gateway(self, api_client, gateway): + response = api_client.post( + WEBHOOK_URL, + {"event": "checkout.session.completed", "data": {}}, + format="json", + ) + assert response.status_code == 200 + assert response.data["event_type"] == "checkout.session.completed" + + def test_receive_no_gateway_404(self, api_client): + response = api_client.post(WEBHOOK_URL, {"event": "x", "data": {}}, format="json") + assert response.status_code == 404 + + def test_receive_is_public(self, api_client, gateway): + response = api_client.post( + WEBHOOK_URL, + {"gateway_slug": "fake", "event": "e", "data": {}}, + format="json", + ) + assert response.status_code == 200 diff --git a/tests/test_e2e.py b/tests/test_e2e.py new file mode 100644 index 0000000..636d11c --- /dev/null +++ b/tests/test_e2e.py @@ -0,0 +1,198 @@ +from contextlib import contextmanager +from unittest import mock + +import pytest +from django.contrib.auth import get_user_model +from django.core.cache import cache +from django.db import transaction +from django.dispatch import receiver +from django.test import override_settings +from rest_framework.test import APIClient + +from infrasynth.audit.models import ModelChangeLog, SecurityEvent +from infrasynth.audit.signals import security_event_occurred +from infrasynth.features.models import FeatureFlag, FeatureFlagOverride +from infrasynth.features.registry import FeatureRegistry +from infrasynth.features.services import FeatureService +from infrasynth.security.models import Grant, Revoke, Role +from infrasynth.security.services import AuthorizationService +from infrasynth.webhooks.models import ( + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) +from infrasynth.webhooks.registry import EventRegistry +from infrasynth.webhooks.signals import outbound_delivery_succeeded + +UserModel = get_user_model() +pytestmark = pytest.mark.django_db + + +@contextmanager +def _emit_and_run_commit_hooks(): + with transaction.atomic(): + yield + connection = transaction.get_connection() + pending = list(connection.run_on_commit) + connection.run_on_commit = [] + for entry in pending: + func = entry[1] + func() + + +@override_settings(CELERY_TASK_ALWAYS_EAGER=True) +def test_e2e_login_permission_feature_webhook_audit(user, clean_feature_registry): + authz = AuthorizationService() + fs = FeatureService() + + # a. Force-authenticate the user and verify the session check endpoint + client = APIClient() + client.force_authenticate(user=user) + resp = client.get("/api/auth/check/") + assert resp.status_code == 200 + assert resp.json()["id"] == user.pk + + # b. Create Role with specific permissions and assign to user + role = Role.objects.create( + name="Webhook Manager", + slug="webhook-manager", + permissions=["webhooks.manage_endpoints"], + ) + role.users.add(user) + + # c. Create Grant for an additional permission + Grant.objects.create(user=user, codename="audit.view_logs") + + # d. Verify permission resolution via AuthorizationService + assert authz.has_permission(user, "webhooks.manage_endpoints") is True + assert authz.has_permission(user, "audit.view_logs") is True + assert authz.has_permission(user, "unknown.perm") is False + + # e. Feature flag registration and DB gating + FeatureRegistry.register("e2e.beta", default=True, name="E2E Beta") + FeatureFlag.objects.create(slug="e2e.beta", is_active=True) + cache.clear() + assert fs.is_enabled("e2e.beta", user=user) is True + assert fs.is_enabled("unknown.feature", user=user) is False + + # f. Register event, create outbound endpoint + subscription + EventRegistry.register( + "e2e.test.event", + description="E2E test event", + example_payload={"test_key": "test_value"}, + ) + endpoint = OutboundEndpoint.objects.create( + name="E2E Target", + url="https://example.com/e2e-hook", + secret="e2e-secret", + ) + sub = OutboundSubscription.objects.create( + endpoint=endpoint, + event_name="e2e.test.event", + ) + + # Connect receiver bridge: outbound_delivery_succeeded -> SecurityEvent + @receiver(outbound_delivery_succeeded) + def bridge_create_audit_event(sender, **kwargs): + security_event_occurred.send( + sender=OutboundDelivery, + event_type="webhook_delivered", + actor=user, + metadata={ + "delivery_id": kwargs.get("delivery_id"), + "event_name": kwargs.get("event_name"), + "status_code": kwargs.get("status_code"), + }, + ) + + payload = {"test_key": "test_value"} + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as mock_post: + mock_post.return_value.status_code = 200 + mock_post.return_value.text = "ok" + with _emit_and_run_commit_hooks(): + EventRegistry.emit("e2e.test.event", payload) + + # g. Verify OutboundDelivery was created via Celery eager delivery + assert OutboundDelivery.objects.filter(subscription=sub).exists() + delivery = OutboundDelivery.objects.get(subscription=sub) + assert delivery.status == OutboundDelivery.Status.SUCCESS + assert delivery.payload is not None + assert delivery.response_status == 200 + + # h. Verify SecurityEvent was created by the signal bridge + assert SecurityEvent.objects.filter(event_type="webhook_delivered").exists() + se = SecurityEvent.objects.get(event_type="webhook_delivered") + assert se.actor == user + assert se.metadata["delivery_id"] == delivery.id + assert se.metadata["event_name"] == "e2e.test.event" + assert se.metadata["status_code"] == 200 + + # i. Verify ModelChangeLog entries exist for the created models + labels = set(ModelChangeLog.objects.values_list("model_label", flat=True)) + expected_labels = [ + "infrasynth_security.Role", + "infrasynth_security.Grant", + "infrasynth_webhooks.OutboundEndpoint", + "infrasynth_webhooks.OutboundSubscription", + "infrasynth_webhooks.OutboundDelivery", + ] + for label in expected_labels: + assert label in labels, f"Missing ModelChangeLog for {label}" + + outbound_delivery_succeeded.disconnect(bridge_create_audit_event) + + +def test_e2e_permission_chain_resolution(user, admin_user): + authz = AuthorizationService() + + # a. Create role + grant + revoke for the same user + role = Role.objects.create( + name="Tester", + slug="tester", + permissions=["perm.role_a", "perm.role_b"], + ) + role.users.add(user) + Grant.objects.create(user=user, codename="perm.grant_c") + Revoke.objects.create(user=user, codename="perm.role_a") + + # b. Verify Revoke > Grant > Role > Default precedence + assert authz.has_permission(user, "perm.role_a") is False + assert authz.has_permission(user, "perm.role_b") is True + assert authz.has_permission(user, "perm.grant_c") is True + assert authz.has_permission(user, "perm.default") is False + + # c. Verify superuser gets ["*"] wildcard + assert authz.has_permission(admin_user, "anything.at.all") is True + assert authz.get_effective_permissions(admin_user) == {"*"} + + # d. Verify get_effective_permissions returns union minus revokes + perms = authz.get_effective_permissions(user) + assert "perm.role_a" not in perms + assert "perm.role_b" in perms + assert "perm.grant_c" in perms + assert len(perms) == 2 + + +def test_e2e_feature_flag_with_user_override(user, clean_feature_registry): + fs = FeatureService() + + # a. Register feature in registry (default True) + FeatureRegistry.register("e2e.feature", default=True) + assert fs.is_enabled("e2e.feature") is True + + # b. Create DB FeatureFlag (is_active=False) — overrides registry default + flag = FeatureFlag.objects.create(slug="e2e.feature", is_active=False) + cache.clear() + assert fs.is_enabled("e2e.feature") is False + + # c. Create FeatureFlagOverride for the specific user (is_enabled=True) + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) + assert fs.is_enabled("e2e.feature", user=user) is True + + # d. Verify the override is user-specific + other_user = UserModel.objects.create_user( + username="other", + email="other@example.com", + password="pass123", + ) + assert fs.is_enabled("e2e.feature", user=other_user) is False diff --git a/tests/test_features/__init__.py b/tests/test_features/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_features/__pycache__/__init__.cpython-314.pyc b/tests/test_features/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8218e4ffc3487bae68a2a7a30e460fc09a87b8a7 GIT binary patch literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITX{m`N wrA4X5`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00HhTEC2ui literal 0 HcmV?d00001 diff --git a/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ccb9c7bc26ad719048d7ab609f87fafe08eca38f GIT binary patch literal 4296 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 literal 0 HcmV?d00001 diff --git a/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b644c43b957e0b6c88839311f74b4971e7905e9b GIT binary patch literal 24062 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 MlbCuU0HOE$-)F>rC;$Ke literal 0 HcmV?d00001 diff --git a/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2c480b5f7e6dccc5b55cdf87dcc0435ec1084ff5 GIT binary patch literal 13171 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 Nkw3=Y=Mb`f{|k(6uEqcW literal 0 HcmV?d00001 diff --git a/tests/test_features/test_decorators.py b/tests/test_features/test_decorators.py new file mode 100644 index 0000000..e91df58 --- /dev/null +++ b/tests/test_features/test_decorators.py @@ -0,0 +1,49 @@ +import pytest +from django.http import HttpRequest +from rest_framework.exceptions import NotFound + +from infrasynth.features.decorators import feature_required +from infrasynth.features.registry import FeatureRegistry + + +@pytest.fixture(autouse=True) +def clean_registry(clean_feature_registry): + from django.core.cache import cache + + cache.clear() + yield + + +class TestFeatureRequired: + def test_raises_not_found_when_disabled(self, db): + FeatureRegistry.register("secret.feature", default=False) + + @feature_required("secret.feature") + def my_view(request): + return "ok" + + request = HttpRequest() + request.user = None + with pytest.raises(NotFound): + my_view(request) + + def test_passes_through_when_enabled(self, db): + FeatureRegistry.register("enabled.feature", default=True) + + @feature_required("enabled.feature") + def my_view(request): + return "ok" + + request = HttpRequest() + request.user = None + assert my_view(request) == "ok" + + def test_works_with_unregistered_flag(self, db): + @feature_required("unregistered") + def my_view(request): + return "ok" + + request = HttpRequest() + request.user = None + with pytest.raises(NotFound): + my_view(request) diff --git a/tests/test_features/test_registry.py b/tests/test_features/test_registry.py new file mode 100644 index 0000000..b53deed --- /dev/null +++ b/tests/test_features/test_registry.py @@ -0,0 +1,39 @@ +import pytest + +from infrasynth.features.registry import FeatureDefinition, FeatureRegistry + + +class TestFeatureRegistry: + @pytest.fixture(autouse=True) + def clean_registry(self, clean_feature_registry): + yield + + def test_register_adds_feature(self): + FeatureRegistry.register("test.flag", name="Test", default=True) + all_flags = FeatureRegistry.get_all() + assert "test.flag" in all_flags + assert isinstance(all_flags["test.flag"], FeatureDefinition) + + def test_register_sets_default(self): + FeatureRegistry.register("flag.a", default=True) + FeatureRegistry.register("flag.b", default=False) + assert FeatureRegistry.get_all()["flag.a"].default is True + assert FeatureRegistry.get_all()["flag.b"].default is False + + def test_register_overwrites_existing(self): + FeatureRegistry.register("dup", default=True) + FeatureRegistry.register("dup", default=False) + assert FeatureRegistry.get_all()["dup"].default is False + + def test_get_all_returns_copy(self): + FeatureRegistry.register("f1") + result = FeatureRegistry.get_all() + result["new_key"] = FeatureDefinition(slug="new_key") + assert "new_key" not in FeatureRegistry.get_all() + + def test_register_with_category(self): + FeatureRegistry.register("cat.flag", category="billing") + assert FeatureRegistry.get_all()["cat.flag"].category == "billing" + + def test_empty_registry(self): + assert FeatureRegistry.get_all() == {} diff --git a/tests/test_features/test_services.py b/tests/test_features/test_services.py new file mode 100644 index 0000000..d40435d --- /dev/null +++ b/tests/test_features/test_services.py @@ -0,0 +1,118 @@ +import uuid + +import pytest +from django.contrib.auth import get_user_model +from django.core.cache import cache + +from infrasynth.features.models import FeatureFlag, FeatureFlagOverride +from infrasynth.features.registry import FeatureRegistry +from infrasynth.features.services import FeatureService + +UserModel = get_user_model() + + +@pytest.fixture(autouse=True) +def clean_registry(clean_feature_registry): + cache.clear() + cache.clear() + yield + + +@pytest.fixture +def user(db): + return UserModel.objects.create_user(username="testuser", password="pass123") + + +@pytest.fixture +def service(): + return FeatureService() + + +class TestIsEnabled: + def test_registry_default_when_no_db_flag(self, service, db): + FeatureRegistry.register("my.flag", default=True) + assert service.is_enabled("my.flag") is True + + def test_registry_default_false_when_no_db_flag(self, service, db): + FeatureRegistry.register("my.flag", default=False) + assert service.is_enabled("my.flag") is False + + def test_fallback_default_when_not_registered(self, service, db): + assert service.is_enabled("unknown.flag") is False + + def test_fallback_default_explicit(self, service, db): + assert service.is_enabled("unknown.flag", default=True) is True + + def test_db_flag_overrides_registry_default(self, service, db): + FeatureRegistry.register("my.flag", default=False) + FeatureFlag.objects.create(slug="my.flag", is_active=True) + assert service.is_enabled("my.flag") is True + + def test_db_flag_false_overrides_registry(self, service, db): + FeatureRegistry.register("my.flag", default=True) + FeatureFlag.objects.create(slug="my.flag", is_active=False) + assert service.is_enabled("my.flag") is False + + def test_user_override_enables(self, service, user, db): + FeatureRegistry.register("my.flag", default=False) + flag = FeatureFlag.objects.create(slug="my.flag", is_active=False) + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) + assert service.is_enabled("my.flag", user=user) is True + + def test_user_override_disables(self, service, user, db): + FeatureRegistry.register("my.flag", default=True) + flag = FeatureFlag.objects.create(slug="my.flag", is_active=True) + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=False) + assert service.is_enabled("my.flag", user=user) is False + + def test_user_override_wins_over_db(self, service, user, db): + FeatureRegistry.register("my.flag", default=False) + flag = FeatureFlag.objects.create(slug="my.flag", is_active=True) + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=False) + assert service.is_enabled("my.flag", user=user) is False + + def test_unauthenticated_user_ignores_override(self, service, db): + FeatureRegistry.register("my.flag", default=True) + anon = UserModel.objects.create(username="anon", password="x") + assert service.is_enabled("my.flag", user=anon) is True + + def test_tenant_id_scope(self, service, db): + FeatureRegistry.register("tenant.flag", default=False) + tid = uuid.UUID("00000000-0000-0000-0000-000000000001") + FeatureFlag.objects.create(slug="tenant.flag", is_active=True, tenant_id=tid) + assert service.is_enabled("tenant.flag", tenant_id=tid) is True + + def test_global_flag_used_when_no_tenant(self, service, db): + FeatureRegistry.register("g.flag", default=False) + FeatureFlag.objects.create(slug="g.flag", is_active=True, tenant_id=None) + tid = uuid.UUID("00000000-0000-0000-0000-000000000002") + assert service.is_enabled("g.flag", tenant_id=tid) is False + + def test_tenant_specific_flag(self, service, db): + FeatureRegistry.register("t.flag", default=False) + tid = uuid.UUID("00000000-0000-0000-0000-000000000003") + FeatureFlag.objects.create(slug="t.flag", is_active=True, tenant_id=tid) + assert service.is_enabled("t.flag", tenant_id=tid) is True + other_tid = uuid.UUID("00000000-0000-0000-0000-000000000004") + assert service.is_enabled("t.flag", tenant_id=other_tid) is False + + +class TestGetActiveFlags: + def test_returns_registry_flags_when_no_db(self, service, db): + FeatureRegistry.register("flag.a", default=True) + FeatureRegistry.register("flag.b", default=False) + FeatureRegistry.register("flag.c", default=True) + result = service.get_active_flags() + assert result == {"flag.a": True, "flag.b": False, "flag.c": True} + + def test_includes_db_only_flags(self, service, db): + FeatureFlag.objects.create(slug="db_only", is_active=True) + result = service.get_active_flags() + assert "db_only" in result + + def test_respects_user_overrides(self, service, user, db): + FeatureRegistry.register("my.flag", default=False) + flag = FeatureFlag.objects.create(slug="my.flag", is_active=False) + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) + result = service.get_active_flags(user=user) + assert result["my.flag"] is True diff --git a/tests/test_features/test_views.py b/tests/test_features/test_views.py new file mode 100644 index 0000000..8f0f358 --- /dev/null +++ b/tests/test_features/test_views.py @@ -0,0 +1,66 @@ +import pytest +from django.contrib.auth import get_user_model +from django.core.cache import cache +from rest_framework import status + +from infrasynth.features.models import FeatureFlag +from infrasynth.features.registry import FeatureRegistry + +UserModel = get_user_model() + + +@pytest.fixture(autouse=True) +def clean_registry(clean_feature_registry): + cache.clear() + cache.clear() + yield + cache.clear() + + +class TestActiveFlagsEndpoint: + def test_returns_registry_flags(self, authenticated_client, db): + FeatureRegistry.register("flag.x", default=True) + FeatureRegistry.register("flag.y", default=False) + resp = authenticated_client.get("/api/features/active/") + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert "flags" in data + assert data["flags"]["flag.x"] is True + assert data["flags"]["flag.y"] is False + + def test_requires_auth(self, api_client, db): + resp = api_client.get("/api/features/active/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_includes_db_flags(self, authenticated_client, db): + FeatureRegistry.register("mixed", default=False) + FeatureFlag.objects.create(slug="mixed", is_active=True) + resp = authenticated_client.get("/api/features/active/") + assert resp.json()["flags"]["mixed"] is True + + def test_includes_user_overrides(self, authenticated_client, user, db): + FeatureRegistry.register("override_me", default=False) + flag = FeatureFlag.objects.create(slug="override_me", is_active=False) + from infrasynth.features.models import FeatureFlagOverride + + FeatureFlagOverride.objects.create(flag=flag, user=user, is_enabled=True) + resp = authenticated_client.get("/api/features/active/") + assert resp.json()["flags"]["override_me"] is True + + +class TestCheckFlagEndpoint: + def test_check_enabled(self, authenticated_client, db): + FeatureRegistry.register("my.flag", default=True) + resp = authenticated_client.get("/api/features/check/my.flag/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"slug": "my.flag", "is_enabled": True} + + def test_check_disabled(self, authenticated_client, db): + FeatureRegistry.register("off.flag", default=False) + resp = authenticated_client.get("/api/features/check/off.flag/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"slug": "off.flag", "is_enabled": False} + + def test_check_requires_auth(self, api_client, db): + resp = api_client.get("/api/features/check/anything/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/test_files/__init__.py b/tests/test_files/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_files/__pycache__/__init__.cpython-314.pyc b/tests/test_files/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2f70cb2bfcf672bf36570925ee266564bfb83bc8 GIT binary patch literal 174 zcmdPq_I|p@<2{{|u76<*lEQpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITX_+~x t#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=7PY%c~eJ~1;gG8QodSpZ%>E8GA8 literal 0 HcmV?d00001 diff --git a/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..65c7d45149b84ebb2a643a4fb14346c072b1e624 GIT binary patch literal 20607 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 literal 0 HcmV?d00001 diff --git a/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d6af52cbdfd7f578b500939300f62741a8239e8b GIT binary patch literal 16144 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 literal 0 HcmV?d00001 diff --git a/tests/test_files/test_processing.py b/tests/test_files/test_processing.py new file mode 100644 index 0000000..8a52631 --- /dev/null +++ b/tests/test_files/test_processing.py @@ -0,0 +1,129 @@ +import io + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + +from infrasynth.files.models import PipelineExecution, ProcessingPipeline +from infrasynth.files.processing import PipelineExecutor, run_pipeline_execution +from infrasynth.files.services import FileService + +pytestmark = pytest.mark.django_db + + +def _make_image_png(width=200, height=100): + from PIL import Image + + buffer = io.BytesIO() + Image.new("RGB", (width, height), color="red").save(buffer, format="PNG") + return buffer.getvalue() + + +@pytest.fixture +def image_file(): + return SimpleUploadedFile("image.png", _make_image_png(), content_type="image/png") + + +@pytest.fixture +def stored_image(user, media_root, image_file): + return FileService().upload(image_file, filename="image.png", user=user) + + +@pytest.fixture +def sync_processing(settings): + settings.INFRASYNTH_FILES = { + **settings.INFRASYNTH_FILES, + "PROCESSING_BACKEND": "sync", + } + + +class TestPipelineExecutor: + def test_resize_step(self, stored_image, sync_processing): + pipeline = ProcessingPipeline.objects.create( + name="Resize", + slug="resize", + steps=[{"type": "resize", "params": {"width": 50}}], + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + PipelineExecutor().execute(execution) + execution.refresh_from_db() + assert execution.status == PipelineExecution.Status.COMPLETED + assert execution.output_file_id is not None + + from PIL import Image + + out = execution.output_file + backend = FileService()._resolve_file(out.id) + from infrasynth.files.storage import get_storage_backend + + fh = get_storage_backend("local").open(backend.storage_key, "rb") + img = Image.open(fh) + assert img.width <= 50 + assert img.height <= 50 + + def test_optimize_and_scan_steps(self, stored_image, sync_processing): + pipeline = ProcessingPipeline.objects.create( + name="Optimize", + slug="optimize", + steps=[{"type": "optimize", "params": {}}, {"type": "scan"}], + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + PipelineExecutor().execute(execution) + execution.refresh_from_db() + assert execution.status == PipelineExecution.Status.COMPLETED + assert execution.output_file_id is not None + + def test_watermark_step(self, stored_image, sync_processing): + pipeline = ProcessingPipeline.objects.create( + name="Watermark", + slug="watermark", + steps=[{"type": "watermark", "params": {"text": "CONF"}}], + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + PipelineExecutor().execute(execution) + execution.refresh_from_db() + assert execution.status == PipelineExecution.Status.COMPLETED + assert execution.output_file.mime_type == "image/png" + + def test_unknown_step_marks_failed(self, stored_image, sync_processing): + pipeline = ProcessingPipeline.objects.create( + name="Bad", + slug="bad", + steps=[{"type": "explode"}], + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + PipelineExecutor().execute(execution) + execution.refresh_from_db() + assert execution.status == PipelineExecution.Status.FAILED + assert "Unknown pipeline step" in execution.error + + def test_emits_file_processed_signal(self, stored_image, sync_processing): + from infrasynth.files.signals import file_processed + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + file_processed.connect(receiver, weak=False) + pipeline = ProcessingPipeline.objects.create( + name="Resize", slug="resize2", steps=[{"type": "resize", "params": {}}] + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + PipelineExecutor().execute(execution) + assert sent + assert sent[0]["file_id"] == stored_image.id + assert sent[0]["pipeline_name"] == "resize2" + assert sent[0]["status"] == "completed" + file_processed.disconnect(receiver) + + +class TestRunPipelineExecutionTask: + def test_task_completes_execution(self, stored_image): + pipeline = ProcessingPipeline.objects.create( + name="Resize", slug="resize_task", steps=[{"type": "resize", "params": {}}] + ) + execution = PipelineExecution.objects.create(file=stored_image, pipeline=pipeline) + result = run_pipeline_execution.delay(execution.id) + execution.refresh_from_db() + assert execution.status == PipelineExecution.Status.COMPLETED + assert result.id + + def test_task_missing_execution_returns_none(self): + assert run_pipeline_execution.run(999999) is None diff --git a/tests/test_files/test_services.py b/tests/test_files/test_services.py new file mode 100644 index 0000000..bbc1cfa --- /dev/null +++ b/tests/test_files/test_services.py @@ -0,0 +1,185 @@ +import hashlib +from unittest import mock + +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + +from infrasynth.files.models import FileCategory, StoredFile +from infrasynth.files.services import FileService +from infrasynth.files.signals import file_deleted, file_uploaded + +pytestmark = pytest.mark.django_db + + +def _make_pdf(name="doc.pdf", content=b"%PDF-1.4 test content"): + return SimpleUploadedFile(name, content, content_type="application/pdf") + + +@pytest.fixture +def upload_signals(): + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + file_uploaded.connect(receiver, weak=False) + yield sent + file_uploaded.disconnect(receiver) + + +class TestUpload: + def test_upload_creates_stored_file(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + assert StoredFile.objects.filter(pk=stored.id).exists() + assert stored.original_filename == "doc.pdf" + assert stored.mime_type == "application/pdf" + assert stored.size_bytes == len(b"%PDF-1.4 test content") + assert stored.storage_backend == "local" + assert stored.storage_key.startswith(stored.storage_key.split("/")[0] + "/") + assert stored.storage_key.endswith("doc.pdf") + assert stored.checksum_sha256 == hashlib.sha256(b"%PDF-1.4 test content").hexdigest() + + def test_upload_with_category_uses_storage_path(self, user, media_root): + category = FileCategory.objects.create(slug="contracts", name="Contracts", storage_path="contracts") + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, category_slug="contracts") + assert stored.category_id == category.slug + assert stored.storage_key.startswith("contracts/") + + def test_upload_rejects_invalid_extension(self, user, media_root): + FileCategory.objects.create( + slug="pdfs", + name="PDFs", + storage_path="pdfs", + allowed_extensions="pdf", + ) + with pytest.raises(ValueError, match="not allowed"): + FileService().upload(_make_pdf("image.png"), filename="image.png", category_slug="pdfs") + + def test_upload_rejects_oversized_file(self, user, media_root): + FileCategory.objects.create( + slug="small", + name="Small", + storage_path="small", + allowed_extensions="pdf", + max_size_bytes=10, + ) + with pytest.raises(ValueError, match="exceeds max size"): + FileService().upload(_make_pdf(), filename="doc.pdf", category_slug="small") + + def test_upload_inactive_category_rejected(self, user, media_root): + FileCategory.objects.create(slug="inactive", name="Inactive", storage_path="x", is_active=False) + with pytest.raises(ValueError, match="not found or inactive"): + FileService().upload(_make_pdf(), filename="doc.pdf", category_slug="inactive") + + def test_upload_emits_signal(self, user, media_root, upload_signals): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + assert upload_signals + assert upload_signals[0]["file_id"] == stored.id + assert upload_signals[0]["filename"] == "doc.pdf" + + def test_upload_file_is_stored_on_disk(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + path = media_root / stored.storage_key + assert path.exists() + assert path.read_bytes() == b"%PDF-1.4 test content" + + +class TestSignedUrl: + def test_generates_url(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + url = FileService().get_signed_url(stored.id) + assert "media" in url + assert stored.storage_key.split("/")[-1] in url + + +class TestDownload: + def test_local_file_returns_file_response(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + request = type("R", (), {})() + response = FileService().get_download_response(stored, request) + assert response.status_code == 200 + assert "attachment" in response["Content-Disposition"] + body = b"".join(response.streaming_content) + assert body == b"%PDF-1.4 test content" + + def test_non_local_redirects_to_signed_url(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + stored.storage_backend = "S3" + stored.save(update_fields=["storage_backend"]) + with mock.patch( + "infrasynth.files.services.get_storage_backend", + return_value=mock.Mock(generate_signed_url=lambda key, exp: "https://signed/url"), + ): + request = type("R", (), {})() + response = FileService().get_download_response(stored, request) + assert response.status_code == 302 + assert response.url == "https://signed/url" + + def test_x_sendfile_enabled(self, user, media_root, settings): + settings.INFRASYNTH_FILES = { + **settings.INFRASYNTH_FILES, + "ENABLE_X_SENDFILE": True, + } + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + request = type("R", (), {})() + response = FileService().get_download_response(stored, request) + assert response["X-Sendfile"] == stored.storage_key + + +class TestDelete: + def test_soft_delete_marks_metadata(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + assert FileService().delete(stored, soft=True) is True + stored.refresh_from_db() + assert stored.metadata.get("is_deleted") is True + assert StoredFile.objects.filter(pk=stored.pk).exists() + + def test_hard_delete_removes_row_and_file(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + path = media_root / stored.storage_key + assert path.exists() + assert FileService().delete(stored, soft=False) is True + assert not StoredFile.objects.filter(pk=stored.pk).exists() + assert not path.exists() + + def test_delete_emits_signal(self, user, media_root): + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + file_deleted.connect(receiver, weak=False) + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user) + FileService().delete(stored, soft=False) + assert sent + assert sent[0]["file_id"] == stored.id + file_deleted.disconnect(receiver) + + +class TestGetFileInfo: + def test_returns_full_metadata(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, is_public=True) + info = FileService().get_file_info(stored) + assert info["id"] == stored.id + assert info["original_filename"] == "doc.pdf" + assert info["is_public"] is True + assert info["uploaded_by"]["id"] == user.id + assert info["category"] is None + assert info["created_at"] + + def test_info_with_category(self, user, media_root): + FileCategory.objects.create(slug="docs", name="Docs", storage_path="docs") + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, category_slug="docs") + info = FileService().get_file_info(stored) + assert info["category"] == {"slug": "docs", "name": "Docs"} + + +class TestPipelineScheduling: + def test_schedules_pipeline_execution(self, user, media_root): + from infrasynth.files.models import PipelineExecution, ProcessingPipeline + + pipeline = ProcessingPipeline.objects.create(name="Noop", slug="noop", steps=[]) + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, pipeline_slug="noop") + execution = PipelineExecution.objects.get(file=stored) + assert execution.pipeline_id == pipeline.id + assert execution.status == PipelineExecution.Status.COMPLETED + + def test_unknown_pipeline_ignored(self, user, media_root): + stored = FileService().upload(_make_pdf(), filename="doc.pdf", user=user, pipeline_slug="does-not-exist") + from infrasynth.files.models import PipelineExecution + + assert not PipelineExecution.objects.filter(file=stored).exists() diff --git a/tests/test_files/test_storage.py b/tests/test_files/test_storage.py new file mode 100644 index 0000000..1e1f2a4 --- /dev/null +++ b/tests/test_files/test_storage.py @@ -0,0 +1,61 @@ +import io + +import pytest + +from infrasynth.files.storage import _LocalStorage, get_storage_backend + +pytestmark = pytest.mark.django_db + + +class TestLocalStorage: + def test_save_and_open_roundtrip(self, media_root): + storage = _LocalStorage({}) + name = storage.save("dir/file.txt", io.BytesIO(b"hello")) + assert name.endswith("file.txt") + assert storage.open(name, "rb").read() == b"hello" + + def test_exists(self, media_root): + storage = _LocalStorage({}) + name = storage.save("a.txt", io.BytesIO(b"data")) + assert storage.exists(name) + assert not storage.exists("nope.txt") + + def test_delete(self, media_root): + storage = _LocalStorage({}) + name = storage.save("b.txt", io.BytesIO(b"data")) + storage.delete(name) + assert not storage.exists(name) + + def test_url(self, media_root): + storage = _LocalStorage({}) + name = storage.save("c.txt", io.BytesIO(b"data")) + url = storage.url(name) + assert "media" in url + assert url.endswith("c.txt") + + def test_signed_url_equals_url_for_local(self, media_root): + storage = _LocalStorage({}) + name = storage.save("d.txt", io.BytesIO(b"data")) + assert storage.generate_signed_url(name, 60) == storage.url(name) + + +class TestGetStorageBackend: + def test_default_is_local(self): + storage = get_storage_backend() + assert isinstance(storage, _LocalStorage) + + def test_unknown_backend_falls_back_to_local(self): + storage = get_storage_backend("nonexistent") + assert isinstance(storage, _LocalStorage) + + def test_local_backend_instantiation(self): + assert isinstance(get_storage_backend("local"), _LocalStorage) + + +class TestSaveFile: + def test_save_file_returns_key(self, media_root): + from infrasynth.files.storage import save_file + + key = save_file(io.BytesIO(b"content"), "uploads/x.txt", backend="local") + assert key.endswith("x.txt") + assert (media_root / key).exists() diff --git a/tests/test_files/test_views.py b/tests/test_files/test_views.py new file mode 100644 index 0000000..b8d0f04 --- /dev/null +++ b/tests/test_files/test_views.py @@ -0,0 +1,101 @@ +import pytest +from django.core.files.uploadedfile import SimpleUploadedFile + +from infrasynth.features.models import FeatureFlag +from infrasynth.files.models import FileCategory, ProcessingPipeline, StoredFile +from infrasynth.files.services import FileService + +pytestmark = pytest.mark.django_db + +FILES_URL = "/api/files/files/" +CATEGORIES_URL = "/api/files/categories/" +PIPELINES_URL = "/api/files/pipelines/" + + +@pytest.fixture +def stored_file(user, media_root): + return FileService().upload( + SimpleUploadedFile("doc.pdf", b"%PDF-1.4", content_type="application/pdf"), + filename="doc.pdf", + user=user, + ) + + +class TestStoredFileViewSet: + def test_list_files(self, authenticated_client, stored_file): + response = authenticated_client.get(FILES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["original_filename"] == "doc.pdf" + + def test_list_requires_auth(self, api_client, stored_file): + assert api_client.get(FILES_URL).status_code == 401 + + def test_retrieve_file(self, authenticated_client, stored_file): + response = authenticated_client.get(f"{FILES_URL}{stored_file.id}/") + assert response.status_code == 200 + assert response.data["id"] == stored_file.id + assert response.data["mime_type"] == "application/pdf" + + def test_download_action(self, authenticated_client, stored_file): + response = authenticated_client.get(f"{FILES_URL}{stored_file.id}/download/") + assert response.status_code == 200 + assert "attachment" in response["Content-Disposition"] + assert b"".join(response.streaming_content) == b"%PDF-1.4" + + def test_download_requires_auth(self, api_client, stored_file): + assert api_client.get(f"{FILES_URL}{stored_file.id}/download/").status_code == 401 + + def test_delete_file(self, authenticated_client, stored_file): + response = authenticated_client.delete(f"{FILES_URL}{stored_file.id}/") + assert response.status_code == 204 + assert not StoredFile.objects.filter(pk=stored_file.pk).exists() + + def test_disabled_feature_returns_404(self, authenticated_client): + FeatureFlag.objects.create(slug="files", name="Files", is_active=False) + assert authenticated_client.get(FILES_URL).status_code == 404 + + +class TestFileCategoryViewSet: + def test_list_categories(self, authenticated_client): + FileCategory.objects.create(slug="docs", name="Docs", storage_path="docs") + response = authenticated_client.get(f"{CATEGORIES_URL}") + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "docs" + + def test_create_category(self, authenticated_client): + response = authenticated_client.post( + f"{CATEGORIES_URL}", + {"slug": "img", "name": "Images", "storage_path": "img"}, + format="json", + ) + assert response.status_code == 201 + assert FileCategory.objects.count() == 1 + + def test_requires_auth(self, api_client): + assert api_client.get(f"{CATEGORIES_URL}").status_code == 401 + + +class TestProcessingPipelineViewSet: + def test_list_pipelines(self, authenticated_client): + ProcessingPipeline.objects.create(name="Resize", slug="resize", steps=[]) + response = authenticated_client.get(f"{PIPELINES_URL}") + assert response.status_code == 200 + assert response.data["count"] == 1 + + def test_create_pipeline(self, authenticated_client): + response = authenticated_client.post( + f"{PIPELINES_URL}", + { + "name": "Optimize", + "slug": "optimize", + "steps": [{"type": "optimize"}], + }, + format="json", + ) + assert response.status_code == 201 + assert ProcessingPipeline.objects.filter(slug="optimize").exists() + + def test_requires_auth(self, api_client): + assert api_client.get(f"{PIPELINES_URL}").status_code == 401 diff --git a/tests/test_notifications/__init__.py b/tests/test_notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_notifications/__pycache__/__init__.cpython-314.pyc b/tests/test_notifications/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..07c71570b95894240cae460796a7e9156d26c271 GIT binary patch literal 182 zcmdPq_I|p@<2{{|u766|A3;pPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(ITdHE%o zX_?81C7Jno#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb=tcTu}^Sd}3x~WGrF^vH%xI BFG~Ob literal 0 HcmV?d00001 diff --git a/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7872fe265938c62dbba5909ca60ae1e7da42b5f1 GIT binary patch literal 41201 zcmeHwd2k$8df(jBbD?n%04aeYz)Ktw#6f_UsDrmaEyabJ6sX8p%?^PfIN|^^=pKk7 zcD0hP613K?q}r4v*|HbwP1#_jQlaBi!qlb`y5dAC*CsW<02sI}Ra})#>{Ko;xegNM zkL35g?tb0VXkb8*lI0W{jc?w2{jPqm`+ev8dRJ8}?85PXpU=Gf`X-lafFj1>(*t*Y ziohGLmt2zjlq>IMzn&iNm}ir#$CvkS(yj#Z0bKE%3ib$jp(m6Nv2^dLa8D#3VPW5? zXiqF3V`2ZPsva>fvT)#3bx%#cW|OPl)#8$ZjV?*(_GoSAYgt+dXE5i}JK} zEG>q#DxOx)(nO?H^Rx!Zy~P!;`Oj4SnYc#{AIayF1DA$VBY8D)G^wPHT}qCOq%v{0 zDxOV^3_dNV2hF6&*=Nu7n$hZWSJIhu_UyA~%{1{`Dw7(Llf!ya*@(KeyK>!WMIebW z{RYNUa^*cQh4bD9R~94O=*s&f51?Q2<^z4Md{FYC$9@$RD|t%~2aL+7&LIB1E|+lU z0|ef1^-~%Nlp=)vecZ$enPHdB(g6SGS&ZldGRE-JucViaX$1P)6kn! z+Z#YDwgc~1q|evomZImkba}e<$3XH*zJ9^T+RIi1Bc}bf@W&)Cn2=nN^&v}fk&12b&eev_yV9cKR zi+S|i=R8)e&EuCmkL%6(GeVfh5Y6K=_r9u%Y*npZo5e4A79TR^&iutJhVN?@Yu0A* z%bZ0i1p2mi+<&nx|7u<#l4LNMPmYK4saNyuxlA%WqIz>zJJfK}B-N3yhf??6tx76N zO3pWL+mXAvKdw;X{xc`9x$k=2ksvYHimFMF-M@D3n%Mm$lBd zt&lxEl1YzHGnNunGI0Q^VBD-4KCBVoiKML7BnFb1OhQiO1jHNUA z^oXJcGFjwM)b$DFQg$pe$g(DeGT95sOoGNv4JDN1#T3<`Rvlqe0dkv>^J-nn0*(rt}&X%Vfheb&eU zC5T$KH?u<)sm2CI)0B1RM(K>eo4;8f%F)?)or6X6AM zLrL5`Ep9FdyC;Vjm=<@J1pO=uy9?rG9_4|FaUPrzb{nyIOYT{!k7u$5P=K}CGA*_g zgx1OP3`~oyB|$%nLTf>6;ZYu-cJwSWLaPy*x8$Cs`gkU500mg9Tc*V=1)*#5+YC&L zT_r(3i$YgH+`^+gK<(&RW`r&yHgCy2OZD+g)&L5i)px%0)^`fRw#juG5Vw^C{VWRG zCceXCJV1@;IcAVUkIkEzXQ?)x#~MI>cAw4DVsk-=PoB_#7%vI>Srp<0v6)ACfSS>> z%m{HKHgCy2OZD+g)&Q>D!6W9n2H}LD3JLa(B@${lkr>Vnj%88^M-z$Bv1EqcKE%WX z9${I@)0;n%O(f)M6qnlp)cQn1$>XgXNZ>h^(-+3@?4v|YA~BG`OEs7t$Pc9O`X{g! z#Vm;^Mhxj;_QuG>t1>`rp+c|a|9TUT|80-Q|Infk@bA3k0+2h6Yy$}J!(eRhJc*<- zfya6$fd|AM0m%m#l>C5#6v&4l0trh&pa?+|fmlQ^&OG&R`d1?Y(Kv15d?kN7=L#9e zt#ifvCJn?pYz$hj{LJBbg^*UW=qbT@NyitS&p6# z+hOI@!Eu8R?yr>J^YXD>!f}Toi&)BW*BRZj)a<}JFT-(1EgZKqr{-OjQ#0>+ zrFj&~sKd2+^h;m#JUVk~{`;KAa$aq19>3sul%gbeSA9KSKYQ+OwKAN~wWVH74(Bqd zHjtjGulMWHx%6j1HmdOa*%Q*4BhQ{t-D5JX2A~-=xvgA}m`ty=3`wF(rU&DUya5n& zbn6O)@ujS?gk)bSe=aL8$-fdOF$NZ7DJKz=fr&uGUX-)L36Q-P(?hC{x*|V9y@$HR zB{vgjA+VXi76MxdkeFHZQAbq4sNSHBk5Pu51a=W1WrDn$KpVhPnzxzJyp~=hGTAFB zc>r>AC2=`*RZ%;L`n4cdP4c}$2^w?dbzpbU!L=JpwegwS_|(2a*NL02e3CCbpC}{; z3p-QA+KUsx1+n3sZ@&dHv9qwLQ-jlDXGze{qR=@3n$Jwo!V_0`+6;2&v3WD|EUS-a zvIbCqH@fA6d_e#JwOSJNvnXt#Cd~vbOpWMiGsvOG=FQBrR1wc(4In>SEQy<@#Z3ia z`(zgb)8h7$pr1uydqLd9qdY*(=viij?M7_gl6#iwYM^62oXuS00$qxxD$CJvCja9Ou%UrZU=wN8bDw5e_ zK}YmZ>mnG)VKgbsW`>cdhlwlcJS0o`1dRoxOZhQ*gmqcnUXPy;V$z z8AlKSEak$Tf6P#^1fA=Man04wb2=^LSWcj6#`5iI=Syae7JaJIb`Xy&fkKvq{W?m> zdHcLh+^$6%t)YhyaR-)Hd$pV{U)+10Iaa=G8%wB;9?WviOEoVC^bnDv{gv|brMu{= zx1FHWqnd$(d;)FCPXVYwJ)itFiurpW%E^w09>4HpPx|rp3s0VdB}EXg|E2tJ=5Bmd z%T@NWzFNubrKdYQ6OXE~6VDzwbvkkM$g!`TICGpWFYzP`wR2h}OWgl}i-g|<{|GnWcwY+xXzC~V092}@&>(8oaCyZmagLNOw; zmEQn(!?n=TJ0Z*pk8pB!Ay!wYe{^a?p?jbZ9lYc9)P*O4w*s!(4f=}GczxH+{JTxX zn$y~I1@QIxoGe8~pEuq}oj}A}CdK;sd*937II|H)>k|In_d37H z{LM7l(^mkqoU_!3m%|DWldAeF<>zm&Z3PJQ6A#J`OC1ifie)5KB5Y1XtN_(=0+pEW zwfs#)$Ag2}fwtV>MWzqi)_eTqxR}i4GP*faJFc+TUQQyj>gh@2WsjKJ3Pm2EI*EC< zzREFqkWz>%)m~tF%Gg^hrzq4+poPG4RD!{QUZhOy^`$}9+So}U_72kntG&ZIzHeFP zB{qAkcCPXTHj5C)t9%5o+L!ki0aruwWN2!Cq5DL!{v?z(oH}T{zV+sdH&4^s{M;At zHsihJ2U!#`oV^G0P{~v;2kSt|-R>T3!BfQX^k|n8Mjt(HzDIbrJj7$?>f>%@hsH$lv9MZRPn}YHe|8 zdA!qPF=pPOV>#0@4GdzXynNIlv9FYuQx~g{q4RY}sw_k2YmgMK*awqM3YIan0?|7T zY!WRJ?6B1AymC6RNincVZcXN%^WxWMuuY~-qw!pf);K=3o6RQ&EOXW9kNd)9_Ba2> z_9Qq87^GUJV_A3pF$-(F_RPYX+A^!o?}GWviWT#QjrX+OU-oQ8q-v?AFJfcCqy4LY zwxUMO#xGcK+p{GDQh6WB-K0EDMkJ3AQc0`fGiL^{DY zv*f*JPEyW;cgwDV(`HvYWvoFYSpFX4zhN+1_7_BMkvPX`hjVfS$CBc)%iE z4ZpU7+USP$N%*z4OQ~cgepp}f{b1XgUD>Rh3j zgXl-Zq!QGqwMVO!UB_dZ41rMvB%!v-2liSc`>m0KngNP7U7dT%Vx3w{GxX@}E{o}Q zA6mHsWtTe2VtbSrI%5hXxJ;sk(J0niRa+cs5^I)ys7o0EdWXb8e5fRDhs?JioSq6? z_fA5_aJnFFpM0$-?nSU99-S7Cvb3W`@i+pX_%tA%F5xmeXQ{|>9_4|zAxzM-%m}BA z*gR{1mz;!*f-B`<|&0(bKpxPk7H7@rp72o%J4QQU>V)R+du z?h-Duvq+bB@hA^aKlLm#Lbnl{7rIHBLKkLPy_T?E3DT#=cv)Kj&1=>MdocKC@T7L( zS0*eY?liW=1BhX9$ zywdUaa$7akJT>w%vE8^i< zVaHPSX%IKdxbv66>si&O#E0+Km-U>xA9=I`yS4OGC+@~kn$9WirJL?@o|paFMWL61 z9lg#nGCnG{6~!#)VC#>Sj+euVLK6K-`8nThljr&Sv8zoxEOn3|%yw8;S7YwMtq3{a z3!dI)TV_OiH!n?-Vy%|D$p!iXQk6C3C6_fOiPo6yTDA3B4u+GXZ}tgnXW)m%NDf_P zJhVA7TbK3-`k;sULQpS1eEwAn4_#v!3rmlU@)(PtF$Pj?#k_)%*EaTHsZLtg7q;<_ zk$aeth*7igOEUsl1k||l2N>OY>nPbqu)%r_BiOh$f;7Lj5nOK_CCUEOZCD#K` z7T3yKqfdBF|3u#!-E=pg4DAN`h1x%u%%#aL3x;1K={&Kecb_!(JGYhXYi{FPnp31D zOb|97!k+auIW;;)d!gHMWMm*Dp*qXuQ-c<*f&Y9~&J3P$QWdDamzC^@TAfSD_)TWg z5lro&26M@) znQU@Uev$H#%tQVq8U?nCMUz_Sf)6dSUy0(fNz5765t<}|(Pg^sQr+gj0b0m6J=;-K zr`!QK!pOC$9kaDv6TweojUR-Iu`Q+8j_KHrQmkz{*0vBmz3BB+g@54!_={N0LTmfP z@ltf-badlvG`_IS65G5`-}HX}d;OE~sq@!|iuF%UoLr2c)UBwiS~u*wPo3wi@bE%y z!~1RTwUufgovwX!Dlk{u&g=#Yjqy2g=TzsMxO-}FPVBmVWKP_7{mPv96a*(X4>B-q zk)9Mu9`ckX&zQ4>A?&z$aH*IBDUfu)aPQr#b7IS6%bd7%vTsgoEeMCMM;MqE50wP{ zEDDDTw6}($bC&Qd<>tApff?ZtRsPENUjG1nezYJQF6=t2!D;buNzl(C(#1!$=$s`y z%kuMV)&MRo)?*SDY5pLGs0!az$T`73+jqLf|I?Pp={?K%8`$joo1to#rCCj?*8e%F zT4tAGP_fV;gy_$RubQfbl?rIbuO?MX`g4(38a=uENV9dbzkbPZqKnYssh*=EKVl4_;8zL1Ru4&MU} z%(XKX8kjs#1V977NxN(-P$TZs@9We^gAD5}m;8MK?-FcF#-zD%v0-sNG0wFQS-SY2IJ>{rd z!`RH2o-nq>dXZLD3x?L@Et)|+L(rG<`5c*fX5}(*OZUq7+PM@7kd%%GfWEs-WWF-` zeqe@>*pa#QhtUtC`1v3@Ep9Cd`dJjVPC$}Ru{kryFV7=?fX8Oc7+r|+3)aA_uys*% z#C=ac;s5xFNKe%=xKCiXPxE3`F2Rw!T(aZoH!%`lQ=L5NINC}1A$g%E#&$^N+7;aI z(Jn5P{cM7TM=vR-YzNt1!TXgiaRP>O)1MJ%7>*up+p$D$quF6q8`+$#svIQ2>v#!{ zgNzg=Y3A}50}JSOSZ28j@*c=-$PTMgem)yE*@3^G^t9}-)X`+5nZL`(ZD6w?1%Tlk zBt6SwIJ2+KX8lHJSqx{Eavl-$DX9j=|EkVZjb)Ub?}JN(R*B(OKYvabj;v2K4ClTl z42N|V2+IM_slhxvHoztwtawC852g~Six=T^%w(MzvU9?9t3h)$s$lTuP6$t}mNd@& z#H+-y!_7uGkI)^|>?@4Ox_u7CWN-&NJV=<-&D|C+!p!PT(Q&bfx@LYO$$2Zs8- zT#dzI40ph8@)q6XT_!^K`m^S0D819o?zeR>7KN4aa{X|7pv;mh$aZrFZCr?8*-T~D%(-Pg=%jh(GJazQAV;QM`8F#-Ib}YH z-FgME5Y(w7vqtcjn=MbFSBj1PpfBWLA!zoC>%q)g*fU6e4{OgKTzdvFOP}uX83G4u6l1#78I*Z>XVw7C6vd+GxO4oP&hT>UJNa9b^V*CD?~>Ayzc4FbPH;8y{d zJkieiG83v5gwa;hxFM?K9K+=ckM1l8N8f&ZQkfNDLUyzy9+(ynAW#qw6vf97xG|^! z5vFSTIZKxx<53=X`*j|ip-Q#byl`|<(SocX49Tb-7CvAJ>lGsX#-NtG6rwAiijNe@ z@6UmfxEp?)5GaVdi()$h*I~>@Kt#3rS)|MDJjw&qIX%maaKMPoQ*QVTqARnkVoO-B z1?kse!DlvM4Ws?a25{e{iTr&6f1kh~5vX7QH$iy_9Kye{6@VMS#cxNW{v+<&O(Flb z+YMoVd~vKT4r*PYVgvZ+he2lCIe?@x&p(+;7I1l?&y^2J0c?&5s_TfHwEI~e zFlwjfXu}dCa@KadukCnWGk&92!m?GznMmVk+Ja2&JKkfv_U`ZM=;++j)xCG$etBgx zI5jdLrv~8}F%5Ttt6_tqGLdPYled*$Kv_pqxVXut8m4fTj+HU0PdcqE7Nu&1^25{x z!!~Ynv39Fz8<(9@t{*Gb9C*OH&70uh?+>Csf{?X!5<*t!BJ~q~{>Y-5VsmDYUxs79 zNeEf>%y8gm#OOklU$6$?|8E7`xL*O4Znur2z2y6`4AW62UFBTKs_o-AZ?aTI%66zb zSjS;M`ZAJN+PiAVO2Nb0wSzt<-)l?uuxi>t&Y|^k*_?L21Wg{rV)MpdK4&YrK0Y>_ zOKoIas#(ri=E}=~J0b|Tb8kn--n{@F`>uJh9d=+K zJ2nED74n2hXO&^5>-NVk)i_)!s~iMs3;sxT-LW9r4K_ zd`BUGbuO7tsf`+G&pf-P1{2wlffU;@Z*1K?hWga-0J{k|f~11l4VA0Jmbj5(#H8jY z)lQCC7ERp}ML{FVPXWjhW%Jb7&Bj9C;B0Nm*&LCu5X<< zX;_}LU+=mRDb^o{<+LHco{;w%Za|!5JnlJ< zWh*{^!JN#09o0&lC3aXSu^>X|!Bj_5#ApvrI~>Gjy?kNAgz1OV5;wwK13*z#Z{#$8 zX(__zR}3WJ1Iv#;BMwECuh(HYTe8?Uw*OOV$^D>W2=#7{-RC(ELav>au~2~G~Y{>b+&_yFKHx3 zO_Se`86q+Jy2DlkQ9K_`CcpKT-ZeXwvY7lfe1&JKvEqGjQKd~$6_(aZ8vqs-#czz|Ets zCQ{^C>Kdc_oe*H(Z|yPyX!Qu;rf}eSV?3?%hOT=VQO{dXBlPCY~upw@gR3l%lQE z(biJ5YdYFB8+{5WQ&)x&G7T2%4+EJp#P)!-VPCQ#NKD7g1wzkcJMqWvBc7fb|0gw( zp3UV5x(-*pqb+ z+zJj_u_tW0CVJ#;2NvIYW(U_$W(P+u3psz&OPjrHhuJnmeJ!?@9MHRyR*R!R$hJSH zR*XF6`i#ywkq|&*rdF)7_R7>qRMuW`n-loMsMH6J_Th)DQeKX_VPRM)FGtbfzbfVB zgj%~$rfmyJRg&mn^9QY}c35iWJf3lliMpIDBC3~a+sKunW+h=h>jnw6Dy>`1=pXe( zK8Mz=p84fyJb%cdbz6JJ!R1J%|i?V%HQJXe5b`M&&m_dLV zztHB|o-g@pQZ+>N>s5D>ZIe~q1B?*SwQmgF6SpAe2$z{*H~oICXxN`n!0Q&3lHaztc2!uhZbMZ?))}Jl!3J9_x~SnP<@b zWgIYddraS_&|_yMb@c5gnvRcs59J)A;>^!mYJ9#`$@eDeP`<;67wjP^2$yag1Nh`D zz^u4w>ewe|XT|3bDTyyli!UKi5ML^a-=x6cB^`>FO31*@S<3KD9_0bl#lthKYArf1 zT$(zj1yS^d8T{ldPc4YgS;8+_!g@p0=%CpLEdz(R`l-07NS|0aSQ6W&#Wn;AVp~z{ zK;Sw)3qnA|_7eRp(&Y{wT|5Lnh!ldhkE|LE zQDa;Zv!)MR(+95U1L>7`5c)vX&oqHd~Fo)9?hjg4NHcBj`a^ozslcXDDHD@z93#_T! z%yQ1;I4=h&FnuDaQhrXp+f;7%`(1$$Hh_d5@OK5OnZwVTGKZg)UGrF&&m~cMfag68#<10)7G8m$)F$wsNn-c(Y>}$3nauR&Kn}6}K<(F}JPU zfqU3!#@7^!*d)S?B^PaT|o}+F_}g zYi5mWj8kzEw?%+4$dwU(<=>JbtI!?J=>{?nGu`1ex3-=7zn~@gKLK3x$p4MP{{!Ie zfy%Zod7VbK`IPZwFQ1eDJL*t_XU8tQOgrR~h&kH8s9xYV`R`Jzy#$`4VqUmV(R}66 zRF=7sfeTtD_KUmk2H@`opT2VT z%a=8GGA~kwT>w_kxBHm081@M_%>aKn`P}bQLFN#~CIzveWZewsLu6H+b`u)6h32keQ#XQz zrtax-&$G0>oP5d7!L6(wN?B)j62| zoGrx8Y8F8MptEPE|C61OoHCSO;+noy7z$^zW0J|c~`s;{2Ot+~I8286uyAy)#jsx~z)^^9dw`RP? zwgdRPk-6(zU*@i_GXCbONZ{|U?o6$#I8&Ssv2kr-sa|Suu-#cb4(7@=b&%m# zd66RY92Wp325(BH#tD7#jP2>+sb-MguU>Wzg>?YI2iuBs&%(cD^Y!HEn{++tqQ9@yS; zd`z_c4<7%~1R=BXP*n|qh0LuXT(>H*w&a@XQNaP5L@s8Ber^m*c!?ZTXjW*U_K=^ zWncAjw43SHpD|K=>K@NcbnD2Nnxq70NHxK+SK9eM$pS6HfJBX_lMX#9I<)s z0kJiji0zCMW>bSBsr;3!d|Cd77+@O```nIGEI8~Bsalhp`$rUIjNJX_4aI-;}~`077szEEtu+d}l@QU@sOogAQ8u+;yj4p2Pnp5E&J zcx&Y8z2#_1#5gip?3x3VH3uko7W4ypKvT-Q<^V+>6(^m+SH|4JBg|JR?Vl3J1C$wC5@7+UYyQ67o^aKsVPRRpaMI%Vg!TJg3@7Cq z=yfIEC5}khYS#EKsgiRH{Vv4e5Bm7quTRy@k{^)cCGoLoa#?a5&Pj?QIJ4t7@fj}y z^xZD~oTba)vdk1MOfEq5v>B>Yi_Htir|PsIE4bSdhQAUjV6710n?5ahDMVNPHu)}z zIvOkf6^38FMSx`8vI`TjDhDP%qud1E#=o)yfSWDi<6S;KyqJXidv7;H{2jL+io%IW zQ@IlpDjIK8g$rZp48BmUFky(gA(6XEGKn_LwiX`wpa{7lr?J;I#FNyZo~DUSh>caZ zS5~di1}T~NVi{D5OYNv!qTJ0t^9-c6uDaN8KF2=nx0)j6mS?tQOWj$y6+=^`k>8>j$EabbIE(%^S7%JASu42 zJPdDNcfbCb?wR+#_qqoY@hAt^pIeHz>cHhOVO;@}dH8!^KH=WyB>o1c^Yqs@>0kAA za+3i)*vU#ldI(BUI=|B$4sUUK{1A7Vll(_GDKO$= zxq5=;1tBkF=ZQ2g40(c`*DCQnTsrbktTvVQ8}X}4N~xq4ua-*-g+-%%s;n0l3VB5@ zluOqNT1CT;#1=xW0h@aEti4gsUUB)kU`AaT0y{h01Q%#~qEj&OQF@&OG> zem#`q^sp3wp#+VN8CBEW{n|ZPtzzR5`1f<1@bK?IKjCIEk6zQ}6j*8eoesKFjF(^x zl27tW0Vybj#zHK&!sqyDlXust_#|%Se9k{U$#E;!`pu%nuQ=`mH!E0@)AXgVQtTI& zCXowV;3e^9&jsH|faOYINtlgUlG6!`bAC^ABWB_f{4Sns>Z#R{w7An;jvq1S$Qt4N zk*0cWmbCNhuJJnq1i*$$XqRZ zdxsvAZ4q^8Bv=;ryWV)_U$#ZEfg9D1LcU@sQ7t`7oyx}WoP%FDf zEptFnv7phWpkg{sP@@>(GJP7&2<3?i+L*LpZuI5Knhx4jR)~?5^GdNO6SYEMCMyb2 zjTjWmbE}1-UMNA$Vi_bfqeIq~%B#hBDk?7)%X3Omrjr+uHDy7?7L3Fz^x45GOb}8g zMoL~NlVwGh)jMDfO4MEu)KTd$%)eS(AgD-;7VMEt+ps$^;!a#(3D)4KJ29?>p_RTa zdKtOw61)=GBYAG@r$p&?uElF0*E4 zEtlujqL!(w87*|m*gP7&PqIcCmsA`^r5y#;`w90=s=t=XR#VwpYP6afttXG+-G01& za;Sc6sNUYO8|T``9&+LK=sjV#m22(zZ2kWFhLC;`p`}_SXdS-V*vZ#7;YDGuVwOvMKaisU4T} zHnwLAxfv*V2s6ax-3A=zj2iI2Jg))wqY<|Zc;~Z0fgptfekpYDmR6EpKw+iZG29jZ z`@WT{xc~(XZ(AmL@jp!_*&8$@b3p(NZ8t}r12mj&>d6x{IMB&5qt;00k2lr#1Ot!P zKOl&(!O8(ZgabHQT(H1%JUJV1NU?oo1J*NVgAHaaB*g)?g_^)g_$k0i*lOAO1#lt) zIN=#UsrUh;%*@&lW%?L?A&u8*ZuW$gW3_Ubm-$uSs$aI@;WNJRt>zow@a;$<{*AyF ziB~5li9K8wH^^CU#3F#}6I=m2n3GV7e<*R;f08Tk=@daAZ1ArTe4`C69imov!bEMUy6Cm3eaibmHn`SNN>kFF1-9Xovq%&o}A2OkDU zaBYkr6(j)9IdTd~Cz38C@bz(|8%Yn6ULZz@8Dt|wl!B&eMr7hnUag>al@`bu6zfAW zfU+)QdC{F1cPGY}6=%kB_*_HkyoL*6n#(RSh>f12W^1)nSXotN#36D;CDgpL)fn5` z-aWD6QD|Jd4hkMjpm3rl^j3x5nlM-u2B`^brzWtoe(Xg3Fq*)~(05!o68)}~i=5n3 z_+*1{Yi~^)tcrsh!gx&_u8PCJY>2~K;ss#7T%n{Yj@O`=-dk9F!A{!Dw%B9yo5HxA z+(G$4Rd%wF>i=X*Ae&(B4S1mSBjSbXd*=j6X zKXj^osH5J|SwG!VKh*`Z0Z$h{2U8JGJo0miKBxuvX8g?e8h$l@J=J$T5&SCAa=kP1 z1^DNJA;@&CWZ8(yGC-Eqq6&OWmRDAlB7Rel5@mUzKs3ErD5<5gEE9CX$yHqaL$a*t z0M7EVqU)qEw+e3q)JV#5z6hV!d?Byr0YS?$0MfWC6RN?|axc?&h@clo364=~2lDSv zVcq}biv|ZCbsP&Ob~zv+bOr}0SX97VONmDTi&tO7`eV+4=<(k^lq(=%W$>1Tq2dHQKSJ3y>?f@|#?w@hvsOUCxtx zhONwZwEgC#|HF4wbj`Tu`;v=TGu;&Kib}0gG8b)H%a*5D%NDC;>(^OJQh;?t_|bG# z{MqMS$3s7^V}ob#1Fxf$fPmNG@59Q=bAUmY8IOG8A8V(D{J1XU=(04%|m+rrh0djzEr zPONGUNFKMaHZUgE_D1BHx6GQE>4~XpH>R#f*Y}Bk0c3$#9e{FI;0&sa?=iZ3xumOi zbXhAY6^Lpz)oo?d;(+-Odm1Ce#%6@gp&c0Bhs`k>t<62BV-(&SfmMhZk{r%1Mfxux z_c9U(^D{7isR7Fm6SP|-jpQs4iVyoNY!3l32Jeqkct5W$D62(yY&yk&@JsWB(xNfa zjO{J>#9~?AwZ^cszcFCn6x%x7iKxE)NIluU8=+CHAdPCpFsk+O{f{?2;@-!0%4RUeWJ+v;gqhm$5QaSL$0cnip=hJ~3M+zs zpl4gm%|MYWF4p@1{0bmd5nLGa-V%CgT+GQ~MK~@qZ&T>CQadi`ZEVUGax+kY_Bv1% z2R4MvA6})TDrRbed2b1s4ROFu+6?w%ifjrQE4Aa2-p2N9AvXgh6q+|+W_vsBH9*qO zgP9$?uAG-X+(%pQ85DZWNak!aHP z6!`fP<=eD^&A7j?Pi#R6(N_cWG%FfHAc+)6glZp-s)4Un&>!5C$6B zTQfIGzY1Mwe+2UQZUbuMqf8^yjfhM&m8qpJR#O*uTd0B#Q3ai_74+E$pm~5)#j`cR zytjn2sE?ft#-o-o^EN@kOzk+r+t`pT<7S|QMFS>a)DXrXui-3RM>2`z1`-9yB9hmU z{1nL>K-`gspJ67FQ7ph0!;Cycg4suhh2Y>W2ZZ&7Vh4;oyt!}WA&Cpra5kc0Gs9M& z9iYmdQGJ@{5wbieL@Vs@gwqI;Z^-5D!Jf9?<7~njRgd@r(#tCl0cR7EbXU8rVOKlo z3Y*(X&uWK*!zPZZgV2N1gSV`$uCl%cARWXr5FY#g7$2sWt7ThRTV-?=cLhOxqhO5Tbyp^w-#414B?4!IK|<5)2&&mgdscut4PBWPrHsux{il_T*rp5@dsW< zDS~b-^Jaq+cyz-EER{9=ZmYJeS2F4y1x}Hv=<%il?Ce6!JgFh;YZW-EfIe+eSz`wg zLf~ED@ig#dhpB&i*L@ZP{7vfWPR%eRQGwTnxx$S|!U7U)2qa)b$#N@Umxv$^0^f3Y z4~zlFF_gSs_&^1iQE(1q@ORJcQK>=-Wm3@BnxP5CuZiu55;|mOPpKPAdyw=3F(P)? zO}kBoV6~raVY^A#kbAr6oXgE*`)TJ{G4naieI}L|V;(9hAs3>-gC)&wC*5H%PCjNM z-Df1Gpq_@d98gR0R4v(8P4?B2FI1B+)RP_cqg{BnAKN&Q*+^xDPb{yetY{-^zGf=|( zJ(zp@vH?rmv-^7#u#91339cXH;AE77la>Z>0{hi2ugBBp8(~A@JWZ}Ql(0?b6HTrM zm~fkFFu8D$)MZA!(2H@l^>j0G=*$+Q5mb>KLAQE2@h`SvG_jlS}*A zhJFd=mz0sv9s4KgSKCuNyO%<|-KYvA7^*0FE?`ga*#Sz%_Ve zBK62Bw`EYP^r+zJCnz#U7nF!`AMvZ3umzzfMV2 z?5hdpy(RSBTeDL(gI~ERu?Z4pYR3`Y#@1{ZHv=X6J6uCJCF@9@>~P(}VkB?Ef1|_I z`{-~e*uTpGVSS-kI%x>ji9%|ejH5C;@{nQX22MI;i25PeK-GxYSw`5*V@rJ*S~5DF zb9|YG^LiO}RFAzVG_Z~Ck`Y)|$ZaDse@iJXmgV_5Bg#6)Y_?^M9wB`gf_^9)G(8j{ z{Df_#zeGNT0@RZldZ@d8p6CC;alhhz8GjTA@V;+3AYhOZ?Vr7S|J_==yBhEQB>E`S c&nLd+fIOPv+xfw7IUtWRZ9JTM148@xFRhtRt^fc4 literal 0 HcmV?d00001 diff --git a/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cfedec100f1bb0313b8f85241acff7583d3016c2 GIT binary patch literal 14631 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 aApA)9VZ%QQgI@>U62+_Hra-@F@&5t_wAq;e literal 0 HcmV?d00001 diff --git a/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fe243a642def69d8255b2ee756cfaa2ac4978281 GIT binary patch literal 36125 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", is_html=True) + assert result.is_ok + + def test_send_success_with_attachment(self, settings): + settings.EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend" + channel = SMTPChannel.from_config( + {"host": "smtp.example.com", "backend": "django.core.mail.backends.locmem.EmailBackend"} + ) + attachment = Attachment("doc.pdf", b"%PDF", "application/pdf") + result = channel.send("a@b.com", "Hello", "Hi", attachments=[attachment]) + assert result.is_ok + + def test_health_check(self): + assert SMTPChannel.from_config({"host": "x"}).health_check() is True + assert SMTPChannel().health_check() is False + + +class TestSendGridChannel: + def test_send_without_key_returns_err(self): + channel = SendGridChannel() + result = channel.send("a@b.com", "s", "b") + assert result.is_err + assert "API key" in result.error + + def test_send_success(self): + channel = SendGridChannel(api_key="sg-key") + with __import__("unittest").mock.patch( + "infrasynth.notifications.channels.email_sendgrid.requests.post" + ) as post: + post.return_value.status_code = 202 + post.return_value.text = "accepted" + result = channel.send("a@b.com", "Hello", "World") + assert result.is_ok + payload = post.call_args.kwargs["json"] + assert payload["personalizations"][0]["to"][0]["email"] == "a@b.com" + assert payload["content"][0]["type"] == "text/html" + assert payload["from"]["email"] == "noreply@example.com" + + def test_send_http_error_returns_err(self): + channel = SendGridChannel(api_key="sg-key") + with __import__("unittest").mock.patch( + "infrasynth.notifications.channels.email_sendgrid.requests.post" + ) as post: + post.return_value.status_code = 401 + post.return_value.text = "unauthorized" + result = channel.send("a@b.com", "Hello", "World") + assert result.is_err + assert "401" in result.error + + def test_send_request_exception_returns_err(self): + import requests + + channel = SendGridChannel(api_key="sg-key") + with __import__("unittest").mock.patch( + "infrasynth.notifications.channels.email_sendgrid.requests.post", + side_effect=requests.RequestException("timeout"), + ): + result = channel.send("a@b.com", "Hello", "World") + assert result.is_err + assert "timeout" in result.error + + def test_health_check(self): + assert SendGridChannel(api_key="k").health_check() is True + assert SendGridChannel().health_check() is False + + +class TestTwilioSMSChannel: + def test_send_without_credentials_returns_err(self): + channel = TwilioSMSChannel() + result = channel.send("+573001234567", "s", "b") + assert result.is_err + assert "credentials" in result.error + + def test_send_success(self): + channel = TwilioSMSChannel(account_sid="sid", auth_token="token", from_number="+15005550006") + with __import__("unittest").mock.patch("infrasynth.notifications.channels.sms_twilio.Client") as client_cls: + client = client_cls.return_value + result = channel.send("+573001234567", "s", "Hello SMS") + assert result.is_ok + client.messages.create.assert_called_once_with(to="+573001234567", from_="+15005550006", body="Hello SMS") + + def test_send_twilio_error_returns_err(self): + from twilio.base.exceptions import TwilioRestException + + channel = TwilioSMSChannel(account_sid="sid", auth_token="token", from_number="+15005550006") + with __import__("unittest").mock.patch("infrasynth.notifications.channels.sms_twilio.Client") as client_cls: + client_cls.return_value.messages.create.side_effect = TwilioRestException( + 400, "http://twilio", msg="Invalid number" + ) + result = channel.send("+573001234567", "s", "Hello") + assert result.is_err + assert "400" in result.error + + def test_health_check(self): + assert TwilioSMSChannel(account_sid="a", auth_token="b", from_number="c").health_check() is True + assert TwilioSMSChannel().health_check() is False + + +class TestTelegramChannel: + def test_send_without_token_returns_err(self): + channel = TelegramChannel() + result = channel.send("12345", "s", "b") + assert result.is_err + assert "bot token" in result.error + + def test_send_without_chat_id_returns_err(self): + channel = TelegramChannel(bot_token="tok") + result = channel.send("", "s", "b") + assert result.is_err + assert "chat_id" in result.error + + def test_send_success(self): + from unittest import mock + + channel = TelegramChannel(bot_token="tok", chat_id="12345") + with mock.patch("infrasynth.notifications.channels.telegram.requests.post") as post: + post.return_value.json.return_value = {"ok": True} + result = channel.send("", "Subject", "Hello") + assert result.is_ok + call_kwargs = post.call_args.kwargs + assert "bot" not in str(call_kwargs["json"]) + + def test_send_api_error_returns_err(self): + from unittest import mock + + channel = TelegramChannel(bot_token="tok", chat_id="12345") + with mock.patch("infrasynth.notifications.channels.telegram.requests.post") as post: + post.return_value.json.return_value = { + "ok": False, + "description": "chat not found", + } + result = channel.send("", "Subject", "Hello") + assert result.is_err + assert "chat not found" in result.error + + def test_send_request_exception_returns_err(self): + from unittest import mock + + import requests + + channel = TelegramChannel(bot_token="tok", chat_id="12345") + with mock.patch( + "infrasynth.notifications.channels.telegram.requests.post", + side_effect=requests.RequestException("network"), + ): + result = channel.send("", "Subject", "Hello") + assert result.is_err + assert "network" in result.error + + def test_health_check(self): + assert TelegramChannel(bot_token="k").health_check() is True + assert TelegramChannel().health_check() is False diff --git a/tests/test_notifications/test_models.py b/tests/test_notifications/test_models.py new file mode 100644 index 0000000..629c6bd --- /dev/null +++ b/tests/test_notifications/test_models.py @@ -0,0 +1,95 @@ +import pytest + +from infrasynth.notifications.models import ( + ChannelConfig, + NotificationDispatch, + NotificationTemplate, +) + +pytestmark = pytest.mark.django_db + + +class TestNotificationTemplate: + def test_create(self): + template = NotificationTemplate.objects.create( + slug="welcome", + name="Welcome", + channel="email", + subject_template="Hi {{ name }}", + body_template="Hello {{ name }}", + is_html=True, + ) + assert template.slug == "welcome" + assert template.is_html is True + + def test_unique_slug_per_namespace(self): + NotificationTemplate.objects.create( + slug="welcome", + name="A", + channel="email", + subject_template="", + body_template="", + namespace="", + ) + NotificationTemplate.objects.create( + slug="welcome", + name="B", + channel="email", + subject_template="", + body_template="", + namespace="other", + ) + assert NotificationTemplate.objects.count() == 2 + with pytest.raises(Exception): + NotificationTemplate.objects.create( + slug="welcome", + name="C", + channel="email", + subject_template="", + body_template="", + namespace="", + ) + + +class TestNotificationDispatch: + def test_default_status_pending(self, user): + dispatch = NotificationDispatch.objects.create( + recipient="a@b.com", + channel="email", + subject="S", + body="B", + ) + assert dispatch.status == NotificationDispatch.Status.PENDING + assert dispatch.attempt == 0 + assert dispatch.context_snapshot == {} + + def test_status_choices(self, user): + dispatch = NotificationDispatch.objects.create( + recipient="a@b.com", + channel="sms", + subject="S", + body="B", + status=NotificationDispatch.Status.FAILED, + ) + assert dispatch.status == "failed" + + +class TestChannelConfig: + def test_create(self): + config = ChannelConfig.objects.create( + slug="email", + channel_type="email", + display_name="Email", + config={"host": "smtp.example.com"}, + ) + assert config.config["host"] == "smtp.example.com" + assert config.is_active is True + assert config.priority == 0 + + def test_pk_is_slug(self): + config = ChannelConfig.objects.create( + slug="telegram", + channel_type="telegram", + display_name="Telegram", + ) + assert config.pk == "telegram" diff --git a/tests/test_notifications/test_resolvers.py b/tests/test_notifications/test_resolvers.py new file mode 100644 index 0000000..272b246 --- /dev/null +++ b/tests/test_notifications/test_resolvers.py @@ -0,0 +1,81 @@ +import pytest + +from infrasynth.notifications.resolvers import VariableResolverRegistry + + +class TestVariableResolverRegistry: + def teardown_method(self): + VariableResolverRegistry._resolvers.clear() + + def test_register_via_decorator(self): + @VariableResolverRegistry.register("ticket_number", label="Número") + def resolve(recipient, context, request=None): + return context["ticket_id"] + + result = VariableResolverRegistry.resolve("ticket_number", "a@b.com", {"ticket_id": 42}) + assert result == 42 + + def test_register_with_namespace(self): + @VariableResolverRegistry.register("user_name", namespace="helpdesk") + def resolve(recipient, context, request=None): + return "John" + + assert VariableResolverRegistry.resolve("user_name", "", {}, namespace="helpdesk") == "John" + + def test_namespace_scoped_resolver_not_global(self): + @VariableResolverRegistry.register("only_ns", namespace="helpdesk") + def resolve(recipient, context, request=None): + return "x" + + assert VariableResolverRegistry.resolve("only_ns", "", {}) is None + + def test_namespace_priority_over_global(self): + @VariableResolverRegistry.register("price", namespace="helpdesk") + def ns_resolve(recipient, context, request=None): + return "ns" + + @VariableResolverRegistry.register("price") + def global_resolve(recipient, context, request=None): + return "global" + + result = VariableResolverRegistry.resolve("price", "", {}, namespace="helpdesk") + assert result == "ns" + + def test_unknown_variable_returns_none(self): + assert VariableResolverRegistry.resolve("nope", "", {}) is None + + def test_resolver_exception_propagates(self): + @VariableResolverRegistry.register("boom") + def resolve(recipient, context, request=None): + raise ValueError("boom") + + with pytest.raises(ValueError, match="boom"): + VariableResolverRegistry.resolve("boom", "", {}) + + def test_get_available_variables(self): + @VariableResolverRegistry.register("a", label="A var", namespace="ns1") + def resolve_a(recipient, context, request=None): + return 1 + + @VariableResolverRegistry.register("b", label="B var", namespace="ns2") + def resolve_b(recipient, context, request=None): + return 2 + + ns1 = VariableResolverRegistry.get_available_variables("ns1") + assert "ns1:a" in ns1 + assert "ns2:b" not in ns1 + assert ns1["ns1:a"]["label"] == "A var" + + def test_get_available_variables_all(self): + @VariableResolverRegistry.register("x", namespace="n1") + def resolve_x(recipient, context, request=None): + return 1 + + all_vars = VariableResolverRegistry.get_available_variables() + assert "n1:x" in all_vars + + def test_register_returns_function(self): + def fn(recipient, context, request=None): + return 1 + + assert VariableResolverRegistry.register("y")(fn) is fn diff --git a/tests/test_notifications/test_services.py b/tests/test_notifications/test_services.py new file mode 100644 index 0000000..d74f8b4 --- /dev/null +++ b/tests/test_notifications/test_services.py @@ -0,0 +1,271 @@ +import pytest + +from infrasynth.notifications.channels.base import Attachment +from infrasynth.notifications.models import ( + ChannelConfig, + NotificationDispatch, + NotificationTemplate, +) +from infrasynth.notifications.resolvers import VariableResolverRegistry +from infrasynth.notifications.services import NotificationService + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def sync_dispatch(settings): + settings.INFRASYNTH_NOTIFICATIONS = { + **settings.INFRASYNTH_NOTIFICATIONS, + "DISPATCH_BACKEND": "sync", + } + + +@pytest.fixture +def email_channel_config(): + return ChannelConfig.objects.create( + slug="email", + channel_type="email", + display_name="Email", + config={ + "host": "smtp.example.com", + "backend": "django.core.mail.backends.locmem.EmailBackend", + }, + ) + + +@pytest.fixture +def sms_channel_config(): + return ChannelConfig.objects.create( + slug="sms", + channel_type="sms", + display_name="SMS", + config={}, + ) + + +@pytest.fixture +def no_retries(settings): + settings.INFRASYNTH_NOTIFICATIONS = { + **settings.INFRASYNTH_NOTIFICATIONS, + "MAX_RETRIES": 0, + } + + +class TestSend: + def test_creates_dispatch_and_sends(self, user, sync_dispatch, email_channel_config): + dispatch = NotificationService().send( + recipient="a@b.com", + subject="Welcome", + body="Hello!", + channel="email", + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.SENT + assert dispatch.recipient == "a@b.com" + assert dispatch.subject == "Welcome" + assert dispatch.body == "Hello!" + assert dispatch.completed_at is not None + + def test_renders_template(self, sync_dispatch, email_channel_config): + template = NotificationTemplate.objects.create( + slug="welcome", + name="Welcome", + channel="email", + subject_template="Hi {{ user_name }}", + body_template="Hello {{ user_name }}, welcome!", + is_html=False, + ) + dispatch = NotificationService().send( + recipient="a@b.com", + subject="", + body="", + channel="email", + template=template, + context={"user_name": "John"}, + ) + dispatch.refresh_from_db() + assert dispatch.subject == "Hi John" + assert dispatch.body == "Hello John, welcome!" + + def test_emits_notification_sent_signal(self, sync_dispatch, email_channel_config): + from infrasynth.notifications.signals import notification_sent + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + notification_sent.connect(receiver, weak=False) + dispatch = NotificationService().send( + recipient="a@b.com", + subject="S", + body="B", + channel="email", + ) + assert sent + assert sent[0]["dispatch_id"] == dispatch.id + assert sent[0]["recipient"] == "a@b.com" + assert sent[0]["status"] == "sent" + notification_sent.disconnect(receiver) + + def test_no_channel_config_schedules_retry(self, user, sync_dispatch): + dispatch = NotificationService().send( + recipient="a@b.com", + subject="S", + body="B", + channel="email", + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.RETRYING + assert "SMTP host not configured" in dispatch.error_message + assert dispatch.next_retry_at is not None + + def test_celery_backend_delivers_eagerly(self, user, email_channel_config): + dispatch = NotificationService().send( + recipient="a@b.com", + subject="S", + body="B", + channel="email", + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.SENT + + def test_snapshot_stringifies_non_json_values(self, sync_dispatch, email_channel_config): + class _Obj: + def __str__(self): + return "obj-string" + + dispatch = NotificationService().send( + recipient="a@b.com", + subject="S", + body="B", + channel="email", + context={"plain": "x", "obj": _Obj()}, + ) + assert dispatch.context_snapshot["plain"] == "x" + assert dispatch.context_snapshot["obj"] == "obj-string" + + +class TestSendWithFailover: + def test_primary_fails_fallback_succeeds(self, user, sync_dispatch, email_channel_config, sms_channel_config): + dispatch = NotificationService().send_with_failover( + recipient="+573001234567", + subject="S", + body="B", + channels=["sms", "email"], + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.SENT + assert dispatch.channel == "email" + assert dispatch.attempt == 1 + + def test_all_channels_fail(self, user, sync_dispatch, sms_channel_config, no_retries): + dispatch = NotificationService().send_with_failover( + recipient="+573001234567", + subject="S", + body="B", + channels=["sms"], + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.FAILED + assert "Twilio credentials not configured" in dispatch.error_message + + def test_emits_failed_signal(self, user, sync_dispatch, sms_channel_config, no_retries): + from infrasynth.notifications.signals import notification_failed + + failed = [] + receiver = lambda **kw: failed.append(kw) # noqa: E731 + notification_failed.connect(receiver, weak=False) + dispatch = NotificationService().send_with_failover( + recipient="+573001234567", + subject="S", + body="B", + channels=["sms"], + ) + assert failed + assert failed[0]["dispatch_id"] == dispatch.id + assert "credentials" in failed[0]["error"] + notification_failed.disconnect(receiver) + + +class TestGetTemplate: + def test_returns_template_by_slug(self): + NotificationTemplate.objects.create( + slug="welcome", + name="Welcome", + channel="email", + subject_template="", + body_template="Hello", + namespace="", + ) + template = NotificationService().get_template("welcome") + assert template is not None + assert template.slug == "welcome" + + def test_namespace_scoping(self): + NotificationTemplate.objects.create( + slug="welcome", + name="Welcome", + channel="email", + subject_template="", + body_template="Global", + namespace="", + ) + NotificationTemplate.objects.create( + slug="welcome", + name="Welcome NS", + channel="email", + subject_template="", + body_template="Namespaced", + namespace="helpdesk", + ) + service = NotificationService() + assert service.get_template("welcome").body_template == "Global" + assert service.get_template("welcome", namespace="helpdesk").body_template == "Namespaced" + assert service.get_template("missing") is None + + +class TestVariableResolversInTemplates: + def test_namespace_resolver_used_in_render(self, sync_dispatch, email_channel_config): + def resolve_agent(recipient, context, request=None): + return "Agent Smith" + + VariableResolverRegistry.register("agent_name", namespace="helpdesk")(resolve_agent) + try: + template = NotificationTemplate.objects.create( + slug="assigned", + name="Assigned", + channel="email", + subject_template="Ticket {{ ticket_id }}", + body_template="Assigned to {{ agent_name }}", + namespace="helpdesk", + is_html=False, + ) + dispatch = NotificationService().send( + recipient="a@b.com", + subject="", + body="", + channel="email", + template=template, + context={"ticket_id": 7}, + ) + dispatch.refresh_from_db() + assert dispatch.subject == "Ticket 7" + assert dispatch.body == "Assigned to Agent Smith" + finally: + VariableResolverRegistry._resolvers.clear() + + +class TestAttachmentsPassThrough: + def test_send_with_attachment(self, sync_dispatch, email_channel_config): + from infrasynth.notifications.services import NotificationService + + dispatch = NotificationService().send( + recipient="a@b.com", + subject="S", + body="B", + channel="email", + ) + dispatch.refresh_from_db() + assert dispatch.status == NotificationDispatch.Status.SENT + + def test_attachment_dataclass_usable(self): + attachment = Attachment("a.txt", b"x", "text/plain") + assert attachment.filename == "a.txt" diff --git a/tests/test_notifications/test_views.py b/tests/test_notifications/test_views.py new file mode 100644 index 0000000..7426e56 --- /dev/null +++ b/tests/test_notifications/test_views.py @@ -0,0 +1,140 @@ +import pytest + +from infrasynth.notifications.models import ( + ChannelConfig, + NotificationDispatch, + NotificationTemplate, +) + +pytestmark = pytest.mark.django_db + +TEMPLATES_URL = "/api/notifications/templates/" +DISPATCHES_URL = "/api/notifications/dispatches/" +CHANNELS_URL = "/api/notifications/channels/" + + +@pytest.fixture +def template(): + return NotificationTemplate.objects.create( + slug="welcome", + name="Welcome", + channel="email", + subject_template="Hi", + body_template="Hello", + ) + + +@pytest.fixture +def dispatch(template, user): + return NotificationDispatch.objects.create( + template=template, + recipient="a@b.com", + channel="email", + subject="Hi", + body="Hello", + status=NotificationDispatch.Status.SENT, + ) + + +class TestNotificationTemplateViewSet: + def test_list_templates(self, authenticated_client, template): + response = authenticated_client.get(TEMPLATES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "welcome" + + def test_requires_auth(self, api_client, template): + assert api_client.get(TEMPLATES_URL).status_code == 401 + + def test_create_template(self, authenticated_client): + response = authenticated_client.post( + TEMPLATES_URL, + { + "slug": "order_shipped", + "name": "Order shipped", + "channel": "email", + "subject_template": "Your order shipped", + "body_template": "Tracking: {{ tracking }}", + }, + format="json", + ) + assert response.status_code == 201 + assert NotificationTemplate.objects.filter(slug="order_shipped").exists() + + def test_retrieve_and_update_template(self, authenticated_client, template): + response = authenticated_client.get(f"{TEMPLATES_URL}{template.id}/") + assert response.status_code == 200 + response = authenticated_client.patch( + f"{TEMPLATES_URL}{template.id}/", + {"name": "Renamed"}, + format="json", + ) + assert response.status_code == 200 + template.refresh_from_db() + assert template.name == "Renamed" + + def test_delete_template(self, authenticated_client, template): + response = authenticated_client.delete(f"{TEMPLATES_URL}{template.id}/") + assert response.status_code == 204 + assert not NotificationTemplate.objects.filter(pk=template.pk).exists() + + +class TestNotificationDispatchViewSet: + def test_list_dispatches(self, authenticated_client, dispatch): + response = authenticated_client.get(DISPATCHES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["recipient"] == "a@b.com" + + def test_retrieve_dispatch(self, authenticated_client, dispatch): + response = authenticated_client.get(f"{DISPATCHES_URL}{dispatch.id}/") + assert response.status_code == 200 + assert response.data["template_info"]["slug"] == "welcome" + + def test_list_does_not_allow_create(self, authenticated_client): + response = authenticated_client.post( + DISPATCHES_URL, + {"recipient": "x@y.com", "channel": "email", "subject": "S", "body": "B"}, + format="json", + ) + assert response.status_code == 405 + + def test_requires_auth(self, api_client, dispatch): + assert api_client.get(DISPATCHES_URL).status_code == 401 + + def test_filter_by_status(self, authenticated_client, dispatch): + response = authenticated_client.get(DISPATCHES_URL, {"status": "sent"}) + assert response.data["count"] == 1 + response = authenticated_client.get(DISPATCHES_URL, {"status": "failed"}) + assert response.data["count"] == 0 + + +class TestChannelConfigViewSet: + def test_list_channels(self, authenticated_client): + ChannelConfig.objects.create( + slug="email", + channel_type="email", + display_name="Email", + config={"host": "smtp.x.com"}, + ) + response = authenticated_client.get(CHANNELS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["display_name"] == "Email" + + def test_create_channel(self, authenticated_client): + response = authenticated_client.post( + CHANNELS_URL, + { + "slug": "sms", + "channel_type": "sms", + "display_name": "SMS", + "config": {"account_sid": "sid"}, + }, + format="json", + ) + assert response.status_code == 201 + assert ChannelConfig.objects.filter(slug="sms").exists() + + def test_requires_auth(self, api_client): + assert api_client.get(CHANNELS_URL).status_code == 401 diff --git a/tests/test_scheduler/__init__.py b/tests/test_scheduler/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc b/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ff8a0f50d86099f9f7ea4691ba6d36d1f99c1a18 GIT binary patch literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT#mO0| vDWy57Mf&manR%Hd@$q^EmA5!-a`Qo=c15f}^Fb~s1~EP{Gcqz3F#}luHgzqH literal 0 HcmV?d00001 diff --git a/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..293ba7e1c9b9fdb3fb03d7e74c0ed661161c4d9c GIT binary patch literal 33091 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 F{{z^JdaM8d literal 0 HcmV?d00001 diff --git a/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..475106651b6ae30f8a44af8fd0ea3ff9f26f8c91 GIT binary patch literal 28346 zcmeG_Yiu0Hd3(ou?7g@ok0SN5MTvTy_&B~KN>*f%mMBrKMA0ki)y^h+Cu$<)$r*8VHRPuzs|t0;I*ANa~2Kg9K=cB7Yji`k_L9 z((jwuo!#48@{WE~aiZn%yP4ThO>taJ#mC^=cOntX zOi76o$zV+``e zWujfI2-`$QxB9%m_&61Jd^{1Gm`XqLq~zAoL?)daRvd8&Zr;<@(9y6D|3VJMF%i3( zP&_yQ(R3_xMG0u563tAf6N*4TH4y-Gt}1h4EeldRoebi$U#O#anWZ}@xr&1 zNeoA08O0w@T}_WCXbvV3A3muP0S=(zI!7L7#eMGlv15Jb&fT(+reqwE-BK;0c-Y$@ zJq7tq@Gl<(U@`919|>FYLSs&7%nMC9p=l);TG`NKIpoj)JG9|{@H`a7py98;VkdGL z`=!QAMID!d!Kvpm4$%(KDOLbw3z8=zO@GrkCy@rxdK8NABIA$WSU z2b8P`5qOf$B{Fe*UA4ml3CQygW*vB25!;}4MH&WtUu3UxmwGl`5vdr28s;PIxO8W7 zDB?WW&vBC@d(_jT|H5%wxr^+9uzI0kajc5CI&EV0g}zREm)bTy8W#z%z@`TM=KeTX zRJCRJ5S$!^X)5Rmr`vRF=OPu_eP~xWTyLsZWvT>OW4$wQU1Ejdj;!$=T0f$rB(77# z4OcX?k>a>C%(Uo=xWl%BQvM>oz2q>oj1R5pfUD-UmbLFIHqm|9mUbMrNeP(8I#U(M zihT2Cy)^B)WSdMzTx3??)5dIc+B9Ys%QTr)FU)Gqg|`i}>M>li^~ia&YZbjEaa|g2 z*j_M3KG7%oBRco-<54M*mXKh@Bw#GyboBC6ay*lq0CrT+y6N_m;6 z92-es3rb}#85-dD`XnhODZ%JSO1c`$L=&&4$72&QxDM{Jt!ZbuG&hv#+z{U4EaIyJx^&!uA-FF##2z*PGXFTzluj zvak#KoU10FsN-F<+A=n)rlHxnYGZ3GZD{t(%fg;H+p@54?!dCpk>!uhCke<2NAtY; zS>%sqg$|lsHl&vjn_(FfaE9`YMkWH;DZ)hX00|QT1?FS2bGrp2F?rmQjc|(GWI`o4 zuW1CQ&o+9hERy4oxPjyZE<_9@=ksACY#L$EuCYu;g4HXNkzfpq=n$RjVu-ezM$D2S zDrWH5{cJJSw_uVump@T z(Pkcqmk*e!3d&HWoxmErD*AwkRsQ@Nu-ZsF;6+fpAbV>ID=tJxpE{*ZNJ+a8;BYGr zpdxLL1Oshr2Vz8uI|igyKmlSirqVo0qk#CSGzv%&ErNtvvLgl?? zP1@Rl=!JYz2VNbyPGqyC{Rj>q=th9Dkc51<^bCT-2)+a$~Dlc^9gf2)dR1lC84(Fkie3tmbS)q$%Sz>mECGSF8 zYVIC3%TCY)lzki4TeUqv)^cznE9|9bjNL$bp@LN~CEx<-8qLHuxUFjVCu{VaqZ~mG zwK6E;;Cdsv;2#8#utS9Rr(0e5)Aoqna_z3T^rOjR5eE?#&TH#PnIhn(h!a-s%@?{1 zE4Nqlt!u?APXx4U@2_@+Wv<+;9;|R6UX)yk^p!hM5|^!pCK1pjR*FKzWm>h|>sYni z+TCj3uxeG&RZAMW$OOQ_jqsG1j>*nJgD9w-Oglx}DrgC*l++9W8Hj_@AZA4b$Q?*+073#V3@JIYexgl#fH4Hs zD7_@bMXw+fpya#fYg3sz8 zZGZRqc}KQ!|9y`$PjE)l3vq3=$eUM{K)iYB~ z4kGVpIzUNTOH|Jrid4@`;XH`%h3U}gnnCrf_hcz4t*Uy~>(!XLfvknKQqDau`}>^in%Vu9{ITjdG}}KB{`Q4Q|r0wUjPPuKhe9FA5ew;0_r_ zibjbiPe!TP{vtdX_Ces%%!g*^*@o-HR`Xdfl{iGts>UaMRMuJn>P#zu|KexA0x$`J zairAvq+g-+3c%_CC+Y{|Qz`r}!aUmsw-wK0xhsISrmR5`e*7t)hbI)FJY(c%L=i4F zXivi+s?AIX=!%htHyx9g2a%sQ9g}YZ&Gd)?CY!!z<;64kL!E|C{#bh-_i>Xy(b6EN zzCh&EO3;T0>sk${oT|U2mi&Nz$J*1Z^{mRNdVR}Pp}0P*4#K)thpLh*3Dc&6Ht5%P zMRb+C4_z*nKX0!_p;qp`<7Rye@Dyv!};qeyEDc6DewYZ3hjqDiuRY z6P3XfsumKXC^4ZP(#jz5S%}K%F(N2~{+o7Hpa^eGk9n`HE(P=cPVhd9Ur_`pF#*{-$}~!toDJesFT((CyxA>xnyc*%w~Q_P?ARekB`hSqzRnwoByw zd46k--@3#q46l}-`_vV6N&^ewDhI7F%B!1BS z_ucT74Tk5tmx71MspoRR=SbOei^1cNSP~HCh2wdEhR-5@{H-Ib;I5v#hYf<=2CqL zex*I5AdCQE1f!V%!bsKUAdIm4(e763xlCcCs6Mo-z+eo283Kl&wQDlcRjot7sM6ZdzD)u~00fI) zP{Crx8J4CdN(vV+hTu*QtlsD-_$dxcA;?o}RuiVsq$QzBZ{|Cyh=E*qdzq4ig%t3q z>~QE=NW=^9r7034$SwlHZ%^Pwrw}mfo74{3P_=X!ONraeB&OCQ6-Y<02I&QD4wUOB z41e|d82%Gj`y_&X1mHTy=|X@s1=#}rzC%Vn!NlKd);Q{R2@@TS#fb-L3Aalf6R$#E zD73}B@0t7Eg{}{~Kj>Zv-QJ&V0WZww9~V!kd11b|EbPwm`{$Yn$O-%Ny!u(>_h*IO zG`nm_FCjL=GA8cw`wd3CqnZ&9d@Y;iYnOxr;D?zPj^u+IA^L`SYK3j8>)sf=;B)<{dWU0MZ zYnwwgN==L*7H(Wc{Cp+y^H0W~?1UoRJl%OR!-q4e(b4flDV9d@B*D!TsNt)L6ISfZ zkj@pL_B1Jn3_C@VGSP|xmjb>upPE^C7HRA%8z!h+}JZ}2^^0E-h@*Q*MDZpI~>SvMf$O<8vT{fhb5Sw8c6Lswx5aecTi*a%Vbz8H4`F4!gdi0GOwqlwV+dLp{RQQ`Isw-^vBE%v%HHzC7S zFkOQ_=P=_|4o3o*!tC&`Zqbkuq5!U?HHwGrv{yXZMrr9pr~bN3elj zU_-{9AbneHkX2&|xJI&IOSoJOgaQg5CEGEhQN(aEXUINA;&Cq$zo zNlDI(CnpjUsc2Ms9x6*;MsNkeG=et~{2qcE0F({UC~h82#v!T!s8B9X!5l(U)lt|| z1nQFEWIPj3z!uUd_&o;jutze9Sq`x@f3aEG) zEHG9)30DAfC<>8kr~wvrp+CM(qC)ufJ#Deg(8BhOD1SA zF&(-c^$?s<;{!1{caX`PdNq(DnB2qUtnzGttln>@HrFyfkUrLoF*1%d$a|>Ittue6 zzY+ReLJX>x7r)EaOFf>W2^6zASoEZt>kuY-D|2) z3kN#P|Gyw9(ivRCb|4r)uoD4Mu93xU7iN(Puc2I1qm&SQR1$xpC?$mP?Zw(_tJIK) z=}r<wYtgq}Q72j3#2+B zss!^$e}rTBbp(Hm;7<^I3&Ecuz@d_EBKT7T-vD6LQN4wQ2#ER;9fFvS>I~?pDx6&p zy*6hrD5DV4juE{~Wz>~2l~J3K%rTYlIT9ySQ%4!?E{u2XcOZpZ+d2c=j5$pqb=VfQ zP1&a^qAYslYT;A{QS>t%_>kj~t_TCKY9(Y(p#Km#2nOSjwNj2VVOVg^ItEyb&^O3* zT*MhC2W-%`bub$UScmNR*QH7s+qf4UgS8s2{+)J0tk9w$UuvKjW;d|%O@)yJD*eotD;SPx_Y4BAx+pV>sf5>^UVU%3mn z=kcOjsz&aC2?6G9FwQ8AGXv6B7RedoxsKN zM~?Y(nnn&bVo5&`?TuUm*TiYh&?u}TTe&0zGk=QPM*jM2r{6kU@f4S|g{mLbfI2@R zO}ECUB-oFYiN@0DM}f(y#8iS90w4zWz}g~(;?JZq5FL{GCzK;m)JnLpNfH2vqn&~P zkg0LS4dEx#5b|1a(S{U1wG%}l=E@X=(AErS!z4;4gkk~DPP70h-g85}L+3xCLJF*9 zNAX&Eu%!yPcWDl@I2Ee~9+9lHQn;uw^Os@2QZ*58jN_>^ufF0_t&|Zv3GJ#XsgDBH zH(&b0m$Dn1^MRJ7K+Ak^rEb$V$G$O^uM6esLW^}xvnN;fHP4>BIdEfO$-if%B|LjF z?|&-ie`?9UZ>6~vb6a!%txNvUN_|tlKAfu$zyHj9^TO3^+v&ynGqe2+9UYpfBLi`CP}AVfFmE&ZH?TCEc__d=!H8u8EstcRPT%R> z-CyOrQ|0U5?ulEH*!RD{Bo+uLihrg*iRRdVi^z6&!p!@ri2#u8FV z(yg0$z+rH$&Bz$Txo|UbP^aP2_n=QgHx(!P4#?7bSa1sgQfTRK5Og8fkKh1;ZUhGr zpsw5$)05bWw7{M{c>Zq@lqSt;xK2WU0Z=y-KOw>DkSKdfQC}5Nh8|`7g#vIdDTnZe zfcoA%bK}gCe|G^9E+Dj5vTY|9>-&MwdXUh1A1k2+|Co2}1muJr;5kD-i~NpR*a@H) z&@_geR?F@JhMK#lBQIfV49A#&GYLdS8q8FeZv$msExvQwZj9Rk z5xeY@9k3oUrQE8Ko^8cKAh;5OXkG9_4$O=IkAQjr6|;);jH*IrYWU22FqksZM8$Ml zx3wvW)dMPq0%|FICPstESPGYo?Qd12WrHi9G1{{9m+)9vY7_V2iDm>b1StSB1yPnE zc7>FNQtGh>%PtABY!TfN;xf{36OdRtx}zRs8GXE04YCXy+_WIe>t2wxwRiw;su#gA z1PKHq2ylxRbql-(se;X3T~>=SQO;58ytVF&N-e)OJh>XtuNh^Y3sd{{cY>1?FEM4uUK2FJllhRe{Mn zJ3g*;I~yO?*qmpH{_?DfOB#j)E{|+S zgr6OYNn?s<_?6hiXbR$#C|=qD7E@PPqJqZ((bak9#nAls{5F_Ut?YVu%^Ry d!uCrJ;lo#K0b4I*5I$_!U^{Djz#$};`hQiMu-5

    _I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT#i_}q wMVTd)`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00ucNL;wH) literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5ee7e6ba008d15324d96db2b61ec8b58fcd6ddb4 GIT binary patch literal 26650 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�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 I09aH07b%AX@Bjb+ literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c8e62e46fa6dd272f7fbb646595ac2e07ba50527 GIT binary patch literal 17437 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 literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97e567f8e62bef1a5f9c5df8c6b2bbf584615f9c GIT binary patch literal 50570 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

    7y`na3`>L2>&E@&`rik{hENQE|f?K&EMJEp&tD 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 TKmoY9__;t}^9_X{Yta7(Oq&Y} literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..699211ebf2a529fcc7a73342e4175d70910d1a41 GIT binary patch literal 80328 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≶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 XktTTOBs!!F!8@B90dI#0va$X@Una^J literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..720abb7878dcc3960dcc400a12abfef48a3800c8 GIT binary patch literal 54552 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 literal 0 HcmV?d00001 diff --git a/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc b/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..872784e1a326ccdffd670031e9adbb08bd52d40c GIT binary patch literal 17202 zcmeHPUu+!5dEdRid*qQvQj#gzl1N%IqqAt9C{hw-ORi)~D(TcJwV{tqz-oKETasrV z?~b!eDWuUrK+~2%j5LYSS_oV;EfT;gP{4U=`qHNWeWgTN5*9A{ln3|8kseeCedzC- zy`9}V?(vS2lE{SyyzgdazxlH>yTAE;-^}rFds~Zy@U#DY@7*iyl9Z){6GD~9{f`m3 zCS8_Pd0aAO{v8+#E(Lm|v5*<=p(}y$$XL{j@_2Au8H<@Q9uJMTjJ2ArJRTm8kF}X? zJRTWuA4`~tu@1ANN7^OzNourPQk9W_-KN>e^J2(r5qVuauN8T5k+(yY4@k+jFR0kX zq-@2^{DPj-3uXohg%T^JqK?=rAlVta+?`^ zPAXvlyCpNE1|Y*~5Hg~M%xGFN6*Y|8h*%wyx?xT*{VZC2$IKUutWzgd-HK08l9c;D zz%X5trp2`px{6Sir*Yj5X?dh_y&SlFX7Ok`I65Xti|4*pxqe=r+b2nTrDsszu1vlp zIX!p!ntP$9JRa5B;8*@_^ivJ0p>6fGc2s=(>W;4HV|O!sbjzV{AvNq7ecyXZ2b{4` zBMo(fj&3S6;F-&ESdFSmI-HWLawF57&KP=X?sD?n?>;FVacXwH=Vdkanp_UNCbKuy z7@pnu7iUny3YM3TSiu6`7Q}|Eu>IyBK1>1CEJEtK(QTX9_0W|r~=GhZ~UNTCD`!`iJG^QEOij;m^Oh0;u>pcOLme>4(Qe3QA7uwp$}Azpe@$Zv*#zUa5P zyp6Ojf=~FCbI}jj7nuI4+*i2aeF>DVLEexWQKRWl4f2Mk2_ty$)ZF3Zx!*wEurou> zH;`8W^2$KoqpAYrZG9N%JI!dkITjM69wjlt2;Uh2I@0W2F{)#Fg@zvEFvD9 zC4lV{0Ba*LkhY>R0wjh;GG^oMb5uDNK8funv4_O-B(PvhY%d8cr(ia0Kfb)!H^h2t za)#N9$YBQ{40K+(J$h+P~1u0_R~iu>aWswlB7T2HYaotLDP)h(JFk}g$$by zhB+TZ(hd{i{dID@I02RjeLGwZtMUoK6W#Z^K@Gvn;c{p?>eP}BxwR}_N{0uW3;14~ z1s=W(ZW=`y#ZyM{l=(0nt19zP=@u%p_^$$&fMa{5bgPp-ADBDEF;yUkDj2uZ%bd*#-E#iyN|XbNf~|rTZl`ZH zw|3j`?VF3W4vvUE+T7YL@7u@C<5$W&8f~a6uufIf*tW)?x%m+&E$+R! z`4Q!C&oQ+{ZT&6uO>p;Z_Dz&S-&)o9SLoSkYxq1U;@N3$s2g*1Q?V*5C$1*cj&!_c z1#X)r?Bu~yGp%Md0V{BuQ?v7p6}S^Ca0s6;J`sGP__X4Km6`oS?Zm3Q^9#aQcp0J# z`Qj3YGGC#s$czrUuQP%iMkvkbb*1I{7>Scs#P%y#Ay_*{tZ-)5)EQ3CIb%xr%toe)O29w95!U7cQ z)U<2?k7O>NHM2Tg71|BiYV%~G81b?*{IOy&s*ePDIt(J#|MS;)JnjVo;of_S9KIkw zh=jsZ@|O}xehXYZ3r!ImR(K!Os@gxfx8k1!kL#8SGWjUYF#M7cH3%#VV<1!=6x%Sf z^a4A9>f`*A$o(~Gs-x&!k*0c!)O#LO>40=Y1~yrtB{+WoRT*zD zm`hAIQt*7_ujr{{l)VK7_H`0}MB;4{e?sCSi9aSW0pXp2Z%`%)8hnEcI6eapFavw; zNf7p+lSb8`JHv3FDy(t}zgXWX4A*c}4MQqw1Tse6akE8LAY0X#8OPMLsV!vuS=|IH zKA!BVnIf>|Gu44PyC$XO38!1Q-~?6pUCTk&j|^XLoV=(hd4y&N!Sy(#gGJ|=l3Zr5 zhTx(PJ66R6J+(C|FxhAl9!QE=OTbF>Ur3$FFYvea) z;0nO00V=nL4FIl5qrN^+(MHo|plaUO@&l?M2Pz^WtsJPD$0L@(M0oIs(aLO%Z~2Z8 zMbWP2P3#JpT9)q>*99edZJADj2As`8*eJyKt4-?<%ah=^^De~-5U_(5D9*VLK?^I9 zXcA6t*l@ziq6$W_YA|62<(UwE;L*duqyC**CmwN#lGNb$AT|NY-R>8jHt-naTxe^b z=LAhpO-Kh+?BoMDDtxGi_>>QLs4mM10)S}o;$fQh@p-6WO2qAJLKOhjV@eGej~eGp zsjUith$#s$nDFH<^w3h^JNPyLxh>JdS`)mCrv`xBa{l~809S=6HPWvSK>kdLwj+4( zutVbUHK8F>YQHdfBgo%ds|7qsFJ@*6dd_M?+=dF+8M!9{Qs=jP19N$Ux7-MzhH-=Y(|Ciqklu0s$o3O0O}b);hCBEiUZW-1@;}>#uLGyx3+xwmWnRqIY?NS zDZNU4+CEcae?jHyAxAGqjxG9OjXvrf97nr#F=TS>($kJ1WHExCDEI#z1O`V1k+g%~ z#%^7b1Dey2si`p*uvoBI#$rfW1WUD$Y+ zu>qg=xapqed5L={qW`>z!;orALtS@UGFau(Q`9!KJ+0KZ^jfEzpTkzimFIk8Te7kp z$!=gCdut{UNz7y7G3RmTvw8eo&Lck-zH9P#wWPy%$ul}ys$HGei`voI4EJ>+FSpqv zdlpYr(@T1O5S}{rGK#Etj{u*rj{u)ov3F|duzd=xrmpBLKf9d57ET0y*<#7Wp|$5c zo?7HM-daYsD=rVyW&bm6gR}t$-*UE;)7|64@CT#V;Vp_Su|l2f?J5dw^_#y>m%h?AiSRAQNA-KoW*b%+x}vkD{V2s4Y<-4B~POZ#*Z++ ze)bl)5;mY=Z>6_kZ|&<_Q~It?t|hQ}J?;YNnER3mTQwZC81N*C&*`5nS5BbN*P)uiqap+`LWL zh<~(lUKoGI3nj_JR+@KyabC#Rp*DLRTIt}s>Kv=H^LqBZ)Z;qjx3)9&Z|e(XoRPty z0Yd}uV@a}CDZfSOEyBd#j-9zhKN?`?79}qbcK#fqN$fnl-Ziiq{r~2xdWI)>0;-~13y#=hnV0av%|FTPSMYEJzgt1?^P43w&rZF)n;}gd{q-- zv(2prX9Mc76R{yT;k(i1vo+_oUb}`xR zdv0`zCis04e?{W^B>tAfPe}X&2_kq~94#g9JR|WUKE_dq%HiHnc>ldP7h%NOxrm03 z-Rp9a585N){a;ED_6ZcKB)QX4oS!-KpXFJ5H2a6qgZ2@4{8)LA=~wZyVEo8gWYMuS zp2yFjSV|82z4ZSR_!)gGo_jY_oGT43(LW`yPw_wR{``2*`I+$`4oT}!2%#TV(oezt z3G$%(6J+~*-N6R>cH0qDZU1;v1cvrmzPcS15=gZMHi z%kqCo(s!lLr2W5^`hG1P|4(WBK_Dmx9>nAFl>8fs-{3 D_uA>a literal 0 HcmV?d00001 diff --git a/tests/test_security/test_altcha.py b/tests/test_security/test_altcha.py new file mode 100644 index 0000000..5e2aab5 --- /dev/null +++ b/tests/test_security/test_altcha.py @@ -0,0 +1,121 @@ +import hashlib +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework import status + +from infrasynth.security.altcha.services import ALTCHAService +from infrasynth.security.models import ALTCHAChallenge + + +@pytest.fixture +def altcha_service(db): + return ALTCHAService() + + +def compute_solution(salt: str, number: int) -> str: + return hashlib.sha256(f"{salt}{number}".encode()).hexdigest() + + +class TestChallengeCreation: + def test_create_challenge_persists(self, altcha_service): + challenge = altcha_service.create_challenge() + assert challenge["algorithm"] == "SHA-256" + assert challenge["difficulty"] == 10000 + assert ALTCHAChallenge.objects.filter(challenge_id=challenge["challenge_id"]).exists() + + def test_create_challenge_fields(self, altcha_service): + challenge = altcha_service.create_challenge() + assert challenge["salt"] + assert challenge["challenge_id"] + assert challenge["difficulty"] > 0 + + def test_challenge_has_expiry(self, altcha_service): + altcha_service.create_challenge() + saved = ALTCHAChallenge.objects.get() + assert saved.expires_at > timezone.now() + + +class TestVerification: + def test_verify_correct_solution(self, altcha_service): + challenge = altcha_service.create_challenge() + number = 5 + solution = compute_solution(challenge["salt"], number) + assert altcha_service.verify(challenge["challenge_id"], solution, number) is True + + def test_verify_marks_used(self, altcha_service): + challenge = altcha_service.create_challenge() + solution = compute_solution(challenge["salt"], 5) + assert altcha_service.verify(challenge["challenge_id"], solution, 5) is True + saved = ALTCHAChallenge.objects.get(challenge_id=challenge["challenge_id"]) + assert saved.is_verified is True + + def test_replay_rejected(self, altcha_service): + challenge = altcha_service.create_challenge() + solution = compute_solution(challenge["salt"], 5) + assert altcha_service.verify(challenge["challenge_id"], solution, 5) is True + assert altcha_service.verify(challenge["challenge_id"], solution, 5) is False + + def test_wrong_solution_rejected(self, altcha_service): + challenge = altcha_service.create_challenge() + wrong = hashlib.sha256(b"nope").hexdigest() + assert altcha_service.verify(challenge["challenge_id"], wrong, 5) is False + + def test_unknown_challenge_rejected(self, altcha_service): + assert altcha_service.verify("unknown-id", "abc", 5) is False + + def test_expired_challenge_rejected(self, altcha_service): + challenge = altcha_service.create_challenge() + ALTCHAChallenge.objects.filter(challenge_id=challenge["challenge_id"]).update( + expires_at=timezone.now() - timedelta(minutes=10) + ) + solution = compute_solution(challenge["salt"], 5) + assert altcha_service.verify(challenge["challenge_id"], solution, 5) is False + + +class TestComputeSolution: + def test_compute_solution_matches_verify(self, altcha_service): + challenge = altcha_service.create_challenge() + solution, number = altcha_service.compute_solution(challenge["salt"], challenge["difficulty"]) + assert altcha_service.verify(challenge["challenge_id"], solution, number) is True + + +class TestALTCHAViews: + def test_challenge_endpoint(self, api_client): + resp = api_client.post("/api/auth/altcha/challenge/") + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert "challenge_id" in data + assert "salt" in data + assert "difficulty" in data + + def test_verify_endpoint_correct(self, api_client): + svc = ALTCHAService() + challenge = svc.create_challenge() + solution = compute_solution(challenge["salt"], 3) + resp = api_client.post( + "/api/auth/altcha/verify/", + { + "challenge_id": challenge["challenge_id"], + "solution": solution, + "number": 3, + }, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.json() == {"verified": True} + + def test_verify_endpoint_wrong(self, api_client): + svc = ALTCHAService() + challenge = svc.create_challenge() + resp = api_client.post( + "/api/auth/altcha/verify/", + { + "challenge_id": challenge["challenge_id"], + "solution": "deadbeef", + "number": 1, + }, + format="json", + ) + assert resp.json() == {"verified": False} diff --git a/tests/test_security/test_api_keys.py b/tests/test_security/test_api_keys.py new file mode 100644 index 0000000..e18c85a --- /dev/null +++ b/tests/test_security/test_api_keys.py @@ -0,0 +1,91 @@ +from django.contrib.auth.hashers import make_password +from rest_framework import status + +from infrasynth.security.auth.middleware import JWTAuthenticationMiddleware +from infrasynth.security.models import APIKey + + +class TestAPIKeyViewSet: + def test_create_returns_full_key_once(self, authenticated_client): + resp = authenticated_client.post( + "/api/auth/api-keys/", + {"name": "CI Key", "scopes": ["read:users"]}, + format="json", + ) + assert resp.status_code == status.HTTP_201_CREATED + data = resp.json() + assert "key" in data + assert "prefix" in data + assert data["key"].startswith(data["prefix"] + ".") + + def test_created_key_authenticates(self, authenticated_client): + resp = authenticated_client.post( + "/api/auth/api-keys/", + {"name": "CI Key", "scopes": ["read:users"]}, + format="json", + ) + full_key = resp.json()["key"] + from rest_framework.test import APIClient + + client = APIClient() + client.credentials(HTTP_X_API_KEY=full_key) + check = client.get("/api/auth/check/") + assert check.status_code == status.HTTP_200_OK + + def test_list_keys(self, authenticated_client, user): + APIKey.objects.create( + name="Old Key", + prefix="prefix01", + key_hash=make_password("secret"), + created_by=user, + ) + resp = authenticated_client.get("/api/auth/api-keys/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + + def test_list_never_exposes_hash(self, authenticated_client, user): + APIKey.objects.create( + name="Old Key", + prefix="prefix01", + key_hash=make_password("secret"), + created_by=user, + ) + resp = authenticated_client.get("/api/auth/api-keys/") + result = resp.json()["results"][0] + assert "key" not in result + assert "key_hash" not in result + assert result["prefix"] == "prefix01" + + def test_delete_key(self, authenticated_client, user): + key = APIKey.objects.create( + name="Old Key", + prefix="prefix01", + key_hash=make_password("secret"), + created_by=user, + ) + resp = authenticated_client.delete(f"/api/auth/api-keys/{key.pk}/") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not APIKey.objects.filter(pk=key.pk).exists() + + def test_requires_auth(self, api_client): + resp = api_client.get("/api/auth/api-keys/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestJWTAuthenticationMiddleware: + def test_ignores_anonymous_requests(self, rf, db): + request = rf.get("/api/features/active/") + request.user = type("Anon", (), {"is_anonymous": True})() + JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request) + assert request.user.is_anonymous + + def test_populates_user_from_cookie(self, rf, user): + from rest_framework_simplejwt.tokens import RefreshToken + + from infrasynth.shared.crypto import encrypt + + request = rf.get("/api/features/active/") + request.COOKIES["access_token"] = encrypt(str(RefreshToken.for_user(user).access_token)) + request.user = type("Anon", (), {"is_anonymous": True})() + JWTAuthenticationMiddleware(get_response=lambda r: None).process_request(request) + assert request.user == user diff --git a/tests/test_security/test_auth.py b/tests/test_security/test_auth.py new file mode 100644 index 0000000..053a547 --- /dev/null +++ b/tests/test_security/test_auth.py @@ -0,0 +1,275 @@ +from datetime import timedelta + +import pytest +from django.contrib.auth import get_user_model +from django.contrib.auth.hashers import make_password +from django.utils import timezone +from rest_framework import status +from rest_framework.test import APIClient, APIRequestFactory + +from infrasynth.security.auth.api_keys import APIKeyAuthentication, SystemUser +from infrasynth.security.auth.backends import EmailOrUsernameBackend +from infrasynth.security.auth.cookies import CookieJWTAuthentication +from infrasynth.security.models import APIKey +from infrasynth.shared.crypto import encrypt + +UserModel = get_user_model() + + +class TestEmailOrUsernameBackend: + def test_authenticates_by_username(self, user): + backend = EmailOrUsernameBackend() + result = backend.authenticate(None, username="testuser", password="testpass123") + assert result == user + + def test_authenticates_by_email(self, user): + backend = EmailOrUsernameBackend() + result = backend.authenticate(None, username="test@example.com", password="testpass123") + assert result == user + + def test_wrong_password_returns_none(self, user): + backend = EmailOrUsernameBackend() + assert backend.authenticate(None, username="testuser", password="wrong") is None + + def test_unknown_user_returns_none(self, user): + backend = EmailOrUsernameBackend() + assert backend.authenticate(None, username="ghost", password="testpass123") is None + + def test_inactive_user_returns_none(self, db): + UserModel.objects.create_user( + username="inactive", email="inactive@example.com", password="testpass123", is_active=False + ) + backend = EmailOrUsernameBackend() + assert backend.authenticate(None, username="inactive", password="testpass123") is None + + def test_missing_credentials_returns_none(self, user): + backend = EmailOrUsernameBackend() + assert backend.authenticate(None, username=None, password=None) is None + + +class TestCookieJWTAuthentication: + def test_authenticates_with_valid_cookie(self, user): + from rest_framework_simplejwt.tokens import RefreshToken + + token = str(RefreshToken.for_user(user).access_token) + encrypted = encrypt(token) + request = APIRequestFactory().get("/") + request.COOKIES["access_token"] = encrypted + + auth = CookieJWTAuthentication() + authenticated_user, validated = auth.authenticate(request) + assert authenticated_user == user + assert validated + + def test_no_cookie_returns_none(self, user): + request = APIRequestFactory().get("/") + auth = CookieJWTAuthentication() + assert auth.authenticate(request) is None + + def test_invalid_cookie_raises(self, user): + from rest_framework.exceptions import AuthenticationFailed + + request = APIRequestFactory().get("/") + request.COOKIES["access_token"] = "not-a-valid-token" + auth = CookieJWTAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + +class TestAPIKeyAuthentication: + @pytest.fixture + def api_key(self, db, user): + secret = "s3cr3t-api-secret" + prefix = "testkey1" + return APIKey.objects.create( + name="Test Key", + prefix=prefix, + key_hash=make_password(secret), + scopes=["read:users", "write:billing"], + created_by=user, + ) + + def test_authenticates_with_valid_key(self, api_key): + request = APIRequestFactory().get("/", HTTP_X_API_KEY="testkey1.s3cr3t-api-secret") + auth = APIKeyAuthentication() + auth_user, key = auth.authenticate(request) + assert isinstance(auth_user, SystemUser) + assert auth_user.scopes == ["read:users", "write:billing"] + assert key == api_key + + def test_no_header_returns_none(self, api_key): + request = APIRequestFactory().get("/") + auth = APIKeyAuthentication() + assert auth.authenticate(request) is None + + def test_invalid_format_raises(self, api_key): + from rest_framework.exceptions import AuthenticationFailed + + request = APIRequestFactory().get("/", HTTP_X_API_KEY="no-dot-here") + auth = APIKeyAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + def test_unknown_prefix_raises(self, api_key): + from rest_framework.exceptions import AuthenticationFailed + + request = APIRequestFactory().get("/", HTTP_X_API_KEY="unknownkey.secret") + auth = APIKeyAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + def test_wrong_secret_raises(self, api_key): + from rest_framework.exceptions import AuthenticationFailed + + request = APIRequestFactory().get("/", HTTP_X_API_KEY="testkey1.wrongsecret") + auth = APIKeyAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + def test_inactive_key_raises(self, api_key): + from rest_framework.exceptions import AuthenticationFailed + + api_key.is_active = False + api_key.save() + request = APIRequestFactory().get("/", HTTP_X_API_KEY="testkey1.s3cr3t-api-secret") + auth = APIKeyAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + def test_expired_key_raises(self, api_key): + from rest_framework.exceptions import AuthenticationFailed + + api_key.expires_at = timezone.now() - timedelta(days=1) + api_key.save() + request = APIRequestFactory().get("/", HTTP_X_API_KEY="testkey1.s3cr3t-api-secret") + auth = APIKeyAuthentication() + with pytest.raises(AuthenticationFailed): + auth.authenticate(request) + + def test_updates_last_used_at(self, api_key): + request = APIRequestFactory().get("/", HTTP_X_API_KEY="testkey1.s3cr3t-api-secret") + auth = APIKeyAuthentication() + auth.authenticate(request) + api_key.refresh_from_db() + assert api_key.last_used_at is not None + + +class TestSystemUser: + def test_scopes_and_auth_flags(self): + sys_user = SystemUser(scopes=["read:x"]) + assert sys_user.is_authenticated is True + assert sys_user.is_anonymous is False + assert sys_user.is_superuser is False + assert sys_user.scopes == ["read:x"] + + def test_default_scopes(self): + assert SystemUser().scopes == [] + + +class TestAuthFlow: + def test_login_sets_cookies(self, db, user): + client = APIClient() + resp = client.post( + "/api/auth/login/", + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies + assert "refresh_token" in resp.cookies + + def test_login_by_email(self, db, user): + client = APIClient() + resp = client.post( + "/api/auth/login/", + {"username": "test@example.com", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + + def test_login_invalid_credentials(self, db, user): + client = APIClient() + resp = client.post( + "/api/auth/login/", + {"username": "testuser", "password": "wrongpass"}, + format="json", + ) + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_login_inactive_user(self, db): + UserModel.objects.create_user( + username="banned", email="banned@example.com", password="testpass123", is_active=False + ) + client = APIClient() + resp = client.post("/api/auth/login/", {"username": "banned", "password": "testpass123"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_check_authenticated(self, authenticated_client, user): + resp = authenticated_client.get("/api/auth/check/") + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert data["id"] == user.pk + assert data["email"] == user.email + assert "effective_permissions" in data + + def test_check_superuser_has_wildcard(self, admin_client, admin_user): + resp = admin_client.get("/api/auth/check/") + data = resp.json() + assert data["effective_permissions"] == ["*"] + + def test_check_unauthenticated(self, api_client): + resp = api_client.get("/api/auth/check/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + assert resp.json() == {"authenticated": False} + + def test_refresh_with_valid_cookie(self, db, user): + from rest_framework_simplejwt.tokens import RefreshToken + + refresh = str(RefreshToken.for_user(user)) + client = APIClient() + client.cookies["refresh_token"] = encrypt(refresh) + resp = client.post("/api/auth/refresh/") + assert resp.status_code == status.HTTP_200_OK + assert "access_token" in resp.cookies + + def test_refresh_without_cookie(self, api_client): + resp = api_client.post("/api/auth/refresh/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_invalid_cookie(self, api_client): + api_client.cookies["refresh_token"] = "garbage-token" + resp = api_client.post("/api/auth/refresh/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_logout_clears_cookies(self, authenticated_client): + resp = authenticated_client.post("/api/auth/logout/") + assert resp.status_code == status.HTTP_200_OK + assert resp.cookies["access_token"].value == "" + assert resp.cookies["refresh_token"].value == "" + + @pytest.mark.parametrize("crypto_key", [None, ""]) + def test_login_falls_back_to_plaintext_tokens(self, db, user, settings, crypto_key): + settings.INFRASYNTH_SECURITY = { + **settings.INFRASYNTH_SECURITY, + "CRYPTO_KEY": crypto_key, + } + client = APIClient() + resp = client.post( + "/api/auth/login/", + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + assert resp.cookies["access_token"].value + + def test_full_login_roundtrip(self, db, user): + client = APIClient() + resp = client.post( + "/api/auth/login/", + {"username": "testuser", "password": "testpass123"}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + check = client.get("/api/auth/check/") + assert check.status_code == status.HTTP_200_OK + assert check.json()["id"] == user.pk diff --git a/tests/test_security/test_authorization.py b/tests/test_security/test_authorization.py new file mode 100644 index 0000000..e913aa9 --- /dev/null +++ b/tests/test_security/test_authorization.py @@ -0,0 +1,297 @@ +from datetime import timedelta + +import pytest +from django.utils import timezone +from rest_framework import status + +from infrasynth.security.models import Grant, Revoke, Role +from infrasynth.security.permissions import HybridPermission, require_permission +from infrasynth.security.services import AuthorizationService + + +@pytest.fixture +def authz(): + return AuthorizationService() + + +@pytest.fixture +def role(db): + return Role.objects.create(name="Editor", slug="editor", permissions=["content.edit", "content.view"]) + + +class TestPermissionResolutionChain: + def test_unauthenticated_denied(self, authz, db): + anon = type("Anon", (), {"is_authenticated": False})() + assert authz.has_permission(anon, "content.edit") is False + + def test_no_permission_default_denied(self, authz, user): + assert authz.has_permission(user, "content.edit") is False + + def test_superuser_always_allowed(self, authz, admin_user): + assert authz.has_permission(admin_user, "anything.at.all") is True + + def test_role_permission_granted(self, authz, user, role): + role.users.add(user) + assert authz.has_permission(user, "content.edit") is True + + def test_direct_grant_granted(self, authz, user): + Grant.objects.create(user=user, codename="billing.view") + assert authz.has_permission(user, "billing.view") is True + + def test_expired_grant_denied(self, authz, user): + Grant.objects.create( + user=user, + codename="billing.view", + expires_at=timezone.now() - timedelta(days=1), + ) + assert authz.has_permission(user, "billing.view") is False + + def test_future_grant_allowed(self, authz, user): + Grant.objects.create( + user=user, + codename="billing.view", + expires_at=timezone.now() + timedelta(days=1), + ) + assert authz.has_permission(user, "billing.view") is True + + def test_revoke_beats_grant(self, authz, user): + Grant.objects.create(user=user, codename="content.edit") + Revoke.objects.create(user=user, codename="content.edit") + assert authz.has_permission(user, "content.edit") is False + + def test_revoke_beats_role(self, authz, user, role): + role.users.add(user) + Revoke.objects.create(user=user, codename="content.edit") + assert authz.has_permission(user, "content.edit") is False + + def test_revoke_only_affects_target_permission(self, authz, user, role): + role.users.add(user) + Revoke.objects.create(user=user, codename="content.edit") + assert authz.has_permission(user, "content.view") is True + + def test_union_of_multiple_roles(self, authz, user): + Role.objects.create(slug="r1", name="R1", permissions=["perm.a"]).users.add(user) + Role.objects.create(slug="r2", name="R2", permissions=["perm.b"]).users.add(user) + assert authz.has_permission(user, "perm.a") is True + assert authz.has_permission(user, "perm.b") is True + + def test_superuser_revoke_ignored(self, authz, admin_user): + Revoke.objects.create(user=admin_user, codename="anything") + assert authz.has_permission(admin_user, "anything") is True + + +class TestEffectivePermissions: + def test_superuser_returns_wildcard(self, authz, admin_user): + assert authz.get_effective_permissions(admin_user) == {"*"} + + def test_unauthenticated_empty(self, authz, db): + anon = type("Anon", (), {"is_authenticated": False})() + assert authz.get_effective_permissions(anon) == set() + + def test_merges_grant_and_role(self, authz, user, role): + role.permissions = ["role.perm"] + role.save() + role.users.add(user) + Grant.objects.create(user=user, codename="grant.perm") + perms = authz.get_effective_permissions(user) + assert perms == {"role.perm", "grant.perm"} + + def test_revoked_permissions_removed(self, authz, user, role): + role.users.add(user) + Revoke.objects.create(user=user, codename="content.edit") + perms = authz.get_effective_permissions(user) + assert "content.edit" not in perms + assert "content.view" in perms + + def test_empty_role_permissions(self, authz, user): + Role.objects.create(slug="empty", name="Empty", permissions=[]).users.add(user) + assert authz.get_effective_permissions(user) == set() + + +class TestAggregateChecks: + def test_has_all_permissions(self, authz, user, role): + role.permissions = ["perm.a", "perm.b"] + role.save() + role.users.add(user) + assert authz.has_all_permissions(user, ["perm.a", "perm.b"]) is True + assert authz.has_all_permissions(user, ["perm.a", "perm.c"]) is False + + def test_has_any_permission(self, authz, user, role): + role.permissions = ["perm.a"] + role.save() + role.users.add(user) + assert authz.has_any_permission(user, ["perm.a", "perm.b"]) is True + assert authz.has_any_permission(user, ["perm.b", "perm.c"]) is False + + +class TestSystemUserPermissions: + @pytest.fixture + def system_user(self): + from infrasynth.security.auth.api_keys import SystemUser + + return SystemUser(scopes=["read:users", "write:billing"]) + + def test_scope_permission_granted(self, authz, system_user): + assert authz.has_permission(system_user, "read:users") is True + assert authz.has_permission(system_user, "write:billing") is True + + def test_unknown_scope_denied(self, authz, system_user): + assert authz.has_permission(system_user, "delete:users") is False + + def test_effective_permissions_are_scopes(self, authz, system_user): + assert authz.get_effective_permissions(system_user) == { + "read:users", + "write:billing", + } + + def test_empty_scopes_deny_all(self, authz, db): + from infrasynth.security.auth.api_keys import SystemUser + + sys_user = SystemUser() + assert authz.has_permission(sys_user, "anything") is False + assert authz.get_effective_permissions(sys_user) == set() + + +class _PermissionView: + def __init__(self, required_permissions=None): + self.required_permissions = required_permissions + + +class TestHybridPermission: + def test_no_required_permissions_allows(self, user): + request = type("R", (), {"user": user})() + perm = HybridPermission() + assert perm.has_permission(request, _PermissionView(None)) is True + + def test_any_permission_sufficient(self, user): + Grant.objects.create(user=user, codename="one.of.these") + request = type("R", (), {"user": user})() + perm = HybridPermission() + view = _PermissionView(["one.of.these", "other.perm"]) + assert perm.has_permission(request, view) is True + + def test_no_permission_denied(self, user): + request = type("R", (), {"user": user})() + perm = HybridPermission() + view = _PermissionView(["missing.perm"]) + assert perm.has_permission(request, view) is False + + def test_unauthenticated_denied(self, db): + anon = type("R", (), {"user": type("U", (), {"is_authenticated": False})()})() + perm = HybridPermission() + assert perm.has_permission(anon, _PermissionView(None)) is False + + +class TestRequirePermission: + def test_all_permissions_required(self, user): + Grant.objects.create(user=user, codename="perm.a") + Grant.objects.create(user=user, codename="perm.b") + request = type("R", (), {"user": user})() + perm_class = require_permission("perm.a", "perm.b") + assert perm_class().has_permission(request, _PermissionView()) is True + + def test_missing_any_denied(self, user): + Grant.objects.create(user=user, codename="perm.a") + request = type("R", (), {"user": user})() + perm_class = require_permission("perm.a", "perm.b") + assert perm_class().has_permission(request, _PermissionView()) is False + + +class TestRoleViewSet: + def test_list_roles(self, authenticated_client, role): + resp = authenticated_client.get("/api/auth/roles/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + + def test_create_role(self, authenticated_client): + resp = authenticated_client.post( + "/api/auth/roles/", + {"name": "Viewer", "slug": "viewer", "permissions": ["content.view"]}, + format="json", + ) + assert resp.status_code == status.HTTP_201_CREATED + assert Role.objects.filter(slug="viewer").exists() + + def test_retrieve_role(self, authenticated_client, role): + resp = authenticated_client.get(f"/api/auth/roles/{role.slug}/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["permissions"] == ["content.edit", "content.view"] + + def test_update_role(self, authenticated_client, role): + resp = authenticated_client.put( + f"/api/auth/roles/{role.slug}/", + {"name": "Editor2", "slug": "editor", "permissions": ["content.edit"]}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + role.refresh_from_db() + assert role.name == "Editor2" + + def test_delete_role(self, authenticated_client, role): + resp = authenticated_client.delete(f"/api/auth/roles/{role.slug}/") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Role.objects.filter(pk=role.pk).exists() + + def test_system_role_cannot_be_deleted(self, authenticated_client, db): + system_role = Role.objects.create(slug="sys", name="Sys", permissions=[], is_system=True) + resp = authenticated_client.delete(f"/api/auth/roles/{system_role.slug}/") + assert resp.status_code in ( + status.HTTP_400_BAD_REQUEST, + status.HTTP_403_FORBIDDEN, + ) + assert Role.objects.filter(pk=system_role.pk).exists() + + def test_requires_auth(self, api_client): + resp = api_client.get("/api/auth/roles/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestGrantViewSet: + def test_create_grant(self, authenticated_client, user): + resp = authenticated_client.post( + "/api/auth/grants/", + {"user": user.pk, "codename": "billing.view", "reason": "temporary"}, + format="json", + ) + assert resp.status_code == status.HTTP_201_CREATED + grant = Grant.objects.get(codename="billing.view") + assert grant.granted_by == user + + def test_list_grants(self, authenticated_client, user): + Grant.objects.create(user=user, codename="a.perm") + resp = authenticated_client.get("/api/auth/grants/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + + def test_delete_grant(self, authenticated_client, user): + grant = Grant.objects.create(user=user, codename="a.perm") + resp = authenticated_client.delete(f"/api/auth/grants/{grant.pk}/") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Grant.objects.filter(pk=grant.pk).exists() + + def test_grant_requires_auth(self, api_client): + resp = api_client.get("/api/auth/grants/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestRevokeViewSet: + def test_create_revoke(self, authenticated_client, user): + resp = authenticated_client.post( + "/api/auth/revokes/", + {"user": user.pk, "codename": "billing.view", "reason": "audit"}, + format="json", + ) + assert resp.status_code == status.HTTP_201_CREATED + assert Revoke.objects.filter(codename="billing.view").exists() + + def test_list_revokes(self, authenticated_client, user): + Revoke.objects.create(user=user, codename="a.perm") + resp = authenticated_client.get("/api/auth/revokes/") + assert resp.status_code == status.HTTP_200_OK + assert resp.json()["count"] == 1 + + def test_delete_revoke(self, authenticated_client, user): + revoke = Revoke.objects.create(user=user, codename="a.perm") + resp = authenticated_client.delete(f"/api/auth/revokes/{revoke.pk}/") + assert resp.status_code == status.HTTP_204_NO_CONTENT + assert not Revoke.objects.filter(pk=revoke.pk).exists() diff --git a/tests/test_security/test_two_factor.py b/tests/test_security/test_two_factor.py new file mode 100644 index 0000000..a14088b --- /dev/null +++ b/tests/test_security/test_two_factor.py @@ -0,0 +1,243 @@ +import json + +import pyotp +import pytest +from rest_framework import status +from rest_framework.test import APIClient + +from infrasynth.security.models import TwoFactorConfig +from infrasynth.security.two_factor.services import RecoveryCodeService, TOTPService + + +@pytest.fixture +def totp_service(): + return TOTPService() + + +@pytest.fixture +def recovery_service(): + return RecoveryCodeService() + + +class TestTOTPService: + def test_generate_secret_is_base32(self, totp_service): + secret = totp_service.generate_secret() + assert len(secret) >= 16 + pyotp.TOTP(secret).now() + + def test_generate_secret_unique(self, totp_service): + assert totp_service.generate_secret() != totp_service.generate_secret() + + def test_provisioning_uri_contains_issuer_and_email(self, totp_service): + uri = totp_service.get_provisioning_uri("SECRETBASE32", "user@example.com") + assert "user%40example.com" in uri + assert "InfraSynth" in uri + + def test_qr_base64_is_valid_png(self, totp_service): + import base64 + + qr = totp_service.generate_qr_base64("SECRETBASE32", "user@example.com") + raw = base64.b64decode(qr) + assert raw[:8] == b"\x89PNG\r\n\x1a\n" + + def test_verify_correct_code(self, totp_service): + secret = totp_service.generate_secret() + code = pyotp.TOTP(secret).now() + assert totp_service.verify(secret, code) is True + + def test_verify_wrong_code(self, totp_service): + secret = totp_service.generate_secret() + assert totp_service.verify(secret, "000000") is False + + def test_encrypt_decrypt_roundtrip(self, totp_service): + secret = "MYTESTSECRET" + encrypted = totp_service.encrypt_secret(secret) + assert encrypted != secret + assert totp_service.decrypt_secret(encrypted) == secret + + +class TestRecoveryCodeService: + def test_generate_codes_count(self, recovery_service): + codes = recovery_service.generate_codes() + assert len(codes) == 8 + assert len(set(codes)) == 8 + + def test_codes_have_prefix(self, recovery_service): + codes = recovery_service.generate_codes() + assert all(c.startswith("RC-") for c in codes) + + def test_encrypt_decrypt_roundtrip(self, recovery_service): + codes = ["RC-AAA-BBB"] + encrypted = recovery_service.encrypt_codes(codes) + assert encrypted != json.dumps(codes) + assert recovery_service.decrypt_codes(encrypted) == codes + + def test_verify_code(self, recovery_service): + codes = recovery_service.generate_codes() + encrypted = recovery_service.encrypt_codes(codes) + assert recovery_service.verify_code(codes[0], encrypted) is True + assert recovery_service.verify_code("RC-WRONG", encrypted) is False + + def test_verify_corrupted_data_returns_false(self, recovery_service): + assert recovery_service.verify_code("RC-X", "garbage-data") is False + + def test_remove_used_code(self, recovery_service): + codes = recovery_service.generate_codes() + encrypted = recovery_service.encrypt_codes(codes) + used = codes[0] + updated = recovery_service.remove_used_code(used, encrypted) + remaining = recovery_service.decrypt_codes(updated) + assert used not in remaining + assert len(remaining) == len(codes) - 1 + + def test_remove_nonexistent_returns_none(self, recovery_service): + codes = recovery_service.generate_codes() + encrypted = recovery_service.encrypt_codes(codes) + assert recovery_service.remove_used_code("RC-NOPE", encrypted) is None + + def test_remove_from_corrupted_returns_none(self, recovery_service): + assert recovery_service.remove_used_code("RC-X", "garbage") is None + + +@pytest.fixture +def two_factor_client(user): + client = APIClient() + client.force_authenticate(user=user) + return client + + +class TestTwoFactorSetup: + def test_setup_returns_secret_and_qr(self, two_factor_client): + resp = two_factor_client.post("/api/auth/2fa/setup/") + assert resp.status_code == status.HTTP_200_OK + data = resp.json() + assert "secret" in data + assert "qr_base64" in data + assert "provisioning_uri" in data + + def test_setup_requires_auth(self, api_client): + resp = api_client.post("/api/auth/2fa/setup/") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestTwoFactorVerifySetup: + def test_verify_setup_complete_flow(self, two_factor_client, user): + setup = two_factor_client.post("/api/auth/2fa/setup/") + secret = setup.json()["secret"] + code = pyotp.TOTP(secret).now() + resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": code}, format="json") + assert resp.status_code == status.HTTP_200_OK + assert len(resp.json()["recovery_codes"]) == 8 + + config = TwoFactorConfig.objects.get(user=user) + assert config.is_enabled is True + assert config.is_configured is True + assert config.secret_key_encrypted + + def test_verify_setup_wrong_code(self, two_factor_client): + two_factor_client.post("/api/auth/2fa/setup/") + resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": "000000"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_verify_setup_without_session(self, two_factor_client): + resp = two_factor_client.post("/api/auth/2fa/verify-setup/", {"code": "123456"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + +class TestTwoFactorDisable: + def test_disable_without_config(self, two_factor_client): + resp = two_factor_client.post("/api/auth/2fa/disable/") + assert resp.status_code == status.HTTP_200_OK + + def test_disable_with_config(self, two_factor_client, user): + TwoFactorConfig.objects.create( + user=user, + is_enabled=True, + is_configured=True, + secret_key_encrypted="enc", + recovery_codes_encrypted="enc", + ) + resp = two_factor_client.post("/api/auth/2fa/disable/") + assert resp.status_code == status.HTTP_200_OK + user.two_factor_config.refresh_from_db() + assert user.two_factor_config.is_enabled is False + assert user.two_factor_config.secret_key_encrypted is None + + +class TestTwoFactorVerifyAndRecovery: + @pytest.fixture + def configured_user(self, db, two_factor_client, user): + secret = TOTPService().generate_secret() + rcs = RecoveryCodeService() + TwoFactorConfig.objects.create( + user=user, + is_enabled=True, + is_configured=True, + secret_key_encrypted=TOTPService().encrypt_secret(secret), + recovery_codes_encrypted=rcs.encrypt_codes(rcs.generate_codes()), + ) + return user, secret + + def test_verify_requires_pre_auth_session(self, two_factor_client, configured_user): + resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "123456"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_recovery_requires_pre_auth_session(self, two_factor_client, configured_user): + resp = two_factor_client.post("/api/auth/2fa/recovery/", {"recovery_code": "RC-X"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_verify_with_correct_code(self, two_factor_client, configured_user): + user, secret = configured_user + session = two_factor_client.session + session["_2fa_user_id"] = user.pk + session["_2fa_pre_auth_token"] = "pre-auth-token" + session.save() + + code = pyotp.TOTP(secret).now() + resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": code}, format="json") + assert resp.status_code == status.HTTP_200_OK + + def test_verify_with_wrong_code(self, two_factor_client, configured_user): + user, secret = configured_user + session = two_factor_client.session + session["_2fa_user_id"] = user.pk + session.save() + + resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "000000"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_verify_without_2fa_config(self, two_factor_client, user): + session = two_factor_client.session + session["_2fa_user_id"] = user.pk + session.save() + + resp = two_factor_client.post("/api/auth/2fa/verify/", {"code": "123456"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED + + def test_recovery_with_valid_code(self, two_factor_client, configured_user): + user, secret = configured_user + config = TwoFactorConfig.objects.get(user=user) + recovery_codes = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted) + session = two_factor_client.session + session["_2fa_user_id"] = user.pk + session.save() + + resp = two_factor_client.post( + "/api/auth/2fa/recovery/", + {"recovery_code": recovery_codes[0]}, + format="json", + ) + assert resp.status_code == status.HTTP_200_OK + + config.refresh_from_db() + remaining = RecoveryCodeService().decrypt_codes(config.recovery_codes_encrypted) + assert recovery_codes[0] not in remaining + + def test_recovery_with_invalid_code(self, two_factor_client, configured_user): + user, secret = configured_user + session = two_factor_client.session + session["_2fa_user_id"] = user.pk + session.save() + + resp = two_factor_client.post("/api/auth/2fa/recovery/", {"recovery_code": "RC-INVALID"}, format="json") + assert resp.status_code == status.HTTP_401_UNAUTHORIZED diff --git a/tests/test_security/test_two_factor_middleware.py b/tests/test_security/test_two_factor_middleware.py new file mode 100644 index 0000000..4e234b2 --- /dev/null +++ b/tests/test_security/test_two_factor_middleware.py @@ -0,0 +1,90 @@ +from datetime import timedelta + +from django.utils import timezone + +from infrasynth.security.models import TwoFactorConfig +from infrasynth.security.two_factor.middleware import TwoFactorMiddleware +from infrasynth.security.two_factor.utils import ( + generate_pre_auth_token, + get_pre_auth_expiry, +) + + +class TestPreAuthUtils: + def test_generate_pre_auth_token_length(self): + token = generate_pre_auth_token() + assert len(token) == 64 + + def test_generate_pre_auth_token_unique(self): + assert generate_pre_auth_token() != generate_pre_auth_token() + + def test_get_pre_auth_expiry(self): + before = timezone.now() + expiry = get_pre_auth_expiry(minutes=5) + after = timezone.now() + assert before + timedelta(minutes=5) <= expiry <= after + timedelta(minutes=5) + + +class _Request: + def __init__(self, user, path="/api/features/active/"): + self.user = user + self.path = path + + +class TestTwoFactorMiddleware: + def test_anonymous_passes(self, rf, db): + request = rf.get("/api/features/active/") + request.user = type("Anon", (), {"is_authenticated": False})() + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_user_without_config_passes(self, rf, user): + request = rf.get("/api/features/active/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_disabled_config_passes(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=False, is_configured=True) + request = rf.get("/api/features/active/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_unconfigured_enabled_passes(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=False) + request = rf.get("/api/features/active/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_redirects_when_2fa_required(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/api/features/active/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is not None + assert result.status_code == 302 + assert result.url == "/api/auth/2fa/verify/" + + def test_exempt_paths_not_redirected(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/api/auth/check/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_admin_path_not_redirected(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/admin/") + request.user = user + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None + + def test_verified_user_not_redirected(self, rf, user): + TwoFactorConfig.objects.create(user=user, is_enabled=True, is_configured=True) + request = rf.get("/api/features/active/") + request.user = user + request._2fa_verified = True + result = TwoFactorMiddleware(get_response=lambda r: None).process_request(request) + assert result is None diff --git a/tests/test_shared/__init__.py b/tests/test_shared/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_shared/__pycache__/__init__.cpython-314.pyc b/tests/test_shared/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bdc3a8d4f17a9c1ff39f5d3a3173c19812250ee9 GIT binary patch literal 175 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 +i>y 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 X@?M+g%u@xNm0c~Kp=WTF&5QbflD@mS literal 0 HcmV?d00001 diff --git a/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f9382ab47be4764ce4019d3922fd2bf53421e86f GIT binary patch literal 20532 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 literal 0 HcmV?d00001 diff --git a/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ddccea85d79d2fe98415962b3c423986227199a2 GIT binary patch literal 19724 zcmeGkTWlLucEBlzksY1#LwDn@&u-3bh;9qw&^|@UCqzbPDpHWVK4zUshSaQC#Xo6zWj_S(F->tXAAQN z=p$;1m>p_5mCdR|E0A>lN+CrwvlULOm*+BBBa_q3U^WjJx_Lm=r}J~!35u#tW%HL) zSv8lsqM2b;PfcpLg4zBeeW;my?iIXObDuhyCs$I2s=Z&xrgA9*&X^K?4yHu!&J40^ zz8x|Hx|W?JsF#>6cnOR^C-}}F-#NuPY=@`$&S@QVDdAW*JHHOK;7XpP-b>94 zjFSAjTH4SDGPy~T(&ux=^i!8px;Ah*m7dXZ69f2YbPC~?;eoIbxNp=B^+aLb?4eID zKcl9JmV&l*PQ98jrd9Tg6Jwmhm#B*`!S(7D(Cj`IzUkQi`N3NUizh#ObRqb4wDZ&J zH?NoEo+6=ODcVz!txs9*S-9@>oW{a?PIDPBtlp}Nyn?GaID7-}ogI6NMoC5x?XAex zr!4p4j_v?!;~v)7GGJJ}RTp^$7jkg;2H@YH9dF#cQIcbe5(Cj#MYcX=Iks@a={XI& zD+^;8Fs$CHi@buXIXHX+&a44_1#i9oAE5o$*ZUR0)*Lt1`xRl^dViQ|jFG04+D$37 zq29k;jj@GNv9(FX-J~MbHQG#RTFYkh1O*S3VFV3(f_5`$L4bDi$QWtEb`$}ctK>ld zw_B-%CTKa5P6TKT5-^7af))iihyb-x@(6;%2*Ah|NEd=00KB{-{V-tevqcV-NxDHY zxhb>TQz&s5c#-;l0k~plxZKgp$)RJ#t0fu0QuJ6wwmxO~7)k|qfVGiFT4T$AVf9vB zp!FVbK^`nm(OXNF=0H9y0?zzx~l>Ye%8hD;Ge5s#MXdsLohUA$|`m4sj3-K)hqdl zxvU25R#lyyOJ&jR2fe7OPG*R1WHUJ}mseF1M`<{KUiZ}c^p@)E3dZim-Dg`nskI{ z{0`Ic-bQlSe`9qdw@JmFr{cYhZT7h&&hP!R&wUY|mGC`ROe1v_)0So!Va2#?fvvMK1=p#mL>Z%2$AZv7Ai@$misyw*9jFp zZDu5i*|Z=J@`UBVoQGF*>tZM#AxClIn+WjrCcOw^0Ql6~UWzy`g7G&^Lv;xgzjcs6%QEBF&p-)_oGLhDPpm?y{-n-z(98a*viZ5417LFG6 zQuJs=wmxO~D9SK*fVEL-S!2t9Vf9u$kYTK-^T=G!8HNTdbDty|c^00|NIXoQgKu&M z!Se`SKyU%U2!h`tIE&y#1TO*Lg9ESNAOg&c(lJ}g2@b?CI1pQp1f{#MDlTx`D=uIJAtDe`0`N~HnTOohU~ImiUC2#n1xO>-a%s&wJn)}}>@vR4iz^BP zp8YRQ29s&APPxN3L>R!Dad5tV^jvhRi|oS{OUh>-$*r<*Tr%nY3(h&pSWfZcV83CxEMeSYEAh2rJUUS9}*)A`8f zZ{K=*F<9<=Y$5V>^uVVd-TVl0Q5X9ds7Xluh|ecw?FC48;u2D=9www($dHh_*yrHz z4VsX8uoCTBigrN;)*|>Y5mcvPm7-mI8Wn3VTyw@S56kL79+ra*iCBx^<*ZHMf)O}# zw|@^ah(UtLYv?npS2$3BV&&N$-t3t@z??(0xK)1>^uX(%%O#+?)J?`#FMN z{QD45S_Z`CJJIA3woC$*RYR7 zu>@ZqMW+>WvM|9j!NJ4KZ8j zVcQm(&Sxidu!!bW1N=Vbi3Uuc>M6spvi?`N6G+g2#DV#nP}~tKb^=(KFLsvE(uh@} zv88AX8mnB1GAk~ILghb!_s=a#wtHBV48{$ffYRe)r_-b+hc#TfYdt_)l4H)5^cMF) z{Wo06zCV5ljY_n4DcTE-RjwYKRdPdBIBxA$fZIK+0EY{~6IcPh&uK1v=rsBAayAHX zN$%Zq*O4g37CVbaN^%08$&qrjA6k`YVkw${#%eTCfiCwc%L#l8*4VP!Tg3&~i4{3f zJmNH|HHr8zT)JyL7+;bT+*9efkMseGGfyh>-^!l;+uy_MP2NRt7J;Yh_8mNf;5hv0 z`2TFVZUa($y;Z_&ZUAgAM2okWvi;K(v^ds0sInikNU(oi_<@N8(XNt=0Y6OP44ENo zjF#4#2i>23NU)!HfI0Nn9T;ChCo6R*(@UdHYIX_zaKe{lQcZjj?1OV&(tzzGr0Dvy`e@V<6ng9R* literal 0 HcmV?d00001 diff --git a/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5b0134d9bc39fa16e559fbba26c3e6d0d8673083 GIT binary patch literal 7268 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 aVg5$QFUVg>^zY<@*WNCs{}lnE>;4C4GzwAx literal 0 HcmV?d00001 diff --git a/tests/test_shared/test_crypto.py b/tests/test_shared/test_crypto.py new file mode 100644 index 0000000..5c7987c --- /dev/null +++ b/tests/test_shared/test_crypto.py @@ -0,0 +1,92 @@ +import pytest +from cryptography.fernet import Fernet + +from infrasynth.shared.crypto import decrypt, encrypt, generate_key, rotate_keys + + +class TestEncryptDecrypt: + def test_roundtrip(self): + token = encrypt("hello-world") + assert decrypt(token) == "hello-world" + + def test_different_plaintexts(self): + for text in ["a", "hello", "data with spaces", "12345", ""]: + assert decrypt(encrypt(text)) == text + + def test_encrypted_values_differ(self): + t1 = encrypt("same") + t2 = encrypt("same") + assert t1 != t2 + + def test_decrypt_invalid_token(self): + with pytest.raises(ValueError, match="Invalid Fernet token"): + decrypt("invalid-token-here") + + def test_decrypt_garbage(self): + with pytest.raises(ValueError, match="Invalid Fernet token"): + decrypt("AAAA") + + def test_decrypt_empty_string(self): + with pytest.raises(ValueError, match="Invalid Fernet token"): + decrypt("") + + +class TestGenerateKey: + def test_generates_string(self): + key = generate_key() + assert isinstance(key, str) + assert len(key) > 0 + + def test_keys_are_unique(self): + keys = {generate_key() for _ in range(10)} + assert len(keys) == 10 + + def test_key_is_valid_fernet_key(self): + key = generate_key() + Fernet(key.encode()) + + +class TestRotateKeys: + def _encrypt_with_key(self, key: str, value: str) -> str: + f = Fernet(key.encode()) + return f.encrypt(value.encode()).decode() + + def test_rotate_single_token(self): + old_key = generate_key() + new_key = generate_key() + token = self._encrypt_with_key(old_key, "secret-value") + rotated = rotate_keys(old_key, new_key, [token]) + assert len(rotated) == 1 + assert rotated[0] != token + f = Fernet(new_key.encode()) + assert f.decrypt(rotated[0].encode()).decode() == "secret-value" + + def test_rotate_multiple_tokens(self): + old_key = generate_key() + new_key = generate_key() + values = [f"value-{i}" for i in range(5)] + tokens = [self._encrypt_with_key(old_key, v) for v in values] + rotated = rotate_keys(old_key, new_key, tokens) + assert len(rotated) == 5 + f = Fernet(new_key.encode()) + for plaintext, cipher in zip(values, rotated): + assert f.decrypt(cipher.encode()).decode() == plaintext + + def test_rotate_with_same_key(self): + key = generate_key() + token = self._encrypt_with_key(key, "persistent") + rotated = rotate_keys(key, key, [token]) + assert len(rotated) == 1 + assert rotated[0] != token + f = Fernet(key.encode()) + assert f.decrypt(rotated[0].encode()).decode() == "persistent" + + def test_rotate_preserves_all_values(self): + old_key = generate_key() + new_key = generate_key() + values = ["keep-me-safe", "another-value", "123"] + tokens = [self._encrypt_with_key(old_key, v) for v in values] + rotated = rotate_keys(old_key, new_key, tokens) + f = Fernet(new_key.encode()) + for original, cipher in zip(values, rotated): + assert f.decrypt(cipher.encode()).decode() == original diff --git a/tests/test_shared/test_enums.py b/tests/test_shared/test_enums.py new file mode 100644 index 0000000..c42aaea --- /dev/null +++ b/tests/test_shared/test_enums.py @@ -0,0 +1,102 @@ +from infrasynth.shared.enums import ( + ApprovalStrategy, + AuditAction, + BillingInterval, + ChannelType, + EventSeverity, + InvoiceStatus, + SubscriptionStatus, +) + + +class TestChannelType: + def test_values(self): + assert ChannelType.EMAIL == "email" + assert ChannelType.SMS == "sms" + assert ChannelType.WHATSAPP == "whatsapp" + assert ChannelType.TELEGRAM == "telegram" + assert ChannelType.PUSH == "push" + + def test_choices(self): + choices = ChannelType.choices() + assert isinstance(choices, list) + assert ("email", "Email") in choices + assert ("sms", "Sms") in choices + assert len(choices) == 5 + + +class TestEventSeverity: + def test_values(self): + assert EventSeverity.INFO == "info" + assert EventSeverity.WARNING == "warning" + assert EventSeverity.ERROR == "error" + assert EventSeverity.CRITICAL == "critical" + + def test_choices(self): + choices = EventSeverity.choices() + assert len(choices) == 4 + assert ("critical", "Critical") in choices + + +class TestBillingInterval: + def test_values(self): + assert BillingInterval.MONTHLY == "monthly" + assert BillingInterval.YEARLY == "yearly" + + def test_choices(self): + choices = BillingInterval.choices() + assert len(choices) == 2 + assert ("monthly", "Monthly") in choices + + +class TestAuditAction: + def test_values(self): + assert AuditAction.CREATE == "create" + assert AuditAction.UPDATE == "update" + assert AuditAction.DELETE == "delete" + + def test_choices(self): + choices = AuditAction.choices() + assert len(choices) == 3 + assert ("create", "Create") in choices + + +class TestSubscriptionStatus: + def test_values(self): + assert SubscriptionStatus.ACTIVE == "active" + assert SubscriptionStatus.PAST_DUE == "past_due" + assert SubscriptionStatus.CANCELLED == "cancelled" + assert SubscriptionStatus.EXPIRED == "expired" + assert SubscriptionStatus.TRIALING == "trialing" + + def test_choices(self): + choices = SubscriptionStatus.choices() + assert len(choices) == 5 + assert ("active", "Active") in choices + assert ("past_due", "Past Due") in choices + + +class TestInvoiceStatus: + def test_values(self): + assert InvoiceStatus.DRAFT == "draft" + assert InvoiceStatus.OPEN == "open" + assert InvoiceStatus.PAID == "paid" + assert InvoiceStatus.VOID == "void" + assert InvoiceStatus.UNCOLLECTIBLE == "uncollectible" + + def test_choices(self): + choices = InvoiceStatus.choices() + assert len(choices) == 5 + assert ("uncollectible", "Uncollectible") in choices + + +class TestApprovalStrategy: + def test_values(self): + assert ApprovalStrategy.ANY == "any" + assert ApprovalStrategy.ALL == "all" + assert ApprovalStrategy.MAJORITY == "majority" + + def test_choices(self): + choices = ApprovalStrategy.choices() + assert len(choices) == 3 + assert ("majority", "Majority") in choices diff --git a/tests/test_shared/test_protocols.py b/tests/test_shared/test_protocols.py new file mode 100644 index 0000000..6c6b44d --- /dev/null +++ b/tests/test_shared/test_protocols.py @@ -0,0 +1,93 @@ +from datetime import datetime +from typing import Any + +from infrasynth.shared.protocols import ( + AuditableProtocol, + EventProtocol, + TenantProtocol, +) + + +class TestAuditableProtocol: + def test_matching_class_is_compatible(self): + class MyModel: + pk: Any = 1 + usuario_creacion: Any = None + fecha_creacion: datetime | None = datetime.now() + usuario_actualizacion: Any = None + fecha_actualizacion: datetime | None = datetime.now() + + assert isinstance(MyModel(), AuditableProtocol) + + def test_non_matching_class_is_not_compatible(self): + class NotAuditable: + pass + + assert not isinstance(NotAuditable(), AuditableProtocol) + + def test_partial_match_is_not_compatible(self): + class Partial: + pk: Any = 1 + + assert not isinstance(Partial(), AuditableProtocol) + + +class TestEventProtocol: + def test_matching_class_is_compatible(self): + class MyEvent: + event_name: str = "ticket.created" + payload: dict = {"id": 1} + timestamp: str = "2026-01-01T00:00:00Z" + + assert isinstance(MyEvent(), EventProtocol) + + def test_non_matching_class_is_not_compatible(self): + class NotEvent: + pass + + assert not isinstance(NotEvent(), EventProtocol) + + def test_partial_match_is_not_compatible(self): + class PartialEvent: + event_name: str = "test" + + assert not isinstance(PartialEvent(), EventProtocol) + + +class TestTenantProtocol: + def test_matching_class_is_compatible(self): + class MyTenant: + tenant_id: str | None = "tenant-123" + + assert isinstance(MyTenant(), TenantProtocol) + + def test_tenant_id_none_is_still_valid(self): + class NoTenant: + tenant_id: str | None = None + + assert isinstance(NoTenant(), TenantProtocol) + + def test_non_matching_class_is_not_compatible(self): + class NoTenantModel: + pass + + assert not isinstance(NoTenantModel(), TenantProtocol) + + +class TestCombinedProtocols: + def test_class_can_satisfy_multiple_protocols(self): + class FullModel: + pk: Any = 1 + usuario_creacion: Any = None + fecha_creacion: datetime | None = datetime.now() + usuario_actualizacion: Any = None + fecha_actualizacion: datetime | None = datetime.now() + event_name: str = "test" + payload: dict = {} + timestamp: str = "2026-01-01T00:00:00Z" + tenant_id: str | None = "t1" + + obj = FullModel() + assert isinstance(obj, AuditableProtocol) + assert isinstance(obj, EventProtocol) + assert isinstance(obj, TenantProtocol) diff --git a/tests/test_shared/test_results.py b/tests/test_shared/test_results.py new file mode 100644 index 0000000..1fb0f1c --- /dev/null +++ b/tests/test_shared/test_results.py @@ -0,0 +1,60 @@ +from infrasynth.shared.results import Result + + +class TestResultOk: + def test_ok_creates_result_with_value(self): + result = Result.ok(42) + assert result.value == 42 + assert result.error is None + assert result.is_ok is True + assert result.is_err is False + + def test_ok_with_string(self): + result = Result.ok("hello") + assert result.value == "hello" + assert result.is_ok is True + + def test_ok_with_list(self): + result = Result.ok([1, 2, 3]) + assert result.value == [1, 2, 3] + assert result.is_ok is True + + def test_ok_with_none(self): + result = Result.ok(None) + assert result.value is None + assert result.is_ok is True + + +class TestResultErr: + def test_err_creates_result_with_error(self): + result = Result.err("something went wrong") + assert result.value is None + assert result.error == "something went wrong" + assert result.is_ok is False + assert result.is_err is True + + def test_err_with_dict(self): + result = Result.err({"code": 404, "message": "Not found"}) + assert result.error == {"code": 404, "message": "Not found"} + assert result.is_err is True + + def test_err_with_exception(self): + exc = ValueError("invalid") + result = Result.err(exc) + assert result.error is exc + assert result.is_err is True + + +class TestResultTypeIndependence: + def test_ok_and_err_are_different_instances(self): + ok_result = Result.ok(42) + err_result = Result.err("fail") + assert ok_result.is_ok is True + assert err_result.is_ok is False + + def test_ok_holds_any_type(self): + assert Result.ok("str").is_ok + assert Result.ok(123).is_ok + assert Result.ok(1.5).is_ok + assert Result.ok({"key": "val"}).is_ok + assert Result.ok([1]).is_ok diff --git a/tests/test_shared/test_settings_utils.py b/tests/test_shared/test_settings_utils.py new file mode 100644 index 0000000..9f3f85c --- /dev/null +++ b/tests/test_shared/test_settings_utils.py @@ -0,0 +1,35 @@ +from django.test import override_settings + +from infrasynth.shared.settings_utils import get_setting + + +class TestGetSetting: + def test_returns_value_when_key_exists(self): + with override_settings(INFRASYNTH_TEST={"MY_KEY": "hello"}): + result = get_setting("INFRASYNTH_TEST", "MY_KEY") + assert result == "hello" + + def test_returns_default_when_prefix_missing(self): + result = get_setting("NONEXISTENT_PREFIX", "SOME_KEY", 42) + assert result == 42 + + def test_returns_default_when_key_missing(self): + with override_settings(INFRASYNTH_TEST={"EXISTING": "val"}): + result = get_setting("INFRASYNTH_TEST", "MISSING_KEY", "fallback") + assert result == "fallback" + + def test_returns_none_when_no_default(self): + with override_settings(INFRASYNTH_TEST={}): + result = get_setting("INFRASYNTH_TEST", "MISSING_KEY") + assert result is None + + def test_works_with_nested_dict(self): + config = {"NESTED": {"key": "deep"}} + with override_settings(INFRASYNTH_TEST=config): + result = get_setting("INFRASYNTH_TEST", "NESTED") + assert result == {"key": "deep"} + + def test_with_real_infrasynth_setting(self): + result = get_setting("INFRASYNTH_SECURITY", "COOKIE_SECURE") + assert result is not None + assert isinstance(result, bool) diff --git a/tests/test_webhooks/__init__.py b/tests/test_webhooks/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc b/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..18c2f95910a42821725314772e3b18035f3c46c1 GIT binary patch literal 177 zcmdPq_I|p@<2{{|u76<)@#KpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT<*7*- w`T5z!`tk9Zd6^~g@p=W7w>WHa^HWN5QtgUZfyRRzPz+*xVrFDyEMf+-00ImxE&u=k literal 0 HcmV?d00001 diff --git a/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e59e3686143a477a07262111d1c6f88ae0f22ea7 GIT binary patch literal 25591 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 literal 0 HcmV?d00001 diff --git a/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8e615725da23eccd61ff482043f469cafc077d26 GIT binary patch literal 5066 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 KXBW{baI2^O&b`0|umrWF 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 literal 0 HcmV?d00001 diff --git a/tests/test_webhooks/test_dispatch.py b/tests/test_webhooks/test_dispatch.py new file mode 100644 index 0000000..68f8dd8 --- /dev/null +++ b/tests/test_webhooks/test_dispatch.py @@ -0,0 +1,143 @@ +from unittest import mock + +import pytest +from celery.exceptions import Retry + +from infrasynth.webhooks.dispatch import _build_payload, _handle_failure, deliver_webhook +from infrasynth.webhooks.models import ( + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def subscription(): + endpoint = OutboundEndpoint.objects.create( + name="Target", + url="https://example.com/hook", + secret="test-secret", + ) + return OutboundSubscription.objects.create(endpoint=endpoint, event_name="evt.test") + + +class TestDeliverWebhook: + def test_successful_delivery(self, subscription): + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + delivery_id = deliver_webhook.run(subscription.id, "evt.test", {"a": 1}, "") + + delivery = OutboundDelivery.objects.get(pk=delivery_id) + assert delivery.status == OutboundDelivery.Status.SUCCESS + assert delivery.response_status == 200 + assert delivery.completed_at is not None + + request = post.call_args + assert request.args[0] == "https://example.com/hook" + assert request.kwargs["data"] == '{"a": 1}' + signature_header = request.kwargs["headers"]["X-Webhook-Signature"] + from infrasynth.webhooks.signature import verify_signature + + assert verify_signature("test-secret", '{"a": 1}', signature_header) + + def test_success_sends_signal(self, subscription): + from infrasynth.webhooks.signals import outbound_delivery_succeeded + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + outbound_delivery_succeeded.connect(receiver, weak=False) + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + deliver_webhook.run(subscription.id, "evt.test", {}, "") + assert sent + assert sent[0]["event_name"] == "evt.test" + assert sent[0]["status_code"] == 200 + outbound_delivery_succeeded.disconnect(receiver) + + def test_http_error_gives_up_after_retries(self, subscription): + endpoint = subscription.endpoint + endpoint.retry_policy = {"max_retries": 0} + endpoint.save(update_fields=["retry_policy"]) + + from infrasynth.webhooks.signals import outbound_delivery_failed + + failed = [] + receiver = lambda **kw: failed.append(kw) # noqa: E731 + outbound_delivery_failed.connect(receiver, weak=False) + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 500 + post.return_value.text = "boom" + result = deliver_webhook.run(subscription.id, "evt.test", {}, "") + + assert result is None + delivery = OutboundDelivery.objects.get(subscription=subscription) + assert delivery.status == OutboundDelivery.Status.FAILED + assert delivery.response_body == "boom" + assert failed + assert "500" in failed[0]["error"] + outbound_delivery_failed.disconnect(receiver) + + def test_missing_subscription_returns_none(self): + assert deliver_webhook.run(999999, "evt", {}, "") is None + + def test_build_payload_with_template(self, subscription): + body = _build_payload({"type": "created"}, '{"event": "{{ event.type }}", "extra": "x"}') + assert body == '{"event": "created", "extra": "x"}' + + def test_build_payload_json_fallback(self, subscription): + body = _build_payload({"a": 1}, "") + assert body == '{"a": 1}' + + def test_build_payload_invalid_template_falls_back(self, subscription): + body = _build_payload({"a": 1}, "{{ event|unknown_filter_xyz }}") + assert body == '{"a": 1}' + + +class _FakeTask: + def __init__(self, retries): + self.request = type("R", (), {"retries": retries})() + + def retry(self, exc, countdown): + raise Retry() + + +class TestHandleFailure: + @pytest.fixture + def delivery(self, subscription): + return OutboundDelivery.objects.create(subscription=subscription, payload={}) + + def test_schedules_retry_when_under_max(self, subscription, delivery): + endpoint = subscription.endpoint + endpoint.retry_policy = {"max_retries": 5} + endpoint.save(update_fields=["retry_policy"]) + + fake_task = _FakeTask(retries=1) + with pytest.raises(Retry): + _handle_failure(fake_task, delivery, subscription, endpoint, "conn refused") + delivery.refresh_from_db() + assert delivery.status == OutboundDelivery.Status.RETRYING + assert delivery.next_retry_at is not None + assert "conn refused" in delivery.response_body + + def test_marks_failed_after_max_retries(self, subscription, delivery): + endpoint = subscription.endpoint + endpoint.retry_policy = {"max_retries": 3} + endpoint.save(update_fields=["retry_policy"]) + + from infrasynth.webhooks.signals import outbound_delivery_failed + + failed = [] + receiver = lambda **kw: failed.append(kw) # noqa: E731 + outbound_delivery_failed.connect(receiver, weak=False) + fake_task = _FakeTask(retries=3) + result = _handle_failure(fake_task, delivery, subscription, endpoint, "gave up") + delivery.refresh_from_db() + assert result is None + assert delivery.status == OutboundDelivery.Status.FAILED + assert delivery.completed_at is not None + assert failed + outbound_delivery_failed.disconnect(receiver) diff --git a/tests/test_webhooks/test_inbound.py b/tests/test_webhooks/test_inbound.py new file mode 100644 index 0000000..25b59c7 --- /dev/null +++ b/tests/test_webhooks/test_inbound.py @@ -0,0 +1,27 @@ +import pytest + +from infrasynth.webhooks.inbound.handlers import BaseInboundHandler + + +class _ConcreteHandler(BaseInboundHandler): + def verify(self, payload, headers, secret): + return payload.get("token") == secret + + def process(self, event_type, payload): + return {"handled": event_type, "items": len(payload)} + + +class TestBaseInboundHandler: + def test_is_abstract(self): + with pytest.raises(TypeError): + BaseInboundHandler() + + def test_concrete_handler_verify(self): + handler = _ConcreteHandler() + assert handler.verify({"token": "abc"}, {}, "abc") is True + assert handler.verify({"token": "wrong"}, {}, "abc") is False + + def test_concrete_handler_process(self): + handler = _ConcreteHandler() + result = handler.process("issue.opened", {"a": 1, "b": 2}) + assert result == {"handled": "issue.opened", "items": 2} diff --git a/tests/test_webhooks/test_integration.py b/tests/test_webhooks/test_integration.py new file mode 100644 index 0000000..64d0a1e --- /dev/null +++ b/tests/test_webhooks/test_integration.py @@ -0,0 +1,308 @@ +from contextlib import contextmanager +from unittest import mock + +import pytest +from django.core.exceptions import ValidationError +from django.db import transaction +from django.dispatch import receiver +from django.test import override_settings + +from infrasynth.audit.models import SecurityEvent +from infrasynth.features.models import FeatureFlag, FeatureFlagOverride +from infrasynth.features.registry import FeatureRegistry +from infrasynth.features.services import FeatureService +from infrasynth.notifications.resolvers import VariableResolverRegistry +from infrasynth.webhooks.models import ( + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) +from infrasynth.webhooks.registry import EventRegistry +from infrasynth.webhooks.signals import ( + outbound_delivery_failed, + outbound_delivery_succeeded, +) +from infrasynth.workflows.validators import DataValidatorRegistry + +pytestmark = pytest.mark.django_db + + +@contextmanager +def _emit_and_run_commit_hooks(): + with transaction.atomic(): + yield + connection = transaction.get_connection() + pending = list(connection.run_on_commit) + connection.run_on_commit = [] + for entry in pending: + func = entry[1] + func() + + +@pytest.fixture(autouse=True) +def clean_registries(): + saved_events = dict(EventRegistry._events) + saved_features = dict(FeatureRegistry._features) + saved_resolvers = dict(VariableResolverRegistry._resolvers) + saved_validators = dict(DataValidatorRegistry._validators) + yield + EventRegistry._events = saved_events + FeatureRegistry._features = saved_features + VariableResolverRegistry._resolvers = saved_resolvers + DataValidatorRegistry._validators = saved_validators + + +class TestOutboundDeliverySignalToAuditIntegration: + def test_outbound_delivery_signal_triggers_audit_event(self, user): + endpoint = OutboundEndpoint.objects.create( + name="Test Endpoint", + url="https://example.com/hook", + secret="test-secret", + ) + subscription = OutboundSubscription.objects.create( + endpoint=endpoint, + event_name="test.event", + ) + delivery = OutboundDelivery.objects.create( + subscription=subscription, + payload={"key": "value"}, + status=OutboundDelivery.Status.RETRYING, + ) + + @receiver(outbound_delivery_succeeded) + def create_audit_on_success(sender, **kwargs): + SecurityEvent.objects.create( + event_type="webhook_outbound_success", + actor=user, + ip_address="127.0.0.1", + metadata={ + "delivery_id": kwargs.get("delivery_id"), + "event_name": kwargs.get("event_name"), + "status_code": kwargs.get("status_code"), + }, + request_id="integration-test-001", + ) + + outbound_delivery_succeeded.send( + sender=OutboundDelivery, + delivery_id=delivery.id, + event_name="test.event", + status_code=200, + ) + + assert SecurityEvent.objects.filter(event_type="webhook_outbound_success").exists() + se = SecurityEvent.objects.get(event_type="webhook_outbound_success") + assert se.metadata["delivery_id"] == delivery.id + assert se.metadata["event_name"] == "test.event" + assert se.metadata["status_code"] == 200 + assert se.actor == user + + def test_outbound_delivery_failed_signal_triggers_audit_event(self, user): + endpoint = OutboundEndpoint.objects.create( + name="Test Endpoint", + url="https://example.com/hook", + secret="test-secret", + ) + subscription = OutboundSubscription.objects.create( + endpoint=endpoint, + event_name="test.event", + ) + delivery = OutboundDelivery.objects.create( + subscription=subscription, + payload={"key": "value"}, + status=OutboundDelivery.Status.RETRYING, + ) + + @receiver(outbound_delivery_failed) + def create_audit_on_failure(sender, **kwargs): + SecurityEvent.objects.create( + event_type="webhook_outbound_failed", + actor=user, + ip_address="127.0.0.1", + metadata={ + "delivery_id": kwargs.get("delivery_id"), + "event_name": kwargs.get("event_name"), + "error": kwargs.get("error"), + }, + request_id="integration-test-002", + ) + + outbound_delivery_failed.send( + sender=OutboundDelivery, + delivery_id=delivery.id, + event_name="test.event", + error="Connection timed out", + ) + + assert SecurityEvent.objects.filter(event_type="webhook_outbound_failed").exists() + se = SecurityEvent.objects.get(event_type="webhook_outbound_failed") + assert se.metadata["delivery_id"] == delivery.id + assert se.metadata["error"] == "Connection timed out" + assert se.actor == user + + +class TestEventRegistryEmissionIntegration: + @override_settings(CELERY_TASK_ALWAYS_EAGER=True) + def test_event_registry_emission_creates_outbound_deliveries(self): + EventRegistry.register( + "integration.order.created", + description="Order created event", + example_payload={"order_id": "ord_001"}, + ) + + endpoint = OutboundEndpoint.objects.create( + name="Integration Endpoint", + url="https://example.com/hook", + secret="test-secret", + ) + subscription = OutboundSubscription.objects.create( + endpoint=endpoint, + event_name="integration.order.created", + ) + + payload = {"order_id": "ord_001", "amount": 100} + + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + with _emit_and_run_commit_hooks(): + EventRegistry.emit("integration.order.created", payload) + + assert OutboundDelivery.objects.filter(subscription=subscription).exists() + delivery = OutboundDelivery.objects.get(subscription=subscription) + assert delivery.status == OutboundDelivery.Status.SUCCESS + assert delivery.payload is not None + assert delivery.response_status == 200 + + +class TestFeatureRegistryRegistrationAndDiscovery: + def test_feature_registry_registration_and_discovery(self, user): + FeatureRegistry.register( + "app_a.premium_feature", + name="Premium Feature", + description="A premium feature from App A", + default=False, + category="app_a", + ) + FeatureRegistry.register( + "app_b.analytics", + name="Analytics", + description="Analytics feature from App B", + default=True, + category="app_b", + ) + + service = FeatureService() + + assert service.is_enabled("app_a.premium_feature") is False + assert service.is_enabled("app_b.analytics") is True + assert service.is_enabled("nonexistent.feature") is False + + flag = FeatureFlag.objects.create( + slug="app_a.premium_feature", + name="Premium Feature Override", + is_active=True, + rollout_percentage=100, + ) + + assert service.is_enabled("app_a.premium_feature") is True + + FeatureFlagOverride.objects.create( + flag=flag, + user=user, + is_enabled=False, + ) + + assert service.is_enabled("app_a.premium_feature", user=user) is False + + FeatureFlag.objects.create( + slug="app_c.experimental", + name="Experimental", + is_active=False, + rollout_percentage=100, + ) + + assert service.is_enabled("app_c.experimental") is False + + +class TestVariableResolverRegistryCrossApp: + def test_variable_resolver_registry_cross_app(self): + @VariableResolverRegistry.register( + "user_name", + label="User Name", + description="Resolves the user's full name", + namespace="app_a", + ) + def resolve_user_name(recipient, context, request=None): + return context.get("user_name", "Unknown") + + @VariableResolverRegistry.register( + "order_total", + label="Order Total", + description="Resolves the order total", + namespace="app_b", + ) + def resolve_order_total(recipient, context, request=None): + return context.get("order_total", 0) + + context_a = {"user_name": "Alice"} + result_a = VariableResolverRegistry.resolve( + "user_name", "alice@example.com", context_a, namespace="app_a" + ) + assert result_a == "Alice" + + context_b = {"order_total": 99.99} + result_b = VariableResolverRegistry.resolve( + "order_total", "alice@example.com", context_b, namespace="app_b" + ) + assert result_b == 99.99 + + result_unknown = VariableResolverRegistry.resolve( + "nonexistent", "alice@example.com", {}, namespace="app_a" + ) + assert result_unknown is None + + available_a = VariableResolverRegistry.get_available_variables(namespace="app_a") + assert len(available_a) == 1 + assert "app_a:user_name" in available_a + + available_b = VariableResolverRegistry.get_available_variables(namespace="app_b") + assert len(available_b) == 1 + assert "app_b:order_total" in available_b + + available_all = VariableResolverRegistry.get_available_variables() + assert len(available_all) == 2 + + +class TestDataValidatorRegistryCrossApp: + def test_data_validator_registry_cross_app(self): + class AppAValidator: + def validate(self, node, data, context): + return {"validated": True, **data} + + class AppBValidator: + def validate(self, node, data, context): + if not data.get("required_field"): + raise ValidationError({"required_field": "This field is required."}) + return data + + DataValidatorRegistry.register("app_a_workflow", AppAValidator()) + DataValidatorRegistry.register("app_b_workflow", AppBValidator()) + + validator_a = DataValidatorRegistry.get("app_a_workflow") + assert validator_a is not None + result = validator_a.validate(None, {"key": "val"}, {}) + assert result["validated"] is True + assert result["key"] == "val" + + validator_b = DataValidatorRegistry.get("app_b_workflow") + assert validator_b is not None + result_valid = validator_b.validate(None, {"required_field": "present"}, {}) + assert result_valid == {"required_field": "present"} + + with pytest.raises(ValidationError) as exc_info: + validator_b.validate(None, {}, {}) + assert "required_field" in exc_info.value.message_dict + + missing = DataValidatorRegistry.get("nonexistent_workflow") + assert missing is None diff --git a/tests/test_webhooks/test_registry.py b/tests/test_webhooks/test_registry.py new file mode 100644 index 0000000..ab59cc3 --- /dev/null +++ b/tests/test_webhooks/test_registry.py @@ -0,0 +1,130 @@ +from contextlib import contextmanager +from unittest import mock + +import pytest +from django.db import transaction + +from infrasynth.webhooks.models import ( + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) +from infrasynth.webhooks.registry import EventDefinition, EventRegistry + +pytestmark = pytest.mark.django_db + + +@contextmanager +def _emit_and_run_commit_hooks(): + with transaction.atomic(): + yield + connection = transaction.get_connection() + pending = list(connection.run_on_commit) + connection.run_on_commit = [] + for entry in pending: + func = entry[1] + func() + + +@pytest.fixture(autouse=True) +def clean_registry(): + EventRegistry._events.clear() + yield + EventRegistry._events.clear() + + +class TestRegister: + def test_register_creates_definition(self): + EventRegistry.register("helpdesk.ticket.created") + event = EventRegistry.get_registered_events()["helpdesk.ticket.created"] + assert isinstance(event, EventDefinition) + assert event.name == "helpdesk.ticket.created" + + def test_register_with_metadata(self): + EventRegistry.register( + "helpdesk.ticket.resolved", + description="Ticket resolved", + example_payload={"ticket_id": 1}, + schema={"type": "object"}, + ) + event = EventRegistry.get_registered_events()["helpdesk.ticket.resolved"] + assert event.description == "Ticket resolved" + assert event.example_payload == {"ticket_id": 1} + assert event.schema == {"type": "object"} + + def test_get_registered_events_returns_copy(self): + EventRegistry.register("evt.a") + events = EventRegistry.get_registered_events() + events["evt.a"] = EventDefinition(name="mutated") + assert "evt.a" in EventRegistry.get_registered_events() + assert EventRegistry.get_registered_events()["evt.a"].name == "evt.a" + + +class TestEmit: + @pytest.fixture + def endpoint(self): + return OutboundEndpoint.objects.create( + name="Target", + url="https://example.com/hook", + secret="test-secret", + ) + + def test_emit_delivers_to_subscriber(self, endpoint): + subscription = OutboundSubscription.objects.create(endpoint=endpoint, event_name="helpdesk.ticket.created") + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + with _emit_and_run_commit_hooks(): + EventRegistry.emit("helpdesk.ticket.created", {"ticket_id": 1}) + + delivery = OutboundDelivery.objects.get(subscription=subscription) + assert delivery.status == OutboundDelivery.Status.SUCCESS + assert delivery.payload == '{"ticket_id": 1}' + + def test_emit_wildcard_subscription(self, endpoint): + subscription = OutboundSubscription.objects.create(endpoint=endpoint, event_name="*") + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + with _emit_and_run_commit_hooks(): + EventRegistry.emit("any.event", {"x": 1}) + + assert OutboundDelivery.objects.filter(subscription=subscription).exists() + + def test_emit_inactive_subscription_skipped(self, endpoint): + OutboundSubscription.objects.create(endpoint=endpoint, event_name="evt.x", is_active=False) + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + with _emit_and_run_commit_hooks(): + EventRegistry.emit("evt.x", {}) + post.assert_not_called() + assert not OutboundDelivery.objects.exists() + + def test_emit_inactive_endpoint_skipped(self, endpoint): + OutboundSubscription.objects.create(endpoint=endpoint, event_name="evt.x", is_active=True) + endpoint.is_active = False + endpoint.save(update_fields=["is_active"]) + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + with _emit_and_run_commit_hooks(): + EventRegistry.emit("evt.x", {}) + post.assert_not_called() + + def test_emit_no_subscriptions_noop(self): + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + with _emit_and_run_commit_hooks(): + EventRegistry.emit("unsubscribed.event", {}) + post.assert_not_called() + + def test_emit_sends_payload_template(self, endpoint): + subscription = OutboundSubscription.objects.create( + endpoint=endpoint, + event_name="evt.template", + payload_template='{"event": "{{ event.type }}"}', + ) + with mock.patch("infrasynth.webhooks.dispatch.requests.post") as post: + post.return_value.status_code = 200 + post.return_value.text = "ok" + with _emit_and_run_commit_hooks(): + EventRegistry.emit("evt.template", {"type": "created"}) + + delivery = OutboundDelivery.objects.get(subscription=subscription) + assert delivery.payload == '{"event": "created"}' diff --git a/tests/test_webhooks/test_signature.py b/tests/test_webhooks/test_signature.py new file mode 100644 index 0000000..56cacb8 --- /dev/null +++ b/tests/test_webhooks/test_signature.py @@ -0,0 +1,54 @@ +import time + +from infrasynth.webhooks.signature import sign_payload, verify_signature + + +class TestSignPayload: + def test_signature_format(self): + signature = sign_payload("secret", '{"a": 1}') + assert signature.startswith("t=") + assert ",v1=" in signature + + def test_signature_changes_with_payload(self): + sig1 = sign_payload("secret", "payload-one") + sig2 = sign_payload("secret", "payload-two") + assert sig1 != sig2 + + def test_signature_changes_with_secret(self): + sig1 = sign_payload("secret-a", "payload") + sig2 = sign_payload("secret-b", "payload") + assert sig1 != sig2 + + def test_timestamp_param(self): + signature = sign_payload("secret", "payload", timestamp=1000) + assert signature.startswith("t=1000,") + + +class TestVerifySignature: + def test_verify_roundtrip(self): + payload = '{"event": "ticket.created"}' + signature = sign_payload("secret", payload) + assert verify_signature("secret", payload, signature) is True + + def test_wrong_secret_rejected(self): + payload = "payload" + signature = sign_payload("secret-a", payload) + assert verify_signature("secret-b", payload, signature) is False + + def test_tampered_payload_rejected(self): + signature = sign_payload("secret", "original") + assert verify_signature("secret", "tampered", signature) is False + + def test_expired_timestamp_rejected(self): + old_timestamp = int(time.time()) - 600 + signature = sign_payload("secret", "payload", timestamp=old_timestamp) + assert verify_signature("secret", "payload", signature, tolerance_seconds=300) is False + + def test_fresh_timestamp_accepted(self): + signature = sign_payload("secret", "payload") + assert verify_signature("secret", "payload", signature, tolerance_seconds=300) is True + + def test_malformed_header_rejected(self): + assert verify_signature("secret", "payload", "garbage") is False + assert verify_signature("secret", "payload", "") is False + assert verify_signature("secret", "payload", "t=x,v1=y") is False diff --git a/tests/test_webhooks/test_views.py b/tests/test_webhooks/test_views.py new file mode 100644 index 0000000..43efa0a --- /dev/null +++ b/tests/test_webhooks/test_views.py @@ -0,0 +1,232 @@ +import pytest + +from infrasynth.webhooks.models import ( + InboundEndpoint, + InboundEvent, + OutboundDelivery, + OutboundEndpoint, + OutboundSubscription, +) + +pytestmark = pytest.mark.django_db + +OUTBOUND_ENDPOINTS_URL = "/api/webhooks/outbound/endpoints/" +OUTBOUND_SUBSCRIPTIONS_URL = "/api/webhooks/outbound/subscriptions/" +OUTBOUND_DELIVERIES_URL = "/api/webhooks/outbound/deliveries/" +INBOUND_ENDPOINTS_URL = "/api/webhooks/inbound/endpoints/" +INBOUND_EVENTS_URL = "/api/webhooks/inbound/events/" + + +@pytest.fixture +def endpoint(): + return OutboundEndpoint.objects.create( + name="Target", + url="https://example.com/hook", + secret="test-secret", + ) + + +@pytest.fixture +def subscription(endpoint): + return OutboundSubscription.objects.create(endpoint=endpoint, event_name="evt.test") + + +@pytest.fixture +def inbound_endpoint(): + return InboundEndpoint.objects.create( + name="Stripe", + slug="stripe", + source="stripe", + secret="wh-secret", + handler="infrasynth.webhooks.inbound.handlers.BaseInboundHandler", + ) + + +class TestOutboundEndpointViewSet: + def test_list(self, authenticated_client, endpoint): + response = authenticated_client.get(OUTBOUND_ENDPOINTS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["name"] == "Target" + + def test_requires_auth(self, api_client, endpoint): + assert api_client.get(OUTBOUND_ENDPOINTS_URL).status_code == 401 + + def test_create(self, authenticated_client): + response = authenticated_client.post( + OUTBOUND_ENDPOINTS_URL, + { + "name": "New", + "url": "https://example.com/new", + "secret": "s", + }, + format="json", + ) + assert response.status_code == 201 + assert OutboundEndpoint.objects.filter(name="New").exists() + + def test_update_and_delete(self, authenticated_client, endpoint): + response = authenticated_client.patch( + f"{OUTBOUND_ENDPOINTS_URL}{endpoint.id}/", + {"name": "Renamed"}, + format="json", + ) + assert response.status_code == 200 + response = authenticated_client.delete(f"{OUTBOUND_ENDPOINTS_URL}{endpoint.id}/") + assert response.status_code == 204 + assert not OutboundEndpoint.objects.filter(pk=endpoint.pk).exists() + + +class TestOutboundSubscriptionViewSet: + def test_list(self, authenticated_client, subscription): + response = authenticated_client.get(OUTBOUND_SUBSCRIPTIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["event_name"] == "evt.test" + + def test_list_includes_endpoint_info(self, authenticated_client, subscription): + response = authenticated_client.get(OUTBOUND_SUBSCRIPTIONS_URL) + assert response.data["results"][0]["endpoint_info"]["name"] == "Target" + + def test_create(self, authenticated_client, endpoint): + response = authenticated_client.post( + OUTBOUND_SUBSCRIPTIONS_URL, + {"endpoint": endpoint.id, "event_name": "evt.new"}, + format="json", + ) + assert response.status_code == 201 + assert OutboundSubscription.objects.filter(event_name="evt.new").exists() + + def test_requires_auth(self, api_client, subscription): + assert api_client.get(OUTBOUND_SUBSCRIPTIONS_URL).status_code == 401 + + def test_duplicate_event_rejected(self, authenticated_client, subscription): + response = authenticated_client.post( + OUTBOUND_SUBSCRIPTIONS_URL, + { + "endpoint": subscription.endpoint_id, + "event_name": "evt.test", + }, + format="json", + ) + assert response.status_code == 400 + + +class TestOutboundDeliveryViewSet: + def test_list(self, authenticated_client, subscription): + OutboundDelivery.objects.create(subscription=subscription, payload={"a": 1}, status="success") + response = authenticated_client.get(OUTBOUND_DELIVERIES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["payload"] == {"a": 1} + + def test_does_not_allow_create(self, authenticated_client): + assert authenticated_client.post(OUTBOUND_DELIVERIES_URL, {}, format="json").status_code == 405 + + def test_requires_auth(self, api_client): + assert api_client.get(OUTBOUND_DELIVERIES_URL).status_code == 401 + + +class TestInboundEndpointViewSet: + def test_list(self, authenticated_client, inbound_endpoint): + response = authenticated_client.get(INBOUND_ENDPOINTS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "stripe" + + def test_create(self, authenticated_client): + response = authenticated_client.post( + INBOUND_ENDPOINTS_URL, + { + "name": "GitHub", + "slug": "github", + "source": "github", + "secret": "s", + "handler": "helpdesk.webhook_handlers.JiraWebhookHandler", + }, + format="json", + ) + assert response.status_code == 201 + assert InboundEndpoint.objects.filter(slug="github").exists() + + def test_requires_auth(self, api_client, inbound_endpoint): + assert api_client.get(INBOUND_ENDPOINTS_URL).status_code == 401 + + +class TestInboundEventViewSet: + def test_list(self, authenticated_client, inbound_endpoint): + InboundEvent.objects.create( + endpoint=inbound_endpoint, + event_type="invoice.paid", + raw_payload={"id": "inv_1"}, + ) + response = authenticated_client.get(INBOUND_EVENTS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["event_type"] == "invoice.paid" + + def test_requires_auth(self, api_client, inbound_endpoint): + assert api_client.get(INBOUND_EVENTS_URL).status_code == 401 + + +class TestInboundReceiveView: + URL = "/api/webhooks/inbound/receive/{slug}/" + + def test_receive_creates_event(self, api_client, inbound_endpoint): + response = api_client.post( + self.URL.format(slug="stripe"), + { + "payload": {"type": "invoice.paid"}, + "headers": {"X-Event-Type": "invoice.paid"}, + }, + format="json", + ) + assert response.status_code == 201 + event = InboundEvent.objects.get(endpoint=inbound_endpoint) + assert event.event_type == "invoice.paid" + assert event.raw_payload == {"type": "invoice.paid"} + + def test_receive_is_public(self, api_client, inbound_endpoint): + response = api_client.post( + self.URL.format(slug="stripe"), + {"payload": {}, "headers": {}}, + format="json", + ) + assert response.status_code == 201 + + def test_receive_unknown_slug_404(self, api_client): + response = api_client.post( + self.URL.format(slug="unknown"), + {"payload": {}, "headers": {}}, + format="json", + ) + assert response.status_code == 404 + + def test_receive_inactive_endpoint_404(self, api_client, inbound_endpoint): + inbound_endpoint.is_active = False + inbound_endpoint.save(update_fields=["is_active"]) + response = api_client.post( + self.URL.format(slug="stripe"), + {"payload": {}, "headers": {}}, + format="json", + ) + assert response.status_code == 404 + + def test_receive_missing_payload_400(self, api_client, inbound_endpoint): + response = api_client.post(self.URL.format(slug="stripe"), {}, format="json") + assert response.status_code == 400 + + def test_receive_emits_signal(self, api_client, inbound_endpoint): + from infrasynth.webhooks.signals import inbound_event_received + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + inbound_event_received.connect(receiver, weak=False) + api_client.post( + self.URL.format(slug="stripe"), + {"payload": {"a": 1}, "headers": {}}, + format="json", + ) + assert sent + assert sent[0]["payload"] == {"a": 1} + inbound_event_received.disconnect(receiver) diff --git a/tests/test_workflows/__init__.py b/tests/test_workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_workflows/__pycache__/__init__.cpython-314.pyc b/tests/test_workflows/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c45e1eba73c4c01e7a4a4cd881a4f8118d498f8 GIT binary patch literal 178 zcmdPq_I|p@<2{{|u76<*%QSpPQpP83dlvrGuSCXNdlvtdqpOl!Kotl@TUy@o3lm(IT<@rU~ xX*v1j#rpB_nR%Hd@$q^EmA5!-a`RJ4b5iY!Sb^q)Tu=;Rd}3x~WGrF^vH(BnExZ5# literal 0 HcmV?d00001 diff --git a/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0c33c2af09a2b6e259f7a5539b4371a6e75916a5 GIT binary patch literal 65425 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 literal 0 HcmV?d00001 diff --git a/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000000000000000000000000000000000000..eee229742cf9e39db382585fa08444be4ade1374 GIT binary patch literal 18581 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 GzyE(ps~!vh literal 0 HcmV?d00001 diff --git a/tests/test_workflows/test_engine.py b/tests/test_workflows/test_engine.py new file mode 100644 index 0000000..ee6855a --- /dev/null +++ b/tests/test_workflows/test_engine.py @@ -0,0 +1,307 @@ +import pytest + +from infrasynth.shared.enums import ApprovalStrategy +from infrasynth.workflows.engine import WorkflowEngine +from infrasynth.workflows.models import ( + NodeAssignment, + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def approval_workflow(): + workflow = Workflow.objects.create(slug="approval", name="Approval", created_by=None) + start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START) + review = WorkflowNode.objects.create(workflow=workflow, name="Review", node_type=WorkflowNode.NodeType.INTERMEDIATE) + end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END) + Transition.objects.create(from_node=start, to_node=review, condition_slug="approved") + Transition.objects.create(from_node=review, to_node=end, condition_slug="approved") + return workflow + + +class TestStart: + def test_starts_at_start_node(self, user, approval_workflow): + instance = WorkflowEngine().start("approval", owner=user) + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + assert instance.current_node.name == "Start" + assert instance.owner_id == user.id + + def test_unknown_workflow_raises(self, user): + with pytest.raises(ValueError, match="not found"): + WorkflowEngine().start("missing", owner=user) + + def test_inactive_workflow_raises(self, user): + workflow = Workflow.objects.create(slug="inactive-wf", name="Inactive") + workflow.is_active = False + workflow.save(update_fields=["is_active"]) + with pytest.raises(ValueError, match="inactive"): + WorkflowEngine().start("inactive-wf", owner=user) + + def test_workflow_without_nodes_raises(self, user): + Workflow.objects.create(slug="no-nodes", name="No nodes") + with pytest.raises(ValueError, match="no nodes"): + WorkflowEngine().start("no-nodes", owner=user) + + def test_emits_instance_started_signal(self, user, approval_workflow): + from infrasynth.workflows.signals import instance_started + + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + instance_started.connect(receiver, weak=False) + instance = WorkflowEngine().start("approval", owner=user) + assert sent + assert sent[0]["instance"].id == instance.id + assert sent[0]["workflow_slug"] == "approval" + instance_started.disconnect(receiver) + + +class TestSubmitDecision: + def test_approve_advances_node(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + + instance = engine.submit_decision(assignment.id, "approved", user=user) + assert instance.current_node.name == "Review" + assignment.refresh_from_db() + assert assignment.has_processed is True + assert assignment.decision == "approved" + + def test_second_submission_rejected(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + engine.submit_decision(assignment.id, "approved", user=user) + with pytest.raises(ValueError, match="already been processed"): + engine.submit_decision(assignment.id, "approved", user=user) + + def test_wrong_user_cannot_submit(self, user, approval_workflow, admin_user): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + with pytest.raises(PermissionError, match="not assigned"): + engine.submit_decision(assignment.id, "approved", user=admin_user) + + def test_decision_without_transition_stays(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + instance = engine.submit_decision(assignment.id, "escalated", user=user) + assert instance.current_node.name == "Start" + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + + def test_completes_at_end_node(self, user, approval_workflow): + from infrasynth.workflows.signals import instance_completed + + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + first = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + instance = engine.submit_decision(first.id, "approved", user=user) + + second = NodeAssignment.objects.get(instance=instance, node__name="Review", user=user) + completed = [] + receiver = lambda **kw: completed.append(kw) # noqa: E731 + instance_completed.connect(receiver, weak=False) + instance = engine.submit_decision(second.id, "approved", user=user) + assert instance.status == WorkflowInstance.Status.COMPLETED + assert instance.completed_at is not None + assert instance.current_node.name == "End" + assert completed + assert completed[0]["workflow_slug"] == "approval" + instance_completed.disconnect(receiver) + + def test_emits_decision_submitted_signal(self, user, approval_workflow): + from infrasynth.workflows.signals import decision_submitted + + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + decision_submitted.connect(receiver, weak=False) + engine.submit_decision(assignment.id, "approved", user=user, comments="OK") + assert sent + assert sent[0]["decision"] == "approved" + assert sent[0]["user"] == user + decision_submitted.disconnect(receiver) + + +class TestApprovalStrategies: + def _setup(self, strategy, min_approvals=1): + workflow = Workflow.objects.create(slug="wf-strat", name="Strategy") + node = WorkflowNode.objects.create( + workflow=workflow, + name="Gate", + node_type=WorkflowNode.NodeType.START, + approval_strategy=strategy, + min_approvals=min_approvals, + ) + end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END) + Transition.objects.create(from_node=node, to_node=end, condition_slug="go") + return workflow, node + + def test_all_strategy_requires_all(self, user, admin_user): + workflow, node = self._setup(ApprovalStrategy.ALL) + engine = WorkflowEngine() + instance = engine.start("wf-strat", owner=user) + a1, a2 = engine.assign_users(instance.pk, node.id, [user, admin_user]) + instance = engine.submit_decision(a1.id, "go", user=user) + assert instance.current_node.id == node.id + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + instance = engine.submit_decision(a2.id, "go", user=admin_user) + assert instance.status == WorkflowInstance.Status.COMPLETED + + def test_any_strategy_advances_with_one(self, user, admin_user): + workflow, node = self._setup(ApprovalStrategy.ANY) + engine = WorkflowEngine() + instance = engine.start("wf-strat", owner=user) + a1, _ = engine.assign_users(instance.pk, node.id, [user, admin_user]) + instance = engine.submit_decision(a1.id, "go", user=user) + assert instance.status == WorkflowInstance.Status.COMPLETED + + def test_majority_strategy(self, user, admin_user): + workflow, node = self._setup(ApprovalStrategy.MAJORITY) + other = admin_user.__class__.objects.create_user( + username="third", email="third@example.com", password="pw123456" + ) + engine = WorkflowEngine() + instance = engine.start("wf-strat", owner=user) + a1, a2, _ = engine.assign_users(instance.pk, node.id, [user, admin_user, other]) + instance = engine.submit_decision(a1.id, "go", user=user) + assert instance.current_node.id == node.id + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + a2 = NodeAssignment.objects.get(instance=instance, user=admin_user, node=node) + instance = engine.submit_decision(a2.id, "go", user=admin_user) + assert instance.status == WorkflowInstance.Status.COMPLETED + + +class TestNodeStates: + def test_states_after_approval(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + states = engine.get_node_states(instance.pk) + assert states[instance.current_node_id] == "ACTIVE" + + engine.submit_decision(assignment.id, "approved", user=user) + states = engine.get_node_states(instance.pk) + start_node = approval_workflow.nodes.get(name="Start") + review_node = approval_workflow.nodes.get(name="Review") + assert states[start_node.id] == "COMPLETED" + assert states[review_node.id] == "ACTIVE" + + def test_rejected_state(self, user, approval_workflow): + review = approval_workflow.nodes.get(name="Review") + rejection = WorkflowNode.objects.create( + workflow=approval_workflow, + name="Rejected", + node_type=WorkflowNode.NodeType.END, + ) + Transition.objects.create(from_node=review, to_node=rejection, condition_slug="rejected") + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + first = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + instance = engine.submit_decision(first.id, "approved", user=user) + second = NodeAssignment.objects.get(instance=instance, node=review, user=user) + instance = engine.submit_decision(second.id, "rejected", user=user) + states = engine.get_node_states(instance.pk) + assert states[review.id] == "REJECTED" + assert instance.status == WorkflowInstance.Status.COMPLETED + + +class TestRoute: + def test_route_lists_processed_nodes(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + engine.submit_decision(assignment.id, "approved", user=user, comments="go") + + route = engine.get_route(instance.pk) + assert any( + entry["node_name"] == "Start" + and entry["decision"] == "approved" + and entry["comments"] == "go" + and entry["user"]["id"] == user.id + for entry in route + ) + + +class TestRoles: + def test_owner(self, user, approval_workflow): + instance = WorkflowEngine().start("approval", owner=user) + assert WorkflowEngine().get_role_in_instance(instance.pk, user) == "OWNER" + + def test_assignee(self, user, admin_user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=admin_user) + engine.assign_users(instance.pk, instance.current_node_id, [user]) + assert engine.get_role_in_instance(instance.pk, user) == "ASSIGNEE" + + def test_observer(self, user, admin_user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=admin_user) + engine.add_observer(instance.pk, user) + assert engine.get_role_in_instance(instance.pk, user) == "OBSERVER" + + def test_none(self, user, admin_user, approval_workflow): + instance = WorkflowEngine().start("approval", owner=admin_user) + assert WorkflowEngine().get_role_in_instance(instance.pk, user) == "NONE" + assert WorkflowEngine().get_role_in_instance(instance.pk, None) == "NONE" + + def test_processed_assignee_becomes_none(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + engine.submit_decision(assignment.id, "approved", user=user) + assert engine.get_role_in_instance(instance.pk, user) == "OWNER" + + +class TestAssignAndObservers: + def test_assign_users_creates_assignments(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + node = instance.current_node + assignments = engine.assign_users(instance.pk, node.id, [user]) + assert len(assignments) == 1 + assert assignments[0].instance_id == instance.pk + assert assignments[0].visit_number == 1 + assert assignments[0].is_required is True + + def test_assign_again_increments_visit(self, user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=user) + node = instance.current_node + engine.assign_users(instance.pk, node.id, [user]) + engine.assign_users(instance.pk, node.id, [user], is_required=False) + assignments = NodeAssignment.objects.filter(instance=instance, node=node) + assert assignments.count() == 2 + assert assignments.order_by("visit_number").last().visit_number == 2 + + def test_add_observer(self, user, admin_user, approval_workflow): + engine = WorkflowEngine() + instance = engine.start("approval", owner=admin_user) + observer = engine.add_observer(instance.pk, user) + assert observer.instance_id == instance.pk + assert WorkflowObserver.objects.filter(instance=instance, user=user).count() == 1 + engine.add_observer(instance.pk, user) + assert WorkflowObserver.objects.filter(instance=instance, user=user).count() == 1 + + +class TestDefaultTransition: + def test_default_transition_used_when_no_condition_match(self, user): + workflow = Workflow.objects.create(slug="wf-default", name="Default") + start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START) + end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END) + Transition.objects.create(from_node=start, to_node=end, condition_slug="approved") + Transition.objects.create(from_node=start, to_node=end, condition_slug="", is_default=True) + engine = WorkflowEngine() + instance = engine.start("wf-default", owner=user) + assignment = engine.assign_users(instance.pk, instance.current_node_id, [user])[0] + instance = engine.submit_decision(assignment.id, "anything", user=user) + assert instance.status == WorkflowInstance.Status.COMPLETED diff --git a/tests/test_workflows/test_models.py b/tests/test_workflows/test_models.py new file mode 100644 index 0000000..822b942 --- /dev/null +++ b/tests/test_workflows/test_models.py @@ -0,0 +1,78 @@ +import pytest + +from infrasynth.workflows.models import ( + Transition, + Workflow, + WorkflowAwareModel, + WorkflowInstance, + WorkflowNode, +) + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def workflow(user): + return Workflow.objects.create(slug="approval", name="Approval", created_by=user) + + +class TestWorkflow: + def test_create(self): + workflow = Workflow.objects.create(slug="approval", name="Approval") + assert workflow.is_active is True + assert str(workflow) == "Approval" + + def test_unique_slug(self): + Workflow.objects.create(slug="approval", name="A") + with pytest.raises(Exception): + Workflow.objects.create(slug="approval", name="B") + + +class TestWorkflowNode: + def test_create(self, workflow): + node = WorkflowNode.objects.create( + workflow=workflow, + name="Start", + node_type=WorkflowNode.NodeType.START, + ) + assert node.node_type == "start" + assert node.approval_strategy == "all" + assert node.min_approvals == 1 + assert str(node) == "approval:Start" + + def test_unique_name_per_workflow(self, workflow): + WorkflowNode.objects.create(workflow=workflow, name="A") + with pytest.raises(Exception): + WorkflowNode.objects.create(workflow=workflow, name="A") + + +class TestTransition: + def test_create(self, workflow): + start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START) + end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END) + transition = Transition.objects.create(from_node=start, to_node=end, condition_slug="approved") + assert transition.from_node == start + assert transition.to_node == end + assert str(transition) == "approval:Start → approval:End" + + +class TestWorkflowInstance: + def test_default_status(self, workflow, user): + instance = WorkflowInstance.objects.create(workflow=workflow, owner=user) + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + assert instance.completed_at is None + + +class TestWorkflowAwareModel: + def test_abstract_with_instance_fk(self): + from django.db.models import ForeignKey + + assert WorkflowAwareModel._meta.abstract is True + field = WorkflowAwareModel._meta.get_field("workflow_instance") + assert isinstance(field, ForeignKey) + assert field.null is True + assert field.blank is True + + def test_assignable_workflow_instance(self, workflow, user): + WorkflowInstance.objects.create(workflow=workflow, owner=user) + assert workflow.instances.count() == 1 diff --git a/tests/test_workflows/test_validators.py b/tests/test_workflows/test_validators.py new file mode 100644 index 0000000..210fd8b --- /dev/null +++ b/tests/test_workflows/test_validators.py @@ -0,0 +1,52 @@ +import pytest + +from infrasynth.workflows.validators import ( + DataValidatorProtocol, + DataValidatorRegistry, +) + + +class _RequiredFieldValidator: + def validate(self, node, data, context): + if "resolution_note" not in data: + raise ValueError("resolution_note is required") + return data + + +class TestDataValidatorProtocol: + def test_protocol_runtime_check(self): + assert isinstance(_RequiredFieldValidator(), DataValidatorProtocol) + + def test_non_matching_class_not_compatible(self): + class NotAValidator: + pass + + assert not isinstance(NotAValidator(), DataValidatorProtocol) + + +class TestDataValidatorRegistry: + def teardown_method(self): + DataValidatorRegistry._validators.clear() + + def test_register_and_get(self): + validator = _RequiredFieldValidator() + DataValidatorRegistry.register("ticket_approval", validator) + assert DataValidatorRegistry.get("ticket_approval") is validator + + def test_get_unknown_returns_none(self): + assert DataValidatorRegistry.get("unknown") is None + + def test_register_overwrites(self): + v1 = _RequiredFieldValidator() + v2 = _RequiredFieldValidator() + DataValidatorRegistry.register("wf", v1) + DataValidatorRegistry.register("wf", v2) + assert DataValidatorRegistry.get("wf") is v2 + + def test_validator_raises_on_missing_field(self): + with pytest.raises(ValueError, match="resolution_note"): + _RequiredFieldValidator().validate(None, {}, {}) + + def test_validator_passes_with_field(self): + data = {"resolution_note": "Fixed"} + assert _RequiredFieldValidator().validate(None, data, {}) == data diff --git a/tests/test_workflows/test_views.py b/tests/test_workflows/test_views.py new file mode 100644 index 0000000..743be54 --- /dev/null +++ b/tests/test_workflows/test_views.py @@ -0,0 +1,212 @@ +import pytest + +from infrasynth.features.models import FeatureFlag +from infrasynth.workflows.models import ( + Transition, + Workflow, + WorkflowInstance, + WorkflowNode, + WorkflowObserver, +) + +pytestmark = pytest.mark.django_db + +DEFINITIONS_URL = "/api/workflows/definitions/" +INSTANCES_URL = "/api/workflows/instances/" +NODES_URL = "/api/workflows/nodes/" +TRANSITIONS_URL = "/api/workflows/transitions/" +ASSIGNMENTS_URL = "/api/workflows/assignments/" +OBSERVERS_URL = "/api/workflows/observers/" + + +@pytest.fixture +def workflow(user): + return Workflow.objects.create(slug="approval", name="Approval", created_by=user) + + +@pytest.fixture +def workflow_graph(workflow): + start = WorkflowNode.objects.create(workflow=workflow, name="Start", node_type=WorkflowNode.NodeType.START) + end = WorkflowNode.objects.create(workflow=workflow, name="End", node_type=WorkflowNode.NodeType.END) + Transition.objects.create(from_node=start, to_node=end, condition_slug="approved") + return {"start": start, "end": end} + + +class TestWorkflowViewSet: + def test_list_workflows(self, authenticated_client, workflow): + response = authenticated_client.get(DEFINITIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["slug"] == "approval" + + def test_create_workflow(self, authenticated_client): + response = authenticated_client.post( + DEFINITIONS_URL, + {"slug": "leave-request", "name": "Leave Request"}, + format="json", + ) + assert response.status_code == 201 + assert Workflow.objects.filter(slug="leave-request").exists() + + def test_requires_auth(self, api_client, workflow): + assert api_client.get(DEFINITIONS_URL).status_code == 401 + + def test_disabled_feature_returns_404(self, authenticated_client): + FeatureFlag.objects.create(slug="workflows", name="Workflows", is_active=False) + assert authenticated_client.get(DEFINITIONS_URL).status_code == 404 + + def test_retrieve_delete(self, authenticated_client, workflow): + response = authenticated_client.get(f"{DEFINITIONS_URL}{workflow.id}/") + assert response.status_code == 200 + response = authenticated_client.delete(f"{DEFINITIONS_URL}{workflow.id}/") + assert response.status_code == 204 + assert not Workflow.objects.filter(pk=workflow.pk).exists() + + +class TestWorkflowInstanceViewSet: + def test_create_starts_instance(self, authenticated_client, workflow_graph, user): + response = authenticated_client.post( + INSTANCES_URL, + {"workflow": workflow_graph["start"].workflow_id}, + format="json", + ) + assert response.status_code == 201 + instance = WorkflowInstance.objects.get() + assert instance.status == WorkflowInstance.Status.IN_PROGRESS + assert instance.current_node_id == workflow_graph["start"].id + assert instance.owner_id == user.id + + def test_list_instances(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + WorkflowEngine().start("approval", owner=user) + response = authenticated_client.get(INSTANCES_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["status"] == "in_progress" + + def test_requires_auth(self, api_client, workflow_graph): + assert api_client.get(INSTANCES_URL).status_code == 401 + + def test_route_action(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + response = authenticated_client.get(f"{INSTANCES_URL}{instance.pk}/route/") + assert response.status_code == 200 + assert "route" in response.data + + def test_state_action(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + response = authenticated_client.get(f"{INSTANCES_URL}{instance.pk}/state/") + assert response.status_code == 200 + assert "state" in response.data + states = response.data["state"] + assert "ACTIVE" in states.values() + assert all(state in ("ACTIVE", "PENDING", "COMPLETED", "REJECTED") for state in states.values()) + + def test_submit_action(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + assignment = WorkflowEngine().assign_users(instance.pk, instance.current_node_id, [user])[0] + response = authenticated_client.post( + f"{INSTANCES_URL}{instance.pk}/submit/", + {"assignment_id": assignment.id, "decision": "approved"}, + format="json", + ) + assert response.status_code == 200 + assert response.data["status"] == "completed" + assert response.data["current_node"]["name"] == "End" + + def test_submit_requires_fields(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + response = authenticated_client.post(f"{INSTANCES_URL}{instance.pk}/submit/", {}, format="json") + assert response.status_code == 400 + + def test_assign_action(self, authenticated_client, workflow_graph, user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=user) + response = authenticated_client.post( + f"{INSTANCES_URL}{instance.pk}/assign/", + { + "node_id": instance.current_node_id, + "users": [user.id], + }, + format="json", + ) + assert response.status_code == 201 + assert len(response.data) == 1 + + def test_observers_add_and_delete(self, authenticated_client, workflow_graph, user, admin_user): + from infrasynth.workflows.engine import WorkflowEngine + + instance = WorkflowEngine().start("approval", owner=admin_user) + response = authenticated_client.post( + f"{INSTANCES_URL}{instance.pk}/observers/", + {"user_id": user.id}, + format="json", + ) + assert response.status_code == 201 + assert WorkflowObserver.objects.filter(instance=instance, user=user).exists() + + response = authenticated_client.delete( + f"{INSTANCES_URL}{instance.pk}/observers/", + {"user_id": user.id}, + format="json", + ) + assert response.status_code == 204 + assert not WorkflowObserver.objects.filter(instance=instance, user=user).exists() + + +class TestWorkflowNodeViewSet: + def test_list_nodes(self, authenticated_client, workflow_graph): + response = authenticated_client.get(NODES_URL) + assert response.status_code == 200 + assert response.data["count"] == 2 + + def test_create_node(self, authenticated_client, workflow): + response = authenticated_client.post( + NODES_URL, + { + "workflow": workflow.id, + "name": "QA", + "node_type": "intermediate", + "approval_strategy": "any", + }, + format="json", + ) + assert response.status_code == 201 + assert WorkflowNode.objects.filter(name="QA").exists() + + def test_requires_auth(self, api_client, workflow_graph): + assert api_client.get(NODES_URL).status_code == 401 + + +class TestTransitionViewSet: + def test_list_transitions(self, authenticated_client, workflow_graph): + response = authenticated_client.get(TRANSITIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + + def test_create_transition(self, authenticated_client, workflow_graph): + start, end = workflow_graph["start"], workflow_graph["end"] + response = authenticated_client.post( + TRANSITIONS_URL, + { + "from_node": start.id, + "to_node": end.id, + "condition_slug": "rejected", + }, + format="json", + ) + assert response.status_code == 201 + assert Transition.objects.filter(condition_slug="rejected").exists() + + def test_requires_auth(self, api_client, workflow_graph): + assert api_client.get(TRANSITIONS_URL).status_code == 401