commit 75c3c7b2c28542aa6051a5b9561d7fc2ca13939f Author: jcv-dev Date: Fri Aug 28 14:38:47 2026 -0500 Lua update diff --git a/.coverage b/.coverage new file mode 100644 index 0000000..453678a Binary files /dev/null and b/.coverage differ 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 0000000..62be409 Binary files /dev/null and b/config/__pycache__/__init__.cpython-314.pyc differ diff --git a/config/__pycache__/celery.cpython-314.pyc b/config/__pycache__/celery.cpython-314.pyc new file mode 100644 index 0000000..5de0edc Binary files /dev/null and b/config/__pycache__/celery.cpython-314.pyc differ diff --git a/config/__pycache__/urls.cpython-314.pyc b/config/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..721a8e1 Binary files /dev/null and b/config/__pycache__/urls.cpython-314.pyc differ 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 0000000..283ada4 Binary files /dev/null and b/config/settings/__pycache__/__init__.cpython-314.pyc differ diff --git a/config/settings/__pycache__/base.cpython-314.pyc b/config/settings/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..8fe5348 Binary files /dev/null and b/config/settings/__pycache__/base.cpython-314.pyc differ diff --git a/config/settings/__pycache__/dev.cpython-314.pyc b/config/settings/__pycache__/dev.cpython-314.pyc new file mode 100644 index 0000000..c13c9df Binary files /dev/null and b/config/settings/__pycache__/dev.cpython-314.pyc differ diff --git a/config/settings/__pycache__/test.cpython-314.pyc b/config/settings/__pycache__/test.cpython-314.pyc new file mode 100644 index 0000000..2bc457d Binary files /dev/null and b/config/settings/__pycache__/test.cpython-314.pyc differ 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 0000000..40be78b Binary files /dev/null and b/docs/04dd318c64774fb08ece1060dbf8c7d0/img.png differ diff --git a/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png b/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png new file mode 100644 index 0000000..40be78b Binary files /dev/null and b/docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png differ 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 0000000..40be78b Binary files /dev/null and b/docs/380be0205e7d4da7b718e96cb62ae2b4/img.png differ 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 0000000..40be78b Binary files /dev/null and b/docs/569c1d2348dd42348e16221e92268ded/img.png differ 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 0000000..40be78b Binary files /dev/null and b/docs/60ac6c34c5c4442f9b2db03580f3073e/img.png differ diff --git a/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png b/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png new file mode 100644 index 0000000..40be78b Binary files /dev/null and b/docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png differ diff --git a/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png b/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png new file mode 100644 index 0000000..40be78b Binary files /dev/null and b/docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png differ diff --git a/docs/98fd1b7317fc46668f945560910d0d77/img.png b/docs/98fd1b7317fc46668f945560910d0d77/img.png new file mode 100644 index 0000000..40be78b Binary files /dev/null and b/docs/98fd1b7317fc46668f945560910d0d77/img.png differ 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 0000000..beab4b0 Binary files /dev/null and b/infrasynth/__pycache__/__init__.cpython-314.pyc differ 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 0000000..e4aa24d Binary files /dev/null and b/infrasynth/audit/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/apps.cpython-314.pyc b/infrasynth/audit/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..fcd9ef5 Binary files /dev/null and b/infrasynth/audit/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/filters.cpython-314.pyc b/infrasynth/audit/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..d678554 Binary files /dev/null and b/infrasynth/audit/__pycache__/filters.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/middleware.cpython-314.pyc b/infrasynth/audit/__pycache__/middleware.cpython-314.pyc new file mode 100644 index 0000000..30d57f6 Binary files /dev/null and b/infrasynth/audit/__pycache__/middleware.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/models.cpython-314.pyc b/infrasynth/audit/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..1c8f79c Binary files /dev/null and b/infrasynth/audit/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/receivers.cpython-314.pyc b/infrasynth/audit/__pycache__/receivers.cpython-314.pyc new file mode 100644 index 0000000..102b481 Binary files /dev/null and b/infrasynth/audit/__pycache__/receivers.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/serializers.cpython-314.pyc b/infrasynth/audit/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..4781714 Binary files /dev/null and b/infrasynth/audit/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/signals.cpython-314.pyc b/infrasynth/audit/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..64dc20b Binary files /dev/null and b/infrasynth/audit/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/urls.cpython-314.pyc b/infrasynth/audit/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..6f7cf18 Binary files /dev/null and b/infrasynth/audit/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/audit/__pycache__/views.cpython-314.pyc b/infrasynth/audit/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..b10b7ff Binary files /dev/null and b/infrasynth/audit/__pycache__/views.cpython-314.pyc differ 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 0000000..62bca94 Binary files /dev/null and b/infrasynth/audit/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..07a141d Binary files /dev/null and b/infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..6ec94ae Binary files /dev/null and b/infrasynth/billing/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/apps.cpython-314.pyc b/infrasynth/billing/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..ffca14b Binary files /dev/null and b/infrasynth/billing/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/filters.cpython-314.pyc b/infrasynth/billing/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..14452e2 Binary files /dev/null and b/infrasynth/billing/__pycache__/filters.cpython-314.pyc differ 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 0000000..20b41ba Binary files /dev/null and b/infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/models.cpython-314.pyc b/infrasynth/billing/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..a23d431 Binary files /dev/null and b/infrasynth/billing/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/serializers.cpython-314.pyc b/infrasynth/billing/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..f74a747 Binary files /dev/null and b/infrasynth/billing/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/services.cpython-314.pyc b/infrasynth/billing/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..f76e46b Binary files /dev/null and b/infrasynth/billing/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/signals.cpython-314.pyc b/infrasynth/billing/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..f503896 Binary files /dev/null and b/infrasynth/billing/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/urls.cpython-314.pyc b/infrasynth/billing/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..413e011 Binary files /dev/null and b/infrasynth/billing/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/billing/__pycache__/views.cpython-314.pyc b/infrasynth/billing/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..9802bc5 Binary files /dev/null and b/infrasynth/billing/__pycache__/views.cpython-314.pyc differ 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 0000000..00f925f Binary files /dev/null and b/infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc new file mode 100644 index 0000000..d2c0caf Binary files /dev/null and b/infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc differ diff --git a/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc new file mode 100644 index 0000000..2c04963 Binary files /dev/null and b/infrasynth/billing/gateways/__pycache__/mercadopago.cpython-314.pyc differ diff --git a/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc new file mode 100644 index 0000000..2cda5e1 Binary files /dev/null and b/infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc differ diff --git a/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc b/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc new file mode 100644 index 0000000..95cd5d7 Binary files /dev/null and b/infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc differ diff --git a/infrasynth/billing/gateways/base.py b/infrasynth/billing/gateways/base.py new file mode 100644 index 0000000..1d176af --- /dev/null +++ b/infrasynth/billing/gateways/base.py @@ -0,0 +1,36 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass + + +@dataclass +class CheckoutSessionResult: + session_id: str + checkout_url: str + client_secret: str + + +@dataclass +class WebhookResult: + event_type: str + is_handled: bool + data: dict + + +class BasePaymentGateway(ABC): + @abstractmethod + def create_checkout_session(self, plan, user, **kwargs) -> 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 0000000..7a5f2da Binary files /dev/null and b/infrasynth/billing/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..9332614 Binary files /dev/null and b/infrasynth/billing/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..723ebce Binary files /dev/null and b/infrasynth/features/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/apps.cpython-314.pyc b/infrasynth/features/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..771a9f3 Binary files /dev/null and b/infrasynth/features/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/decorators.cpython-314.pyc b/infrasynth/features/__pycache__/decorators.cpython-314.pyc new file mode 100644 index 0000000..bfb62bd Binary files /dev/null and b/infrasynth/features/__pycache__/decorators.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/models.cpython-314.pyc b/infrasynth/features/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..dcfb383 Binary files /dev/null and b/infrasynth/features/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/registry.cpython-314.pyc b/infrasynth/features/__pycache__/registry.cpython-314.pyc new file mode 100644 index 0000000..3ee7df2 Binary files /dev/null and b/infrasynth/features/__pycache__/registry.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/serializers.cpython-314.pyc b/infrasynth/features/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..987a449 Binary files /dev/null and b/infrasynth/features/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/services.cpython-314.pyc b/infrasynth/features/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..2300c2e Binary files /dev/null and b/infrasynth/features/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/urls.cpython-314.pyc b/infrasynth/features/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..fbfe8e1 Binary files /dev/null and b/infrasynth/features/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/features/__pycache__/views.cpython-314.pyc b/infrasynth/features/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..2fd36a6 Binary files /dev/null and b/infrasynth/features/__pycache__/views.cpython-314.pyc differ diff --git a/infrasynth/features/apps.py b/infrasynth/features/apps.py new file mode 100644 index 0000000..96e9b05 --- /dev/null +++ b/infrasynth/features/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class FeaturesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.features" + label = "infrasynth_features" + + def ready(self): + from .registry import FeatureRegistry + + FeatureRegistry.register( + "features", + name="Feature Flags", + description="Feature flag system itself — never disabled", + default=True, + category="system", + ) diff --git a/infrasynth/features/decorators.py b/infrasynth/features/decorators.py new file mode 100644 index 0000000..0fff437 --- /dev/null +++ b/infrasynth/features/decorators.py @@ -0,0 +1,21 @@ +from functools import wraps + +from rest_framework.exceptions import NotFound + +from .services import FeatureService + + +def feature_required(slug: str): + """Decorator for views that require a feature flag to be enabled.""" + + def decorator(view_func): + @wraps(view_func) + def wrapper(request, *args, **kwargs): + fs = FeatureService() + if not fs.is_enabled(slug, user=getattr(request, "user", None)): + raise NotFound() + return view_func(request, *args, **kwargs) + + return wrapper + + return decorator diff --git a/infrasynth/features/filters.py b/infrasynth/features/filters.py new file mode 100644 index 0000000..55d9a9b --- /dev/null +++ b/infrasynth/features/filters.py @@ -0,0 +1,14 @@ +import django_filters + +from .models import FeatureFlag + + +class FeatureFlagFilter(django_filters.FilterSet): + class Meta: + model = FeatureFlag + fields = { + "slug": ["exact", "in"], + "is_active": ["exact"], + "category": ["exact"], + "tenant_id": ["exact", "isnull"], + } diff --git a/infrasynth/features/migrations/0001_initial.py b/infrasynth/features/migrations/0001_initial.py new file mode 100644 index 0000000..787e1bf --- /dev/null +++ b/infrasynth/features/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# 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 = [ + ("auth", "0012_alter_user_first_name_max_length"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="FeatureFlag", + 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)), + ("description", models.TextField(blank=True)), + ("is_active", models.BooleanField(default=False)), + ( + "rollout_percentage", + models.PositiveSmallIntegerField(default=100, help_text="0-100. 100 = all users"), + ), + ( + "tenant_id", + models.UUIDField(blank=True, help_text="Null = global. Value = tenant-specific", null=True), + ), + ("environments", models.JSONField(default=list, help_text='["production", "staging"] or [] = all')), + ("category", models.CharField(blank=True, help_text="Grouping for UI", max_length=50)), + ("metadata", models.JSONField(default=dict)), + ], + options={ + "db_table": "features_flag", + "unique_together": {("slug", "tenant_id")}, + }, + ), + migrations.CreateModel( + name="FeatureFlagOverride", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("is_enabled", models.BooleanField()), + ( + "flag", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="overrides", + to="infrasynth_features.featureflag", + ), + ), + ( + "group", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to="auth.group", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="+", + to=settings.AUTH_USER_MODEL, + ), + ), + ], + options={ + "db_table": "features_override", + "unique_together": {("flag", "group"), ("flag", "user")}, + }, + ), + ] diff --git a/infrasynth/features/migrations/__init__.py b/infrasynth/features/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..e660de4 Binary files /dev/null and b/infrasynth/features/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..b24df67 Binary files /dev/null and b/infrasynth/features/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/features/models.py b/infrasynth/features/models.py new file mode 100644 index 0000000..be579ce --- /dev/null +++ b/infrasynth/features/models.py @@ -0,0 +1,41 @@ +from django.conf import settings +from django.db import models + + +class FeatureFlag(models.Model): + 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 = all users") + tenant_id = models.UUIDField(null=True, blank=True, help_text="Null = global. Value = tenant-specific") + environments = models.JSONField(default=list, help_text='["production", "staging"] or [] = all') + category = models.CharField(max_length=50, blank=True, help_text="Grouping for UI") + metadata = models.JSONField(default=dict) + + class Meta: + db_table = "features_flag" + unique_together = [("slug", "tenant_id")] + + +class FeatureFlagOverride(models.Model): + 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")] diff --git a/infrasynth/features/registry.py b/infrasynth/features/registry.py new file mode 100644 index 0000000..971c5c3 --- /dev/null +++ b/infrasynth/features/registry.py @@ -0,0 +1,42 @@ +import logging +from dataclasses import dataclass + +logger = logging.getLogger(__name__) + + +@dataclass +class FeatureDefinition: + slug: str + name: str = "" + description: str = "" + default: bool = True + category: str | None = None + + +class FeatureRegistry: + """Registry where every app registers its feature flags in apps.py:ready().""" + + _features: dict[str, FeatureDefinition] = {} + + @classmethod + def register( + cls, + slug: str, + *, + name: str = "", + description: str = "", + default: bool = True, + category: str | None = None, + ): + cls._features[slug] = FeatureDefinition( + slug=slug, + name=name or slug, + description=description, + default=default, + category=category, + ) + logger.debug(f"Feature flag registered: {slug} (default={default})") + + @classmethod + def get_all(cls) -> 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 0000000..a9916d9 Binary files /dev/null and b/infrasynth/files/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/apps.cpython-314.pyc b/infrasynth/files/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..57cc910 Binary files /dev/null and b/infrasynth/files/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/models.cpython-314.pyc b/infrasynth/files/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..eb5064e Binary files /dev/null and b/infrasynth/files/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/processing.cpython-314.pyc b/infrasynth/files/__pycache__/processing.cpython-314.pyc new file mode 100644 index 0000000..0e12b92 Binary files /dev/null and b/infrasynth/files/__pycache__/processing.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/serializers.cpython-314.pyc b/infrasynth/files/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..ef64b45 Binary files /dev/null and b/infrasynth/files/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/services.cpython-314.pyc b/infrasynth/files/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..215a030 Binary files /dev/null and b/infrasynth/files/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/signals.cpython-314.pyc b/infrasynth/files/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..db5c027 Binary files /dev/null and b/infrasynth/files/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/storage.cpython-314.pyc b/infrasynth/files/__pycache__/storage.cpython-314.pyc new file mode 100644 index 0000000..c420315 Binary files /dev/null and b/infrasynth/files/__pycache__/storage.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/urls.cpython-314.pyc b/infrasynth/files/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..1b7a0db Binary files /dev/null and b/infrasynth/files/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/files/__pycache__/views.cpython-314.pyc b/infrasynth/files/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..375843d Binary files /dev/null and b/infrasynth/files/__pycache__/views.cpython-314.pyc differ diff --git a/infrasynth/files/apps.py b/infrasynth/files/apps.py new file mode 100644 index 0000000..6612fae --- /dev/null +++ b/infrasynth/files/apps.py @@ -0,0 +1,18 @@ +from django.apps import AppConfig + + +class FilesConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "infrasynth.files" + label = "infrasynth_files" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register( + "files", + name="Files", + description="File storage, uploads, and pipeline processing", + default=True, + category="storage", + ) diff --git a/infrasynth/files/filters.py b/infrasynth/files/filters.py new file mode 100644 index 0000000..05d85d1 --- /dev/null +++ b/infrasynth/files/filters.py @@ -0,0 +1,35 @@ +import django_filters + +from .models import FileCategory, ProcessingPipeline, StoredFile + + +class StoredFileFilter(django_filters.FilterSet): + class Meta: + model = StoredFile + fields = { + "storage_backend": ["exact"], + "mime_type": ["exact", "startswith"], + "is_public": ["exact"], + "category": ["exact", "isnull"], + "uploaded_by": ["exact"], + "created_at": ["exact", "gte", "lte"], + } + + +class FileCategoryFilter(django_filters.FilterSet): + class Meta: + model = FileCategory + fields = { + "slug": ["exact", "in"], + "is_active": ["exact"], + "storage_backend_override": ["exact"], + } + + +class ProcessingPipelineFilter(django_filters.FilterSet): + class Meta: + model = ProcessingPipeline + fields = { + "slug": ["exact", "in"], + "is_active": ["exact"], + } diff --git a/infrasynth/files/migrations/0001_initial.py b/infrasynth/files/migrations/0001_initial.py new file mode 100644 index 0000000..2e31b14 --- /dev/null +++ b/infrasynth/files/migrations/0001_initial.py @@ -0,0 +1,136 @@ +# 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="FileCategory", + fields=[ + ("slug", models.SlugField(max_length=100, primary_key=True, serialize=False)), + ("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(blank=True, null=True)), + ("is_active", models.BooleanField(default=True)), + ("storage_backend_override", models.CharField(blank=True, max_length=50)), + ], + options={ + "db_table": "files_category", + }, + ), + migrations.CreateModel( + name="ProcessingPipeline", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("name", models.CharField(max_length=200)), + ("slug", models.SlugField(max_length=100, unique=True)), + ("steps", models.JSONField()), + ("is_active", models.BooleanField(default=True)), + ], + options={ + "db_table": "files_pipeline", + }, + ), + migrations.CreateModel( + name="StoredFile", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("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)), + ("metadata", models.JSONField(default=dict)), + ("created_at", models.DateTimeField(auto_now_add=True)), + ( + "category", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_files.filecategory", + ), + ), + ( + "uploaded_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": "files_stored_file", + }, + ), + migrations.CreateModel( + name="PipelineExecution", + fields=[ + ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "status", + models.CharField( + choices=[ + ("pending", "Pending"), + ("running", "Running"), + ("completed", "Completed"), + ("failed", "Failed"), + ], + default="pending", + max_length=20, + ), + ), + ("started_at", models.DateTimeField(blank=True, null=True)), + ("completed_at", models.DateTimeField(blank=True, null=True)), + ("error", models.TextField(blank=True)), + ( + "pipeline", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_files.processingpipeline", + ), + ), + ( + "file", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="pipeline_executions", + to="infrasynth_files.storedfile", + ), + ), + ( + "output_file", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="+", + to="infrasynth_files.storedfile", + ), + ), + ], + options={ + "db_table": "files_pipeline_execution", + }, + ), + ] diff --git a/infrasynth/files/migrations/__init__.py b/infrasynth/files/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc b/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc new file mode 100644 index 0000000..d81aeb0 Binary files /dev/null and b/infrasynth/files/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..e6e14ca Binary files /dev/null and b/infrasynth/files/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..116ba5b Binary files /dev/null and b/infrasynth/notifications/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/apps.cpython-314.pyc b/infrasynth/notifications/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..6d4faa0 Binary files /dev/null and b/infrasynth/notifications/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/filters.cpython-314.pyc b/infrasynth/notifications/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..7b1856e Binary files /dev/null and b/infrasynth/notifications/__pycache__/filters.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/models.cpython-314.pyc b/infrasynth/notifications/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..f42f9a7 Binary files /dev/null and b/infrasynth/notifications/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc b/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc new file mode 100644 index 0000000..7bfea30 Binary files /dev/null and b/infrasynth/notifications/__pycache__/resolvers.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc b/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..4c0e159 Binary files /dev/null and b/infrasynth/notifications/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/services.cpython-314.pyc b/infrasynth/notifications/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..6d363e8 Binary files /dev/null and b/infrasynth/notifications/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/signals.cpython-314.pyc b/infrasynth/notifications/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..ff174a4 Binary files /dev/null and b/infrasynth/notifications/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc b/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc new file mode 100644 index 0000000..7ef8bca Binary files /dev/null and b/infrasynth/notifications/__pycache__/tasks.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/urls.cpython-314.pyc b/infrasynth/notifications/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..f5d233a Binary files /dev/null and b/infrasynth/notifications/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/notifications/__pycache__/views.cpython-314.pyc b/infrasynth/notifications/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..24f36f2 Binary files /dev/null and b/infrasynth/notifications/__pycache__/views.cpython-314.pyc differ 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 0000000..b321336 Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/__init__.cpython-314.pyc differ 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 0000000..50c6bdc Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/base.cpython-314.pyc differ diff --git a/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc new file mode 100644 index 0000000..c653adc Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/email_sendgrid.cpython-314.pyc differ 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 0000000..2b038dd Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/email_smtp.cpython-314.pyc differ diff --git a/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc b/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc new file mode 100644 index 0000000..b1010fb Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/sms_twilio.cpython-314.pyc differ 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 0000000..7505cb3 Binary files /dev/null and b/infrasynth/notifications/channels/__pycache__/telegram.cpython-314.pyc differ 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 0000000..eea71bf Binary files /dev/null and b/infrasynth/notifications/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc b/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..8fe2832 Binary files /dev/null and b/infrasynth/notifications/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..b0cb710 Binary files /dev/null and b/infrasynth/scheduler/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc b/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..bead607 Binary files /dev/null and b/infrasynth/scheduler/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc b/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..2bad126 Binary files /dev/null and b/infrasynth/scheduler/__pycache__/filters.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/models.cpython-314.pyc b/infrasynth/scheduler/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..b33472f Binary files /dev/null and b/infrasynth/scheduler/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc b/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..ddb9bdb Binary files /dev/null and b/infrasynth/scheduler/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/services.cpython-314.pyc b/infrasynth/scheduler/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..4095f95 Binary files /dev/null and b/infrasynth/scheduler/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc b/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..f517a08 Binary files /dev/null and b/infrasynth/scheduler/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc b/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..c19e55d Binary files /dev/null and b/infrasynth/scheduler/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/scheduler/__pycache__/views.cpython-314.pyc b/infrasynth/scheduler/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..410cb4e Binary files /dev/null and b/infrasynth/scheduler/__pycache__/views.cpython-314.pyc differ 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 0000000..fe0f7c4 Binary files /dev/null and b/infrasynth/scheduler/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..606cba2 Binary files /dev/null and b/infrasynth/scheduler/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/scheduler/models.py b/infrasynth/scheduler/models.py new file mode 100644 index 0000000..c40a383 --- /dev/null +++ b/infrasynth/scheduler/models.py @@ -0,0 +1,49 @@ +from django.db import models + + +class ScheduledTask(models.Model): + class ScheduleType(models.TextChoices): + CRON = "cron", "Cron" + INTERVAL = "interval", "Interval" + DATE = "date", "Date" + MANUAL = "manual", "Manual" + + name = models.CharField(max_length=200, unique=True) + task_path = models.CharField(max_length=500) + schedule_type = models.CharField(max_length=20, choices=ScheduleType.choices) + 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(max_length=100, blank=True) + priority = models.IntegerField(default=0) + + class Meta: + db_table = "scheduler_task" + + def __str__(self): + return self.name + + +class TaskExecution(models.Model): + class Status(models.TextChoices): + PENDING = "pending", "Pending" + RUNNING = "running", "Running" + SUCCESS = "success", "Success" + FAILURE = "failure", "Failure" + + 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=Status.choices, default=Status.PENDING) + started_at = models.DateTimeField(null=True, blank=True) + completed_at = models.DateTimeField(null=True, blank=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"] + + def __str__(self): + return f"{self.task.name}#{self.pk}" diff --git a/infrasynth/scheduler/serializers.py b/infrasynth/scheduler/serializers.py new file mode 100644 index 0000000..e7dabce --- /dev/null +++ b/infrasynth/scheduler/serializers.py @@ -0,0 +1,44 @@ +from rest_framework import serializers + +from .models import ScheduledTask, TaskExecution + + +class ScheduledTaskSerializer(serializers.ModelSerializer): + class Meta: + model = ScheduledTask + fields = [ + "id", + "name", + "task_path", + "schedule_type", + "schedule_config", + "args", + "kwargs", + "is_active", + "queue", + "priority", + ] + read_only_fields = ["id"] + + +class TaskExecutionSerializer(serializers.ModelSerializer): + task_info = serializers.SerializerMethodField() + + class Meta: + model = TaskExecution + fields = [ + "id", + "task", + "task_info", + "celery_task_id", + "status", + "started_at", + "completed_at", + "result", + "error_traceback", + "worker_hostname", + ] + read_only_fields = ["id", "started_at", "completed_at"] + + def get_task_info(self, obj): + return {"id": obj.task_id, "name": obj.task.name} diff --git a/infrasynth/scheduler/services.py b/infrasynth/scheduler/services.py new file mode 100644 index 0000000..8628f64 --- /dev/null +++ b/infrasynth/scheduler/services.py @@ -0,0 +1,153 @@ +import logging + +from django.utils import timezone +from django.utils.module_loading import import_string + +from infrasynth.shared.settings_utils import get_setting + +from .models import ScheduledTask, TaskExecution +from .signals import task_failed, task_scheduled, task_started + +logger = logging.getLogger(__name__) + + +class TaskService: + """Public API for scheduled/on-demand Celery task management.""" + + def run_now(self, task_id: int) -> 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 0000000..15e08f2 Binary files /dev/null and b/infrasynth/security/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/apps.cpython-314.pyc b/infrasynth/security/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..7038025 Binary files /dev/null and b/infrasynth/security/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/models.cpython-314.pyc b/infrasynth/security/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..80ca907 Binary files /dev/null and b/infrasynth/security/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/permissions.cpython-314.pyc b/infrasynth/security/__pycache__/permissions.cpython-314.pyc new file mode 100644 index 0000000..479369e Binary files /dev/null and b/infrasynth/security/__pycache__/permissions.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/serializers.cpython-314.pyc b/infrasynth/security/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..794ef72 Binary files /dev/null and b/infrasynth/security/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/services.cpython-314.pyc b/infrasynth/security/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..027828c Binary files /dev/null and b/infrasynth/security/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/signals.cpython-314.pyc b/infrasynth/security/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..056b378 Binary files /dev/null and b/infrasynth/security/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/urls.cpython-314.pyc b/infrasynth/security/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..66c804b Binary files /dev/null and b/infrasynth/security/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/security/__pycache__/views.cpython-314.pyc b/infrasynth/security/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..81b2c58 Binary files /dev/null and b/infrasynth/security/__pycache__/views.cpython-314.pyc differ 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 0000000..3bbbf07 Binary files /dev/null and b/infrasynth/security/altcha/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc b/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc new file mode 100644 index 0000000..06a4c9a Binary files /dev/null and b/infrasynth/security/altcha/__pycache__/services.cpython-314.pyc differ diff --git a/infrasynth/security/altcha/services.py b/infrasynth/security/altcha/services.py new file mode 100644 index 0000000..005d525 --- /dev/null +++ b/infrasynth/security/altcha/services.py @@ -0,0 +1,59 @@ +import hashlib +import secrets +from datetime import timedelta + +from django.utils import timezone + +from infrasynth.shared.settings_utils import get_setting + +from ..models import ALTCHAChallenge + + +class ALTCHAService: + """Proof-of-work challenge for anti-spam protection.""" + + def create_challenge(self) -> 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 0000000..cad0537 Binary files /dev/null and b/infrasynth/security/auth/__pycache__/__init__.cpython-314.pyc differ 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 0000000..5889f93 Binary files /dev/null and b/infrasynth/security/auth/__pycache__/api_keys.cpython-314.pyc differ 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 0000000..02ba1f4 Binary files /dev/null and b/infrasynth/security/auth/__pycache__/backends.cpython-314.pyc differ 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 0000000..ab8d5e3 Binary files /dev/null and b/infrasynth/security/auth/__pycache__/cookies.cpython-314.pyc differ 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 0000000..f557bf7 Binary files /dev/null and b/infrasynth/security/auth/__pycache__/middleware.cpython-314.pyc differ 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 0000000..02ef98b Binary files /dev/null and b/infrasynth/security/migrations/__pycache__/0001_initial.cpython-314.pyc differ diff --git a/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc b/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc new file mode 100644 index 0000000..695fb5e Binary files /dev/null and b/infrasynth/security/migrations/__pycache__/0002_add_role_users_m2m.cpython-314.pyc differ 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 0000000..f70c174 Binary files /dev/null and b/infrasynth/security/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..42b4661 Binary files /dev/null and b/infrasynth/security/two_factor/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc b/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc new file mode 100644 index 0000000..fafdb2c Binary files /dev/null and b/infrasynth/security/two_factor/__pycache__/middleware.cpython-314.pyc differ 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 0000000..b1223c5 Binary files /dev/null and b/infrasynth/security/two_factor/__pycache__/services.cpython-314.pyc differ 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 0000000..97d886b Binary files /dev/null and b/infrasynth/security/two_factor/__pycache__/utils.cpython-314.pyc differ 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 0000000..0a688a9 Binary files /dev/null and b/infrasynth/shared/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/shared/__pycache__/crypto.cpython-314.pyc b/infrasynth/shared/__pycache__/crypto.cpython-314.pyc new file mode 100644 index 0000000..b4a3a4d Binary files /dev/null and b/infrasynth/shared/__pycache__/crypto.cpython-314.pyc differ diff --git a/infrasynth/shared/__pycache__/enums.cpython-314.pyc b/infrasynth/shared/__pycache__/enums.cpython-314.pyc new file mode 100644 index 0000000..bf643ae Binary files /dev/null and b/infrasynth/shared/__pycache__/enums.cpython-314.pyc differ diff --git a/infrasynth/shared/__pycache__/protocols.cpython-314.pyc b/infrasynth/shared/__pycache__/protocols.cpython-314.pyc new file mode 100644 index 0000000..d593ac1 Binary files /dev/null and b/infrasynth/shared/__pycache__/protocols.cpython-314.pyc differ diff --git a/infrasynth/shared/__pycache__/results.cpython-314.pyc b/infrasynth/shared/__pycache__/results.cpython-314.pyc new file mode 100644 index 0000000..6030677 Binary files /dev/null and b/infrasynth/shared/__pycache__/results.cpython-314.pyc differ 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 0000000..662bf04 Binary files /dev/null and b/infrasynth/shared/__pycache__/settings_utils.cpython-314.pyc differ 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 0000000..3a53e22 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc b/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..ce01c97 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc b/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc new file mode 100644 index 0000000..849ab05 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/dispatch.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc b/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..da1dde2 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/filters.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/models.cpython-314.pyc b/infrasynth/webhooks/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..56dc55b Binary files /dev/null and b/infrasynth/webhooks/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc b/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc new file mode 100644 index 0000000..d040d4c Binary files /dev/null and b/infrasynth/webhooks/__pycache__/registry.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc b/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..563e021 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..f8288d8 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc b/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc new file mode 100644 index 0000000..cf9e56c Binary files /dev/null and b/infrasynth/webhooks/__pycache__/signature.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc b/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..d8225be Binary files /dev/null and b/infrasynth/webhooks/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/webhooks/__pycache__/views.cpython-314.pyc b/infrasynth/webhooks/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..af02c48 Binary files /dev/null and b/infrasynth/webhooks/__pycache__/views.cpython-314.pyc differ diff --git a/infrasynth/webhooks/apps.py b/infrasynth/webhooks/apps.py new file mode 100644 index 0000000..c316a3f --- /dev/null +++ b/infrasynth/webhooks/apps.py @@ -0,0 +1,13 @@ +from django.apps import AppConfig + + +class WebhooksConfig(AppConfig): + name = "infrasynth.webhooks" + label = "infrasynth_webhooks" + + def ready(self): + from infrasynth.features.registry import FeatureRegistry + + FeatureRegistry.register("webhooks", default=True, category="integration") + FeatureRegistry.register("webhooks_outbound", default=True, category="integration") + FeatureRegistry.register("webhooks_inbound", default=True, category="integration") diff --git a/infrasynth/webhooks/dispatch.py b/infrasynth/webhooks/dispatch.py new file mode 100644 index 0000000..8252eb4 --- /dev/null +++ b/infrasynth/webhooks/dispatch.py @@ -0,0 +1,132 @@ +import logging +from datetime import timedelta + +import requests +from celery import shared_task +from django.template import Context, Template +from django.utils import timezone + +from infrasynth.shared.settings_utils import get_setting + +from .models import OutboundDelivery, OutboundSubscription +from .signals import outbound_delivery_failed, outbound_delivery_succeeded +from .signature import sign_payload + +logger = logging.getLogger(__name__) + + +@shared_task( + name="infrasynth.webhooks.deliver_webhook", + bind=True, + max_retries=5, + default_retry_delay=60, +) +def deliver_webhook(self, subscription_id, event_name, payload, payload_template): + """Delivers an outbound webhook with HMAC signature and retry/backoff.""" + try: + subscription = OutboundSubscription.objects.select_related("endpoint").get(pk=subscription_id) + except OutboundSubscription.DoesNotExist: + logger.warning("Subscription %s not found", subscription_id) + return None + + endpoint = subscription.endpoint + body = _build_payload(payload, payload_template) + + delivery = OutboundDelivery.objects.create( + subscription=subscription, + payload=body, + status=OutboundDelivery.Status.RETRYING, + attempt=self.request.retries + 1, + ) + + headers = {"Content-Type": "application/json"} + headers.update(endpoint.headers or {}) + headers[get_setting("INFRASYNTH_WEBHOOKS", "SIGNATURE_HEADER", "X-Webhook-Signature")] = sign_payload( + endpoint.secret, body + ) + + timeout = endpoint.timeout_seconds or get_setting("INFRASYNTH_WEBHOOKS", "DEFAULT_TIMEOUT_SECONDS", 10) + + try: + response = requests.post( + endpoint.url, + data=body, + headers=headers, + timeout=timeout, + ) + except requests.RequestException as exc: + return _handle_failure(self, delivery, subscription, endpoint, str(exc)) + + delivery.response_status = response.status_code + delivery.response_body = response.text[:4000] + delivery.completed_at = timezone.now() + + if 200 <= response.status_code < 300: + delivery.status = OutboundDelivery.Status.SUCCESS + delivery.save() + outbound_delivery_succeeded.send( + sender=OutboundDelivery, + delivery_id=delivery.id, + event_name=event_name, + status_code=response.status_code, + ) + logger.info( + "Webhook delivered: %s -> %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 0000000..f616911 Binary files /dev/null and b/infrasynth/webhooks/inbound/__pycache__/__init__.cpython-314.pyc differ 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 0000000..52e5738 Binary files /dev/null and b/infrasynth/webhooks/inbound/__pycache__/handlers.cpython-314.pyc differ 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 0000000..21a0e67 Binary files /dev/null and b/infrasynth/webhooks/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..ec9d1bc Binary files /dev/null and b/infrasynth/webhooks/migrations/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/webhooks/models.py b/infrasynth/webhooks/models.py new file mode 100644 index 0000000..985dbc8 --- /dev/null +++ b/infrasynth/webhooks/models.py @@ -0,0 +1,98 @@ +from django.db import models + + +class OutboundEndpoint(models.Model): + 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(default=dict, blank=True) + headers = models.JSONField(default=dict, blank=True) + timeout_seconds = models.PositiveIntegerField(default=30) + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + db_table = "webhooks_outbound_endpoint" + + def __str__(self): + return self.name + + +class OutboundSubscription(models.Model): + endpoint = models.ForeignKey(OutboundEndpoint, on_delete=models.CASCADE, related_name="subscriptions") + event_name = models.CharField(max_length=255, db_index=True) + 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) + + class Meta: + db_table = "webhooks_outbound_subscription" + unique_together = [("endpoint", "event_name")] + + def __str__(self): + return f"{self.endpoint.name} / {self.event_name}" + + +class OutboundDelivery(models.Model): + class Status(models.TextChoices): + SUCCESS = "success", "Success" + FAILED = "failed", "Failed" + RETRYING = "retrying", "Retrying" + + subscription = models.ForeignKey(OutboundSubscription, on_delete=models.CASCADE, related_name="deliveries") + payload = models.JSONField() + response_status = models.PositiveSmallIntegerField(null=True, blank=True) + response_body = models.TextField(blank=True) + attempt = models.PositiveIntegerField(default=0) + status = models.CharField(max_length=20, choices=Status.choices, default=Status.RETRYING) + next_retry_at = models.DateTimeField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + completed_at = models.DateTimeField(null=True, blank=True) + + class Meta: + db_table = "webhooks_outbound_delivery" + + def __str__(self): + return f"Delivery {self.id} — {self.status}" + + +class InboundEndpoint(models.Model): + class Source(models.TextChoices): + STRIPE = "stripe", "Stripe" + GITHUB = "github", "GitHub" + MERCADOPAGO = "mercadopago", "Mercado Pago" + CUSTOM = "custom", "Custom" + + name = models.CharField(max_length=255) + slug = models.SlugField(unique=True) + source = models.CharField(max_length=50, choices=Source.choices) + 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) + + class Meta: + db_table = "webhooks_inbound_endpoint" + + def __str__(self): + return self.name + + +class InboundEvent(models.Model): + endpoint = models.ForeignKey(InboundEndpoint, on_delete=models.CASCADE, related_name="events") + 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(null=True, blank=True) + + class Meta: + db_table = "webhooks_inbound_event" + + def __str__(self): + return f"{self.endpoint.slug} / {self.event_type}" diff --git a/infrasynth/webhooks/registry.py b/infrasynth/webhooks/registry.py new file mode 100644 index 0000000..8a1b3f2 --- /dev/null +++ b/infrasynth/webhooks/registry.py @@ -0,0 +1,52 @@ +import logging +from dataclasses import dataclass, field + +from django.db import transaction + +logger = logging.getLogger(__name__) + + +@dataclass +class EventDefinition: + name: str + description: str = "" + example_payload: dict = field(default_factory=dict) + schema: dict = field(default_factory=dict) + + +class EventRegistry: + _events: dict[str, EventDefinition] = {} + + @classmethod + def register(cls, event_name, *, description="", example_payload=None, schema=None): + cls._events[event_name] = EventDefinition( + name=event_name, + description=description, + example_payload=example_payload or {}, + schema=schema or {}, + ) + logger.debug(f"Event registered: {event_name}") + + @classmethod + def emit(cls, event_name, payload: dict): + from django.db.models import Q + + from .models import OutboundSubscription + + active_subs = OutboundSubscription.objects.filter( + Q(event_name=event_name) | Q(event_name="*"), + is_active=True, + endpoint__is_active=True, + ).select_related("endpoint") + + with transaction.atomic(): + for sub in active_subs: + transaction.on_commit( + lambda sid=sub.id, en=event_name, pl=payload, pt=sub.payload_template: __import__( # type: ignore[misc] + "infrasynth.webhooks.dispatch", fromlist=["deliver_webhook"] + ).deliver_webhook.delay(sid, en, pl, pt) + ) + + @classmethod + def get_registered_events(cls) -> 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 0000000..cc623b9 Binary files /dev/null and b/infrasynth/workflows/__pycache__/__init__.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/apps.cpython-314.pyc b/infrasynth/workflows/__pycache__/apps.cpython-314.pyc new file mode 100644 index 0000000..505aa93 Binary files /dev/null and b/infrasynth/workflows/__pycache__/apps.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/engine.cpython-314.pyc b/infrasynth/workflows/__pycache__/engine.cpython-314.pyc new file mode 100644 index 0000000..7e6dd8a Binary files /dev/null and b/infrasynth/workflows/__pycache__/engine.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/filters.cpython-314.pyc b/infrasynth/workflows/__pycache__/filters.cpython-314.pyc new file mode 100644 index 0000000..9e6b32c Binary files /dev/null and b/infrasynth/workflows/__pycache__/filters.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/models.cpython-314.pyc b/infrasynth/workflows/__pycache__/models.cpython-314.pyc new file mode 100644 index 0000000..45d9eac Binary files /dev/null and b/infrasynth/workflows/__pycache__/models.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc b/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc new file mode 100644 index 0000000..8a0c53c Binary files /dev/null and b/infrasynth/workflows/__pycache__/serializers.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/signals.cpython-314.pyc b/infrasynth/workflows/__pycache__/signals.cpython-314.pyc new file mode 100644 index 0000000..b8abd84 Binary files /dev/null and b/infrasynth/workflows/__pycache__/signals.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/urls.cpython-314.pyc b/infrasynth/workflows/__pycache__/urls.cpython-314.pyc new file mode 100644 index 0000000..a71772b Binary files /dev/null and b/infrasynth/workflows/__pycache__/urls.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/validators.cpython-314.pyc b/infrasynth/workflows/__pycache__/validators.cpython-314.pyc new file mode 100644 index 0000000..61ff781 Binary files /dev/null and b/infrasynth/workflows/__pycache__/validators.cpython-314.pyc differ diff --git a/infrasynth/workflows/__pycache__/views.cpython-314.pyc b/infrasynth/workflows/__pycache__/views.cpython-314.pyc new file mode 100644 index 0000000..9fb6464 Binary files /dev/null and b/infrasynth/workflows/__pycache__/views.cpython-314.pyc differ 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 0000000..f0da871 Binary files /dev/null and b/infrasynth/workflows/migrations/__pycache__/0001_initial.cpython-314.pyc differ 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 0000000..79e9dd9 Binary files /dev/null and b/infrasynth/workflows/migrations/__pycache__/__init__.cpython-314.pyc differ 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 0000000..3725e76 Binary files /dev/null and b/tests/__pycache__/__init__.cpython-314.pyc differ 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 0000000..869868d Binary files /dev/null and b/tests/__pycache__/conftest.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/__pycache__/helpers.cpython-314.pyc b/tests/__pycache__/helpers.cpython-314.pyc new file mode 100644 index 0000000..de99d21 Binary files /dev/null and b/tests/__pycache__/helpers.cpython-314.pyc differ diff --git a/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc b/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..31d94c1 Binary files /dev/null and b/tests/__pycache__/test_debug_tmp.cpython-314-pytest-9.1.1.pyc differ 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 0000000..1fe0520 Binary files /dev/null and b/tests/__pycache__/test_e2e.cpython-314-pytest-9.1.1.pyc differ 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 0000000..abaa119 Binary files /dev/null and b/tests/test_audit/__pycache__/__init__.cpython-314.pyc differ 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 0000000..8e01fa7 Binary files /dev/null and b/tests/test_audit/__pycache__/test_middleware.cpython-314-pytest-9.1.1.pyc differ 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 0000000..212e6af Binary files /dev/null and b/tests/test_audit/__pycache__/test_receivers.cpython-314-pytest-9.1.1.pyc differ 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 0000000..1467bd3 Binary files /dev/null and b/tests/test_audit/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_audit/test_middleware.py b/tests/test_audit/test_middleware.py new file mode 100644 index 0000000..5737c78 --- /dev/null +++ b/tests/test_audit/test_middleware.py @@ -0,0 +1,147 @@ +import json + +import pytest +from django.conf import settings +from django.http import JsonResponse +from django.test import RequestFactory, override_settings +from rest_framework.response import Response + +from infrasynth.audit.middleware import AuditAPIMiddleware +from infrasynth.audit.models import APIInteractionLog + + +def audit_config(**overrides): + return {**settings.INFRASYNTH_AUDIT, **overrides} + + +@pytest.fixture +def middleware(): + return AuditAPIMiddleware(get_response=lambda request: None) + + +def run_request(middleware, request, response): + middleware.process_request(request) + return middleware.process_response(request, response) + + +class TestAPILogging: + def test_logs_api_interaction(self, middleware, db): + request = RequestFactory().get("/api/features/active/") + response = JsonResponse({"ok": True}) + run_request(middleware, request, response) + + log = APIInteractionLog.objects.get(path="/api/features/active/") + assert log.method == "GET" + assert log.status_code == 200 + assert log.request_id + assert log.duration_ms >= 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 0000000..7bae239 Binary files /dev/null and b/tests/test_billing/__pycache__/__init__.cpython-314.pyc differ 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 0000000..1d44ed5 Binary files /dev/null and b/tests/test_billing/__pycache__/test_gateways.cpython-314-pytest-9.1.1.pyc differ 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 0000000..61423a6 Binary files /dev/null and b/tests/test_billing/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc differ 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 0000000..dea3bd5 Binary files /dev/null and b/tests/test_billing/__pycache__/test_invoice_generator.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..6937b87 Binary files /dev/null and b/tests/test_billing/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc differ 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 0000000..83a84b9 Binary files /dev/null and b/tests/test_billing/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc differ 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 0000000..f9bd1f3 Binary files /dev/null and b/tests/test_billing/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ 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 0000000..8218e4f Binary files /dev/null and b/tests/test_features/__pycache__/__init__.cpython-314.pyc differ 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 0000000..ccb9c7b Binary files /dev/null and b/tests/test_features/__pycache__/test_decorators.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc b/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..8c73b00 Binary files /dev/null and b/tests/test_features/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc differ 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 0000000..b644c43 Binary files /dev/null and b/tests/test_features/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc differ 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 0000000..2c480b5 Binary files /dev/null and b/tests/test_features/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ 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 0000000..2f70cb2 Binary files /dev/null and b/tests/test_files/__pycache__/__init__.cpython-314.pyc differ 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 0000000..65c7d45 Binary files /dev/null and b/tests/test_files/__pycache__/test_processing.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..88b03db Binary files /dev/null and b/tests/test_files/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc differ 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 0000000..d6af52c Binary files /dev/null and b/tests/test_files/__pycache__/test_storage.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..e2ce4d5 Binary files /dev/null and b/tests/test_files/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ 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 0000000..07c7157 Binary files /dev/null and b/tests/test_notifications/__pycache__/__init__.cpython-314.pyc differ 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 0000000..7872fe2 Binary files /dev/null and b/tests/test_notifications/__pycache__/test_channels.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..12853c1 Binary files /dev/null and b/tests/test_notifications/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc differ 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 0000000..cfedec1 Binary files /dev/null and b/tests/test_notifications/__pycache__/test_resolvers.cpython-314-pytest-9.1.1.pyc differ 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 0000000..fe243a6 Binary files /dev/null and b/tests/test_notifications/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..82c2930 Binary files /dev/null and b/tests/test_notifications/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_notifications/test_channels.py b/tests/test_notifications/test_channels.py new file mode 100644 index 0000000..e6b012e --- /dev/null +++ b/tests/test_notifications/test_channels.py @@ -0,0 +1,193 @@ +import pytest + +from infrasynth.notifications.channels.base import Attachment, BaseChannel +from infrasynth.notifications.channels.email_sendgrid import SendGridChannel +from infrasynth.notifications.channels.email_smtp import SMTPChannel +from infrasynth.notifications.channels.sms_twilio import TwilioSMSChannel +from infrasynth.notifications.channels.telegram import TelegramChannel + + +class TestBaseChannel: + def test_is_abstract(self): + with pytest.raises(TypeError): + BaseChannel() + + def test_attachment_fields(self): + attachment = Attachment("f.txt", b"data", "text/plain") + assert attachment.filename == "f.txt" + assert attachment.content == b"data" + assert attachment.mime_type == "text/plain" + assert attachment.cid is None + assert attachment.inline is False + + +class TestSMTPChannel: + def test_from_config_lowercases_keys(self): + channel = SMTPChannel.from_config({"HOST": "smtp.example.com", "PORT": 2525, "USERNAME": "u"}) + assert channel.host == "smtp.example.com" + assert channel.port == 2525 + assert channel.username == "u" + + def test_send_without_host_returns_err(self): + channel = SMTPChannel() + result = channel.send("a@b.com", "s", "b") + assert result.is_err + assert "SMTP host" in result.error + + def test_send_success_with_locmem_backend(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"} + ) + result = channel.send("a@b.com", "Hello", "Hi", 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 0000000..ff8a0f5 Binary files /dev/null and b/tests/test_scheduler/__pycache__/__init__.cpython-314.pyc differ 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 0000000..293ba7e Binary files /dev/null and b/tests/test_scheduler/__pycache__/test_services.cpython-314-pytest-9.1.1.pyc differ 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 0000000..4751066 Binary files /dev/null and b/tests/test_scheduler/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_scheduler/test_services.py b/tests/test_scheduler/test_services.py new file mode 100644 index 0000000..69f5504 --- /dev/null +++ b/tests/test_scheduler/test_services.py @@ -0,0 +1,190 @@ +from unittest import mock + +import pytest + +from infrasynth.scheduler.models import ScheduledTask, TaskExecution +from infrasynth.scheduler.services import TaskService + +pytestmark = pytest.mark.django_db + + +@pytest.fixture +def celery_task(): + return ScheduledTask.objects.create( + name="deliver", + task_path="infrasynth.notifications.tasks.deliver_notification", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + args=[999999], + ) + + +@pytest.fixture +def plain_task(): + return ScheduledTask.objects.create( + name="plain", + task_path="os.getpid", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + ) + + +class TestRunNow: + def test_creates_execution_for_celery_task(self, celery_task): + execution = TaskService().run_now(celery_task.id) + execution.refresh_from_db() + assert execution.task_id == celery_task.id + assert execution.status == TaskExecution.Status.RUNNING + assert execution.celery_task_id + assert execution.started_at is not None + + def test_runs_plain_function(self, plain_task): + execution = TaskService().run_now(plain_task.id) + execution.refresh_from_db() + assert execution.status == TaskExecution.Status.SUCCESS + assert execution.completed_at is not None + + def test_plain_function_with_args(self): + task = ScheduledTask.objects.create( + name="pow", + task_path="math.pow", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + args=[2, 10], + ) + execution = TaskService().run_now(task.id) + execution.refresh_from_db() + assert execution.status == TaskExecution.Status.SUCCESS + + def test_unimportable_task_marks_failure(self): + task = ScheduledTask.objects.create( + name="broken", + task_path="does.not.exist", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + ) + execution = TaskService().run_now(task.id) + execution.refresh_from_db() + assert execution.status == TaskExecution.Status.FAILURE + assert "Could not import" in execution.error_traceback + assert execution.completed_at is not None + + def test_task_exception_marks_failure(self): + task = ScheduledTask.objects.create( + name="boom", + task_path="math.sqrt", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + args=["not-a-number"], + ) + execution = TaskService().run_now(task.id) + execution.refresh_from_db() + assert execution.status == TaskExecution.Status.FAILURE + assert "must be real number" in execution.error_traceback + + def test_emits_task_failed_signal_on_import_error(self): + from infrasynth.scheduler.signals import task_failed + + task = ScheduledTask.objects.create( + name="broken2", + task_path="does.not.exist", + schedule_type=ScheduledTask.ScheduleType.MANUAL, + ) + sent = [] + receiver = lambda **kw: sent.append(kw) # noqa: E731 + task_failed.connect(receiver, weak=False) + TaskService().run_now(task.id) + assert sent + assert sent[0]["task_name"] == "broken2" + task_failed.disconnect(receiver) + + def test_emits_scheduled_and_started_signals(self, celery_task): + from infrasynth.scheduler.signals import task_scheduled, task_started + + scheduled = [] + started = [] + rec_sched = lambda **kw: scheduled.append(kw) # noqa: E731 + rec_start = lambda **kw: started.append(kw) # noqa: E731 + task_scheduled.connect(rec_sched, weak=False) + task_started.connect(rec_start, weak=False) + TaskService().run_now(celery_task.id) + assert scheduled + assert started + assert started[0]["task_name"] == "deliver" + task_scheduled.disconnect(rec_sched) + task_started.disconnect(rec_start) + + +class TestToggle: + def test_toggle_enables_disables(self, celery_task): + service = TaskService() + assert celery_task.is_active is True + task = service.toggle(celery_task.id) + assert task.is_active is False + task = service.toggle(celery_task.id) + assert task.is_active is True + + def test_toggle_persists(self, celery_task): + TaskService().toggle(celery_task.id) + celery_task.refresh_from_db() + assert celery_task.is_active is False + + +class TestQueueStatus: + def test_aggregates_worker_tasks(self): + with mock.patch("celery.current_app") as app: + inspect = app.control.inspect() + inspect.active.return_value = { + "worker1": [ + {"delivery_info": {"routing_key": "default"}}, + {"delivery_info": {"routing_key": "webhooks"}}, + ] + } + inspect.scheduled.return_value = {"worker1": [{"delivery_info": {"routing_key": "default"}}]} + inspect.reserved.return_value = {} + stats = TaskService().get_queue_status() + + assert stats["total_active"] == 2 + assert stats["total_scheduled"] == 1 + assert stats["total_reserved"] == 0 + assert stats["queues"]["default"]["active"] == 1 + assert stats["queues"]["default"]["scheduled"] == 1 + assert stats["queues"]["webhooks"]["active"] == 1 + + def test_empty_inspect(self): + with mock.patch("celery.current_app") as app: + app.control.inspect().active.return_value = None + app.control.inspect().scheduled.return_value = None + app.control.inspect().reserved.return_value = None + stats = TaskService().get_queue_status() + assert stats == { + "queues": {}, + "total_active": 0, + "total_scheduled": 0, + "total_reserved": 0, + } + + +class TestGetWorkers: + def test_lists_workers_with_stats(self): + with mock.patch("celery.current_app") as app: + inspect = app.control.inspect() + inspect.stats.return_value = { + "worker1@host": { + "total": {"task": 42}, + "uptime": 3600, + "queues": ["default"], + } + } + inspect.active.return_value = {"worker1@host": [{}, {}]} + workers = TaskService().get_workers() + + assert len(workers) == 1 + worker = workers[0] + assert worker["hostname"] == "worker1@host" + assert worker["active_tasks"] == 2 + assert worker["processed"] == 42 + assert worker["uptime_seconds"] == 3600 + assert worker["queues"] == ["default"] + assert worker["status"] == "online" + + def test_no_workers(self): + with mock.patch("celery.current_app") as app: + app.control.inspect().stats.return_value = None + app.control.inspect().active.return_value = None + assert TaskService().get_workers() == [] diff --git a/tests/test_scheduler/test_views.py b/tests/test_scheduler/test_views.py new file mode 100644 index 0000000..9454f9b --- /dev/null +++ b/tests/test_scheduler/test_views.py @@ -0,0 +1,125 @@ +from unittest import mock + +import pytest + +from infrasynth.features.models import FeatureFlag +from infrasynth.scheduler.models import ScheduledTask, TaskExecution + +pytestmark = pytest.mark.django_db + +TASKS_URL = "/api/scheduler/tasks/" +EXECUTIONS_URL = "/api/scheduler/executions/" +STATUS_URL = "/api/scheduler/status/" + + +@pytest.fixture +def task(): + return ScheduledTask.objects.create( + name="cleanup", + task_path="os.getpid", + schedule_type=ScheduledTask.ScheduleType.CRON, + schedule_config={"cron": "0 2 * * *"}, + ) + + +@pytest.fixture +def execution(task): + return TaskExecution.objects.create( + task=task, + status=TaskExecution.Status.SUCCESS, + started_at="2026-01-01T00:00:00Z", + completed_at="2026-01-01T00:00:01Z", + ) + + +class TestScheduledTaskViewSet: + def test_list_tasks(self, authenticated_client, task): + response = authenticated_client.get(TASKS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["name"] == "cleanup" + + def test_requires_auth(self, api_client, task): + assert api_client.get(TASKS_URL).status_code == 401 + + def test_create_task(self, authenticated_client): + response = authenticated_client.post( + TASKS_URL, + { + "name": "backup", + "task_path": "os.getpid", + "schedule_type": "interval", + "schedule_config": {"interval": 3600}, + }, + format="json", + ) + assert response.status_code == 201 + assert ScheduledTask.objects.filter(name="backup").exists() + + def test_retrieve_update_delete(self, authenticated_client, task): + response = authenticated_client.get(f"{TASKS_URL}{task.id}/") + assert response.status_code == 200 + response = authenticated_client.patch(f"{TASKS_URL}{task.id}/", {"name": "renamed"}, format="json") + assert response.status_code == 200 + task.refresh_from_db() + assert task.name == "renamed" + response = authenticated_client.delete(f"{TASKS_URL}{task.id}/") + assert response.status_code == 204 + assert not ScheduledTask.objects.filter(pk=task.pk).exists() + + def test_run_action(self, authenticated_client, task): + response = authenticated_client.post(f"{TASKS_URL}{task.id}/run/") + assert response.status_code == 200 + assert response.data["status"] == "triggered" + assert TaskExecution.objects.filter(task=task).exists() + + def test_toggle_action(self, authenticated_client, task): + response = authenticated_client.post(f"{TASKS_URL}{task.id}/toggle/") + assert response.status_code == 200 + assert response.data["is_active"] is False + response = authenticated_client.post(f"{TASKS_URL}{task.id}/toggle/") + assert response.data["is_active"] is True + + def test_disabled_feature_returns_404(self, authenticated_client): + FeatureFlag.objects.create(slug="scheduler", name="Scheduler", is_active=False) + assert authenticated_client.get(TASKS_URL).status_code == 404 + + +class TestTaskExecutionViewSet: + def test_list_executions(self, authenticated_client, execution): + response = authenticated_client.get(EXECUTIONS_URL) + assert response.status_code == 200 + assert response.data["count"] == 1 + assert response.data["results"][0]["task_info"]["name"] == "cleanup" + + def test_retrieve_execution(self, authenticated_client, execution): + response = authenticated_client.get(f"{EXECUTIONS_URL}{execution.id}/") + assert response.status_code == 200 + assert response.data["status"] == "success" + + def test_requires_auth(self, api_client, execution): + assert api_client.get(EXECUTIONS_URL).status_code == 401 + + +class TestSchedulerStatusViewSet: + def test_queue_status(self, authenticated_client): + with mock.patch("celery.current_app") as app: + inspect = app.control.inspect() + inspect.active.return_value = {} + inspect.scheduled.return_value = {} + inspect.reserved.return_value = {} + response = authenticated_client.get(f"{STATUS_URL}queue_status/") + assert response.status_code == 200 + assert response.data["total_active"] == 0 + + def test_workers(self, authenticated_client): + with mock.patch("celery.current_app") as app: + inspect = app.control.inspect() + inspect.stats.return_value = {} + inspect.active.return_value = {} + response = authenticated_client.get(f"{STATUS_URL}workers/") + assert response.status_code == 200 + assert response.data == [] + + def test_requires_auth(self, api_client): + assert api_client.get(f"{STATUS_URL}queue_status/").status_code == 401 diff --git a/tests/test_security/__init__.py b/tests/test_security/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_security/__pycache__/__init__.cpython-314.pyc b/tests/test_security/__pycache__/__init__.cpython-314.pyc new file mode 100644 index 0000000..4840f36 Binary files /dev/null and b/tests/test_security/__pycache__/__init__.cpython-314.pyc differ 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 0000000..5ee7e6b Binary files /dev/null and b/tests/test_security/__pycache__/test_altcha.cpython-314-pytest-9.1.1.pyc differ 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 0000000..c8e62e4 Binary files /dev/null and b/tests/test_security/__pycache__/test_api_keys.cpython-314-pytest-9.1.1.pyc differ 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 0000000..97e567f Binary files /dev/null and b/tests/test_security/__pycache__/test_auth.cpython-314-pytest-9.1.1.pyc differ 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 0000000..699211e Binary files /dev/null and b/tests/test_security/__pycache__/test_authorization.cpython-314-pytest-9.1.1.pyc differ 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 0000000..720abb7 Binary files /dev/null and b/tests/test_security/__pycache__/test_two_factor.cpython-314-pytest-9.1.1.pyc differ 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 0000000..872784e Binary files /dev/null and b/tests/test_security/__pycache__/test_two_factor_middleware.cpython-314-pytest-9.1.1.pyc differ 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 0000000..bdc3a8d Binary files /dev/null and b/tests/test_shared/__pycache__/__init__.cpython-314.pyc differ diff --git a/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..47c385b Binary files /dev/null and b/tests/test_shared/__pycache__/test_crypto.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc b/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..977e65b Binary files /dev/null and b/tests/test_shared/__pycache__/test_enums.cpython-314-pytest-9.1.1.pyc differ 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 0000000..f9382ab Binary files /dev/null and b/tests/test_shared/__pycache__/test_protocols.cpython-314-pytest-9.1.1.pyc differ 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 0000000..ddccea8 Binary files /dev/null and b/tests/test_shared/__pycache__/test_results.cpython-314-pytest-9.1.1.pyc differ 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 0000000..5b0134d Binary files /dev/null and b/tests/test_shared/__pycache__/test_settings_utils.cpython-314-pytest-9.1.1.pyc differ 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 0000000..18c2f95 Binary files /dev/null and b/tests/test_webhooks/__pycache__/__init__.cpython-314.pyc differ 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 0000000..e59e368 Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_dispatch.cpython-314-pytest-9.1.1.pyc differ 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 0000000..8e61572 Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_inbound.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..1208436 Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_integration.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..c7179e1 Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_registry.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..dd2f1e0 Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_signature.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..21f28bb Binary files /dev/null and b/tests/test_webhooks/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ 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 0000000..7c45e1e Binary files /dev/null and b/tests/test_workflows/__pycache__/__init__.cpython-314.pyc differ 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 0000000..0c33c2a Binary files /dev/null and b/tests/test_workflows/__pycache__/test_engine.cpython-314-pytest-9.1.1.pyc differ 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 0000000..eee2297 Binary files /dev/null and b/tests/test_workflows/__pycache__/test_models.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..3a374ff Binary files /dev/null and b/tests/test_workflows/__pycache__/test_validators.cpython-314-pytest-9.1.1.pyc differ diff --git a/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc b/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc new file mode 100644 index 0000000..4336939 Binary files /dev/null and b/tests/test_workflows/__pycache__/test_views.cpython-314-pytest-9.1.1.pyc differ 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