Lua update

This commit is contained in:
jcv-dev 2026-08-28 14:38:47 -05:00
commit 75c3c7b2c2
455 changed files with 20071 additions and 0 deletions

BIN
.coverage Normal file

Binary file not shown.

26
.dockerignore Normal file
View file

@ -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

124
.github/workflows/ci.yml vendored Normal file
View file

@ -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

28
.pre-commit-config.yaml Normal file
View file

@ -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

View file

@ -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^>;<Jfb5-8MB>@qkDBZW\!G<3F)%(-GG:j@V4_;`U`bu9"CZ#`Q*??DL]/imrHZFMN_koNh%js*gkImRr5heg78C*o"Omg<R9tTaIDH/B4R2($4TE]rqGd/":d;f&A]`RD#k/[P_5HHKZ+kq!;>Ss<0mfr5'^5K-?%)j]R81=q6hg1eK_W""dmBH,WI?ebj)?9AZL*\&q[o2!.SeJ%#g9hqkg>Ip3YQ<k6rX@`XFPH"I*RDNd?dBJUkAoeHj>VJ"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

View file

@ -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?<u&M_OmA4\8DII\cuDKAaBMXkf'eE#`HAE=Y^6?-2SQJgZ]7CR"&O,uLlFp>cnPm#mkR#$i\F<W&*L7faLL;4EW6ceV-T>84J"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<E*&1ofFb'5b3N>^R[m'UhH<mo>92U.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(Q<pO]o;D'TAE4,?SJ@'o'@dr#h:_`pE(1$23.~>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
[<bc6093c212d09b5ce4fbd73a51960790><bc6093c212d09b5ce4fbd73a51960790>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1661
%%EOF

View file

@ -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<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>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

View file

@ -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&<?."4]"j+SX;BBfbLcRAQiFog1CTO>/*$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<Lr6`[WoD%a;lh)"9Y2k^[+);[,icA9nT8R%+[T:CX$L"J=P=P`Q_f*2>)(:&1Pu;LZ(%^6nr]2JYWbYr)d24Yf>hOr4ej-;.o8^s8<U,_4TTAau[b!2(WQ;F<NArLaS"J4BfdPjC15j0o?stoQsueSsc89]AUrPZ&-E;`J?kNJr>2TMda%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

392
AGENTS.md Normal file
View file

@ -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 <repo-url> && 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/
```

61
Dockerfile Normal file
View file

@ -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"]

3125
PLAN.md Normal file

File diff suppressed because it is too large Load diff

View file

@ -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<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>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

3
config/__init__.py Normal file
View file

@ -0,0 +1,3 @@
from .celery import app as celery_app
__all__ = ["celery_app"]

Binary file not shown.

Binary file not shown.

Binary file not shown.

9
config/celery.py Normal file
View file

@ -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()

View file

@ -0,0 +1,8 @@
"""
Settings package.
Import the appropriate environment module:
from .dev import * # development
from .test import * # testing
from .base import * # common base
"""

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

289
config/settings/base.py Normal file
View file

@ -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,
}

12
config/settings/dev.py Normal file
View file

@ -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="

26
config/settings/test.py Normal file
View file

@ -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

15
config/urls.py Normal file
View file

@ -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")),
]

6
config/wsgi.py Normal file
View file

@ -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()

View file

@ -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<grV,YX$k=#nOMGLgNIcj@c='r?L_Zt]"4Wsh;-aOMq;bhL:#6T1H1@cWnni1!<?o&L"dEDf',m0BAlW':ZIM'>$Gg+5JA9W^#"H\EI8eoPp3T`ak7!ISXTo[a!@<V[UE1\'+Q.-_28WOd^[V--#bp20^&H&"Sq91Z^SQ"=#A[GQR-6nH&eQdYDXq0Umnjn]5o6;p"582/%,!EU[!a%/SN/TjR(4&2-sgmJE,m]"<[G'O^FZk/g:GP[(LXA7Git-=ZR6Vt6U&2KP5elUpZ1R9jq4V+L;cBk"Z3=~>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
[<e98e62cc67699399dd11ec773ffbcc0f><e98e62cc67699399dd11ec773ffbcc0f>]
% ReportLab generated PDF document -- digest (opensource)
/Info 6 0 R
/Root 5 0 R
/Size 9
>>
startxref
1657
%%EOF

78
docker-compose.yml Normal file
View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1 KiB

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

3
infrasynth/__init__.py Normal file
View file

@ -0,0 +1,3 @@
"""
InfraSynth Base — reusable Django infrastructure kit.
"""

Binary file not shown.

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

20
infrasynth/audit/apps.py Normal file
View file

@ -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",
)

View file

@ -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"],
}

View file

@ -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

View file

@ -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"),
],
},
),
]

View file

View file

@ -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

View file

@ -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"

View file

@ -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(),
)

View file

@ -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",
]

View file

@ -0,0 +1,4 @@
from django.dispatch import Signal
model_changed = Signal()
security_event_occurred = Signal()

13
infrasynth/audit/urls.py Normal file
View file

@ -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)),
]

40
infrasynth/audit/views.py Normal file
View file

@ -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()

View file

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -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",
)

View file

@ -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"],
}

View file

View file

@ -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: ...

View file

@ -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)

View file

@ -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)

View file

@ -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)

View file

@ -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"<b>N&ordm;:</b> {invoice.invoice_number}<br/>"
f"<b>Vencimiento:</b> {invoice.due_date.date() if invoice.due_date else '-'}",
body_style,
),
Spacer(1, 12),
Paragraph("<b>Cliente</b>", body_style),
Paragraph(
f"{invoice.user.get_full_name() or invoice.user.username}<br/>{invoice.user.email}",
body_style,
),
Spacer(1, 12),
]
table_data = [["Descripci&oacute;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"<b>Total: {total} {invoice.currency}</b>")
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()

View file

@ -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",
),
),
]

Some files were not shown because too many files have changed in this diff Show more