Lua update
BIN
.coverage
Normal file
26
.dockerignore
Normal 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
|
|
@ -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
|
|
@ -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
|
||||||
74
04d271951d82468f9dbc33e39637c47e/invoice_INV-2026-000002.pdf
Normal 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
|
||||||
74
141c3c42ce784d2f896d2c9fbeec0598/invoice_INV-2026-000001.pdf
Normal 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
|
||||||
74
51edeb0b29ec4eea9d3e73b3bfe818d5/invoice_INV-2026-000001.pdf
Normal 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
|
||||||
74
6925f0e0013b444f89743eaff8983583/invoice_INV-2026-000001.pdf
Normal 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
|
|
@ -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
|
|
@ -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"]
|
||||||
74
b8c92713fd1c4688951bdcfe2c243c8c/invoice_INV-2026-000001.pdf
Normal 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
|
|
@ -0,0 +1,3 @@
|
||||||
|
from .celery import app as celery_app
|
||||||
|
|
||||||
|
__all__ = ["celery_app"]
|
||||||
BIN
config/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
config/__pycache__/celery.cpython-314.pyc
Normal file
BIN
config/__pycache__/urls.cpython-314.pyc
Normal file
9
config/celery.py
Normal 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()
|
||||||
8
config/settings/__init__.py
Normal 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
|
||||||
|
"""
|
||||||
BIN
config/settings/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
config/settings/__pycache__/base.cpython-314.pyc
Normal file
BIN
config/settings/__pycache__/dev.cpython-314.pyc
Normal file
BIN
config/settings/__pycache__/test.cpython-314.pyc
Normal file
289
config/settings/base.py
Normal 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
|
|
@ -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
|
|
@ -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
|
|
@ -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
|
|
@ -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()
|
||||||
74
d99ed435c17848a39e40185c73bb2aeb/invoice_INV-2026-000001.pdf
Normal 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
|
|
@ -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:
|
||||||
BIN
docs/04dd318c64774fb08ece1060dbf8c7d0/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
docs/07454893ed3e4f40852f3d6c3d5a94b0/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
1
docs/2e5d7892b2d345dba141852e5b9d88f2/test.txt
Normal 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
|
||||||
BIN
docs/380be0205e7d4da7b718e96cb62ae2b4/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
1
docs/4b2e6d2c2c044354a29b53dd6b29998c/test.txt
Normal 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
|
||||||
1
docs/513a00e428e848e8a77d007243a0f88e/test.txt
Normal 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
|
||||||
BIN
docs/569c1d2348dd42348e16221e92268ded/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
1
docs/5a05d7c2edca4d509722b05f57114874/test.txt
Normal 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
|
||||||
BIN
docs/60ac6c34c5c4442f9b2db03580f3073e/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
docs/6bdd5cfc93ea4f0e8c2fc26792dcc124/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
docs/8c72d789a3fa4759b8ca9df91cd1f5f3/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
BIN
docs/98fd1b7317fc46668f945560910d0d77/img.png
Normal file
|
After Width: | Height: | Size: 1 KiB |
1
docs/a3f2d4ebbc87485d80544891363ca5b6/test.txt
Normal 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
|
||||||
1
docs/c008ebbc2a974fa69a459e9d002f2ca8/test.txt
Normal 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
|
||||||
1
docs/cfcb245ce6114826bd0063bef7b58075/test.txt
Normal 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
|
||||||
1
docs/e54d4898fc1d4124a6eaef6bc6a2ed6b/test.txt
Normal 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
|
||||||
1
docs/f454df5e834c4db2a9824a6387d7115d/test.txt
Normal 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
|
|
@ -0,0 +1,3 @@
|
||||||
|
"""
|
||||||
|
InfraSynth Base — reusable Django infrastructure kit.
|
||||||
|
"""
|
||||||
BIN
infrasynth/__pycache__/__init__.cpython-314.pyc
Normal file
0
infrasynth/audit/__init__.py
Normal file
BIN
infrasynth/audit/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/apps.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/filters.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/middleware.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/models.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/receivers.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/signals.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/urls.cpython-314.pyc
Normal file
BIN
infrasynth/audit/__pycache__/views.cpython-314.pyc
Normal file
20
infrasynth/audit/apps.py
Normal 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",
|
||||||
|
)
|
||||||
40
infrasynth/audit/filters.py
Normal 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"],
|
||||||
|
}
|
||||||
80
infrasynth/audit/middleware.py
Normal 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
|
||||||
91
infrasynth/audit/migrations/0001_initial.py
Normal 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"),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
0
infrasynth/audit/migrations/__init__.py
Normal file
BIN
infrasynth/audit/migrations/__pycache__/__init__.cpython-314.pyc
Normal file
26
infrasynth/audit/mixins.py
Normal 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
|
||||||
51
infrasynth/audit/models.py
Normal 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"
|
||||||
130
infrasynth/audit/receivers.py
Normal 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(),
|
||||||
|
)
|
||||||
54
infrasynth/audit/serializers.py
Normal 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",
|
||||||
|
]
|
||||||
4
infrasynth/audit/signals.py
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
from django.dispatch import Signal
|
||||||
|
|
||||||
|
model_changed = Signal()
|
||||||
|
security_event_occurred = Signal()
|
||||||
13
infrasynth/audit/urls.py
Normal 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
|
|
@ -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()
|
||||||
0
infrasynth/billing/__init__.py
Normal file
BIN
infrasynth/billing/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/apps.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/filters.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/invoice_generator.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/models.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/serializers.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/services.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/signals.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/urls.cpython-314.pyc
Normal file
BIN
infrasynth/billing/__pycache__/views.cpython-314.pyc
Normal file
18
infrasynth/billing/apps.py
Normal 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",
|
||||||
|
)
|
||||||
56
infrasynth/billing/filters.py
Normal 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"],
|
||||||
|
}
|
||||||
0
infrasynth/billing/gateways/__init__.py
Normal file
BIN
infrasynth/billing/gateways/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
infrasynth/billing/gateways/__pycache__/base.cpython-314.pyc
Normal file
BIN
infrasynth/billing/gateways/__pycache__/stripe.cpython-314.pyc
Normal file
BIN
infrasynth/billing/gateways/__pycache__/wompi.cpython-314.pyc
Normal file
36
infrasynth/billing/gateways/base.py
Normal 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: ...
|
||||||
134
infrasynth/billing/gateways/mercadopago.py
Normal 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)
|
||||||
125
infrasynth/billing/gateways/stripe.py
Normal 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)
|
||||||
149
infrasynth/billing/gateways/wompi.py
Normal 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)
|
||||||
138
infrasynth/billing/invoice_generator.py
Normal 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º:</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ó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()
|
||||||
199
infrasynth/billing/migrations/0001_initial.py
Normal 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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||