From e5fef8b18195d4c53074d5537e23745f7609f7aa Mon Sep 17 00:00:00 2001 From: kfj001 Date: Thu, 11 Jun 2026 22:34:55 -0700 Subject: [PATCH] working 3 tier approach --- .vscode/launch.json | 21 +- .vscode/settings.json | 17 ++ .vscode/tasks.json | 82 +++--- Dockerfile | 14 + backend/.vscode/launch.json | 18 ++ backend/.vscode/settings.json | 13 + backend/Dockerfile | 12 + backend/__pycache__/seed.cpython-312.pyc | Bin 0 -> 4886 bytes backend/app/__init__.py | 0 .../app/__pycache__/config.cpython-312.pyc | Bin 0 -> 672 bytes .../app/__pycache__/database.cpython-312.pyc | Bin 0 -> 813 bytes backend/app/__pycache__/main.cpython-312.pyc | Bin 0 -> 1835 bytes .../app/__pycache__/models.cpython-312.pyc | Bin 0 -> 2343 bytes .../app/__pycache__/schemas.cpython-312.pyc | Bin 0 -> 3722 bytes backend/app/api/__init__.py | 0 .../api/__pycache__/events.cpython-312.pyc | Bin 0 -> 4531 bytes .../api/__pycache__/programs.cpython-312.pyc | Bin 0 -> 4830 bytes .../app/api/__pycache__/tiers.cpython-312.pyc | Bin 0 -> 4268 bytes backend/app/api/events.py | 70 +++++ backend/app/api/programs.py | 75 ++++++ backend/app/api/tiers.py | 66 +++++ backend/app/config.py | 11 + backend/app/database.py | 11 + backend/app/main.py | 38 +++ backend/app/models.py | 51 ++++ backend/app/schemas.py | 99 +++++++ backend/requirements.txt | 9 + backend/seed.py | 118 +++++++++ docker-compose.yml | 42 +++ nginx.conf | 26 ++ src/app/app.config.ts | 7 +- src/app/donate/donate.component.html | 36 +-- src/app/donate/donate.component.ts | 74 ++---- src/app/events/events.component.html | 44 ++-- src/app/events/events.component.ts | 64 ++--- src/app/interfaces/event.ts | 10 + src/app/interfaces/program.ts | 9 + src/app/interfaces/tier.ts | 8 + src/app/schedule/schedule.component.html | 34 ++- src/app/schedule/schedule.component.ts | 41 +-- src/app/services/event.service.ts | 37 +++ src/app/services/program.service.ts | 37 +++ src/app/services/tier.service.ts | 35 +++ src/environments/environment.prod.ts | 5 + src/environments/environment.ts | 4 + trans_to_3_tier.md | 243 ++++++++++++++++++ web_app.code-workspace | 7 + 47 files changed, 1288 insertions(+), 200 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 Dockerfile create mode 100644 backend/.vscode/launch.json create mode 100644 backend/.vscode/settings.json create mode 100644 backend/Dockerfile create mode 100644 backend/__pycache__/seed.cpython-312.pyc create mode 100644 backend/app/__init__.py create mode 100644 backend/app/__pycache__/config.cpython-312.pyc create mode 100644 backend/app/__pycache__/database.cpython-312.pyc create mode 100644 backend/app/__pycache__/main.cpython-312.pyc create mode 100644 backend/app/__pycache__/models.cpython-312.pyc create mode 100644 backend/app/__pycache__/schemas.cpython-312.pyc create mode 100644 backend/app/api/__init__.py create mode 100644 backend/app/api/__pycache__/events.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/programs.cpython-312.pyc create mode 100644 backend/app/api/__pycache__/tiers.cpython-312.pyc create mode 100644 backend/app/api/events.py create mode 100644 backend/app/api/programs.py create mode 100644 backend/app/api/tiers.py create mode 100644 backend/app/config.py create mode 100644 backend/app/database.py create mode 100644 backend/app/main.py create mode 100644 backend/app/models.py create mode 100644 backend/app/schemas.py create mode 100644 backend/requirements.txt create mode 100644 backend/seed.py create mode 100644 docker-compose.yml create mode 100644 nginx.conf create mode 100644 src/app/interfaces/event.ts create mode 100644 src/app/interfaces/program.ts create mode 100644 src/app/interfaces/tier.ts create mode 100644 src/app/services/event.service.ts create mode 100644 src/app/services/program.service.ts create mode 100644 src/app/services/tier.service.ts create mode 100644 src/environments/environment.prod.ts create mode 100644 src/environments/environment.ts create mode 100644 trans_to_3_tier.md create mode 100644 web_app.code-workspace diff --git a/.vscode/launch.json b/.vscode/launch.json index 925af83..7c9e608 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,20 +1,25 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { - "name": "ng serve", - "type": "chrome", + "name": "Backend (FastAPI)", + "type": "python", "request": "launch", - "preLaunchTask": "npm: start", - "url": "http://localhost:4200/" + "module": "uvicorn", + "args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"], + "cwd": "${workspaceFolder}/backend", + "env": { + "DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain" + }, + "console": "integratedTerminal" }, { - "name": "ng test", + "name": "Frontend (ng serve)", "type": "chrome", "request": "launch", - "preLaunchTask": "npm: test", - "url": "http://localhost:9876/debug.html" + "url": "http://localhost:4200", + "webRoot": "${workspaceFolder}/src", + "preLaunchTask": "Start Frontend" } ] } diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..e240a56 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,17 @@ +{ + "typescript.tsdk": "node_modules/typescript/lib", + "python.defaultInterpreterPath": "${workspaceFolder}/backend/.venv/bin/python", + "python.terminal.activateEnvironment": true, + "editor.formatOnSave": true, + "editor.tabSize": 2, + "[html]": { + "editor.defaultFormatter": "vscode.html-language-features" + }, + "[typescript]": { + "editor.defaultFormatter": "vscode.typescript-language-features" + }, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.tabSize": 4 + } +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 244306f..4c60d2a 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,42 +1,62 @@ { - // For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558 "version": "2.0.0", "tasks": [ { - "type": "npm", - "script": "start", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } + "label": "Start Backend", + "type": "shell", + "command": "cd ${workspaceFolder}/backend && uvicorn app.main:app --reload --host 0.0.0.0 --port 8000", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [], + "runOptions": { + "instanceLimit": 1 } }, { - "type": "npm", - "script": "test", - "isBackground": true, - "problemMatcher": { - "owner": "typescript", - "pattern": "$tsc", - "background": { - "activeOnStart": true, - "beginsPattern": { - "regexp": "Changes detected" - }, - "endsPattern": { - "regexp": "bundle generation (complete|failed)" - } - } + "label": "Start Frontend", + "type": "shell", + "command": "ng serve", + "portAction": "open", + "portPattern": { + "startBody": 4200 + }, + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": ["$ng-cli-warn"], + "runOptions": { + "instanceLimit": 1 } + }, + { + "label": "Docker Up", + "type": "shell", + "command": "docker compose up --build -d", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [], + "runOptions": { + "instanceLimit": 1 + } + }, + { + "label": "Docker Down", + "type": "shell", + "command": "docker compose down", + "group": "build", + "presentation": { + "reveal": "always", + "panel": "dedicated" + }, + "problemMatcher": [] } ] } diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..0ca92b3 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,14 @@ +# ── Build stage ─────────────────────────────────────────────────────── +FROM node:20-alpine AS build +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npx ng build --configuration production + +# ── Serve stage ──────────────────────────────────────────────────────── +FROM docker.io/library/nginx:1.30-perl +COPY --from=build /app/dist/radio-station/browser /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/backend/.vscode/launch.json b/backend/.vscode/launch.json new file mode 100644 index 0000000..dd87224 --- /dev/null +++ b/backend/.vscode/launch.json @@ -0,0 +1,18 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "FastAPI (uvicorn)", + "type": "python", + "request": "launch", + "module": "uvicorn", + "args": ["app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000"], + "cwd": "${workspaceFolder}", + "env": { + "DATABASE_URL": "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain" + }, + "django": false, + "console": "integratedTerminal" + } + ] +} diff --git a/backend/.vscode/settings.json b/backend/.vscode/settings.json new file mode 100644 index 0000000..8d5ebc6 --- /dev/null +++ b/backend/.vscode/settings.json @@ -0,0 +1,13 @@ +{ + "python.defaultInterpreterPath": "${workspaceFolder}/.venv/bin/python", + "python.linting.enabled": true, + "python.linting.flake8Enabled": true, + "python.linting.pylanceEnabled": true, + "python.formatting.provider": "black", + "editor.formatOnSave": true, + "editor.tabSize": 4, + "[python]": { + "editor.defaultFormatter": "ms-python.black-formatter", + "editor.tabSize": 4 + } +} diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..39c096e --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,12 @@ +FROM docker.io/library/python:3.12-slim AS base + +WORKDIR /app + +RUN pip install --no-cache-dir gunicorn uvicorn psycopg2-binary + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY . . + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "2"] diff --git a/backend/__pycache__/seed.cpython-312.pyc b/backend/__pycache__/seed.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..db7128d476fe49bbf559f66b4f76c2bcdf95eae3 GIT binary patch literal 4886 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 literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/main.cpython-312.pyc b/backend/app/__pycache__/main.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a1c49722ab030be0f228818e3bf7e0d154954aa6 GIT binary patch literal 1835 zcma)6U2GIp6h3!;cBcC?EL&O>3vPl~w+#if1r1=ch+xySXlqQ^mpa{hyPb7@Hg~2h zt=JGDCL| zPI3w35~Gl2>GJ-D{N&11rj=iL5^2aZMU4^>^UjVXwSnP{>2l|@zARaO?OBwj=}z38 zS(5)_cj~#jTj{R9d?1>J{#lxas(4xxn1O z57oe8+z(~5QYm8jw}v2vECHUD}=(Qn93W{qWp!b!e`_X zDQ_0$$z4ILsg?DnwEZndgx%JKw6qz)6(nYKgWR|wA{}B&n{i!{o{m7+?#ZzSK1=(J;?f={Xvf%Euk0@wFll+Qzz+?A^&M}~7TE>aV!;|wz! z>U_!_6E))E38C(=z@%nihVde2X28Zw+s;WmDU1r%(e*`$+3cSQO!y(kwA`M`bg0|b z1oJDV%XgtQfz$s0mtSDxGCYn!x_$1_wM%oe*Jr=kRWmYk&Nb)e>03iz4Xt(bu6FeP zVDzo&efRagKV(Sno11;4wA59UHfnm`MqOYVbF*`^*Jl6nKf+T#fAN$=z9#w3*i9Lw zTL<$8<&`AK=VB` z;E*@v2Kb`v8QZHeo*gl%P$RB zqHf}%g2TBsBtfHdZEKv1e@m!MVB_NHT>R_8-J4pEv(>*nMUVSjG)ovqhM?+BSS2nx zB}@uHkZT;9Ibvahbeh1$p(4^Km?(fKB@N~oCZ%KBmxM|2(-=E9GH`Zacr-LDw`fmM zHpacFfblTa_QAtB|QF znObJgTBdU~(^+fZQ)_LXKM*bT1d!Ip5F=7Ov*kVi=3@Us|Ly*JUH3ZfL*_>qu7TV* qX#VKp$%T`*_byK^7b4Sp+orz76ALG9CzsDIzlEls;9cH@Px>3kI<^Y{ literal 0 HcmV?d00001 diff --git a/backend/app/__pycache__/models.cpython-312.pyc b/backend/app/__pycache__/models.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cdabdfe1e145d1bc6b16fcbcd5b844c57318f56 GIT binary patch literal 2343 zcma)-J#5=X6vs)5;)f_&wrn>+ng;zyTPQ|t#7P8`Z zjMEh~rhFCowIF2bdYF(+LYZ`k4en;IUM+!K=B-v)<<1QNR8_{>;FyjvWud z?ai-^^+T9@etoRptY-=aQ}57dlR5#@s@+Olekw7eO1;3Lb^9TyGt;Rhkr!4d2}qc% zJ{6f3)_mW&?~`L>`uKMg7v&B}sW3tDC(|6tBnUHYJHhVV2vNk7%IJ_hml>{8x1$aF zM?xM_{P9dj3(QY^CMRA11SMYL6DB{1;)JC)Nk|Eu!by1arm;5L7c|9NAQ0b~OO4xq z>A78Xzx?Y=Tff;@KFXhOojbVR&R=bOb2RzZ-gfJL^XG$=_T*w?{m7Ww`>2&~UfHj; zjZYfIqilZf)7A&g&kttX*@ebR*FW8yJ4m;U`M&@C<_&0P=XX}RtHFhgXX!b3&dic} ziWRLFOZ^QjaaM?NYD_==ZyEy!p7Wu*<^w4$4IkuLEZSa)RoT4X5TOr9%D{YUsPzcfbmL51Tp&lU+zF92NQ@ z8Wl7eE{QADt0rC)GR==FkY=DJdlhWcc)M!Xs82QQt2hkfDpO zF=R~bUf!Q=8z1)>7-0nXngJg2^T1bbc5kC~ulW%8$}Kj&evPkzzu3-Q1;07HyS86$ zn^$&LyDZ^aPO*eL#pmQVkh8J`aRQnKIY4sLhsq#x4arhntsh6|F^p+H52+)ZjQaa| zC`-)2H%9%OBgmMewjAdqTZ}>;rLqT?$%NZ$*$Fgz*c>myMpxkqP->yKRU`CmXmfy$ zG0$?SN`=X@pumdK6L{WSx>7w*f069T98iY?-&umMdLqbpTHVu4_V=&)hMIp>Zi&UDh2NIpLE-G}Y(e&@S4Kjrfo z1^(Xu_=QjYG!*4G{1N>nyUD}9A^BEu6i4-xs#;N1%!j<8YNC>$JmG28WF=YEE4r!- zDOVInyQ?@!mi#dx=cuFz)d5Y#P=ind&~yw<6Pf`u8$&aM<^avd&@7<^K#MUnN9Zu1 zBQZ2j=qR9LF|mCb?2y6TxLFTfzh-;pkVwC22W-K2m?w%W zF7q5~-S%AQ=<{Xy%`$cgP#(_1?{5{RR8&W)4Bb*#f@zL=9#EEqY^awddYRVClD$mF ztdTMek$;=}tK6<$W1CWaBWfMsJCu?$!)hNXykHOBm{ zItruA@iRHr5zk`CVaQ`BKrr*Xi1{#v5e(E_!)z487zP?Wj@f`I_yKGnh9)t?52S$O zgZCf+zca@-t~Z~#U%Gdsr5$fimo~0#-MOFG{<5W&+RsjHytOsgd}aGoOPgvRn%=my zb*_1F`$9{b-nVT>o0gBnrwv||kDw*KC^1Q+xc`@=sR)Q-$8gfFTG1OBGb@T+k-x)& zItY)6A=i04-%yU+uUd8(a(8JhWWi$DO!Y6pF7e2@$bs~kXD$1+6?e6t?L*LmW56qT z5dyGnWH+xoR??gEzm6aOKKyy?T z=uvFIV8RtV4*^KaV98V6OEV~pY%WL(n3~J+^o zK_8#&d4UdCs*6QlV8pP<3(y~;evucTKSWsM1?UeECWR{fA>Vz5(e;OPm-NUTdaQGF zLOzS3gaOGDy1g8_>&z?+WSXcYy2(_C^8vH)6F8N&25(*y|&^3)9kO`@CXr+mV@#%Uf5Qi`(TLZALC3K6daDde9PI!kBaHDjpWl zk((iAax=tCZiblA(tz&uxb7J&vO|mEq@4^nNxG3Wb0SZb5cvo{3~#Vo5QCP6NaaVM zA2>*+e|dHtgw~frwk1b1t8k0zg7!L#==-)HMZi0fyg^< zQ1|c{!X3uTkGco3gmMi7b6eVE zdu(Dyn~*40we#6G3YXiA;M9B!TQ4D^AqaO+GZ)yd|;jsmyNa8jL<`CMn%fIhGN Jqri>C^B+(T>GA*o literal 0 HcmV?d00001 diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/api/__pycache__/events.cpython-312.pyc b/backend/app/api/__pycache__/events.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..41c6ceb5f832f9df837c0824325286ea81d22fc0 GIT binary patch literal 4531 zcmdT{YitwQ6~5Oqgn3z0BOiHrl5tfC8O+u)ugwO)3N>*2{@eGU2__24! zEZKFcz*gO$U9~}~3YDrht0EOdDs81oyFY>b)yI#xrga!CDyXVj{yESe2=zzLneikJ zcxk2fM|-6?_uPBWJ@@*a?>ziluh&hWee|ut)IXer{22#UgR#*b2W*5~CK8dTLeex! zDe^YOmgb^dnve2nAu1SioMKNqq7H-S6=&KNbs4;%xYJ@(G
NqeJSgLfzm>BeYd z+86bu{ZT(9HX?I3s>eo~!DPC&)j<9x8;0sRj$^H#z)M})XUv8`FYO2xI4!U-lr`A%qwO?vp zYPrFgvvA{$+FIqts(}1rGI_1R@rChvrBC?`Z_`1zIytk_N7f<@OHJo zG+3i+^Nc&v@eq|osO~(P(~{XtT#0bHYxHYhJ(kUDGSeN0frU9gafa!a!gip*^Dad zbQrWWu<9_D&PuW}{Z8BAqykq7pPUKD6I$}D99FfsmRIpaNmdgqY1~}5o2$B`dWA$4 zTgGegK;MJ!@em+tl2Gsmt%@x|v^7Z!Zz;jpwSlZlrR*CPjDfXt)>a_ry9>6I>v@?P z4Tjfllqirh1BBG*p(*c12^I~pO%@-@I2iipOX^+ z9GzE^ss^S=#FaQxbuO+b5#EqtO(-b5g>cgCVC=l2sc4=sw5R@fE;k&{C5L4Mk2;i_ z(M41hGskQ*2sXf{W`O*SJeebZcN1^(4|acWr0nS~db+Rf{`o5(y;AZF%}uO0yvvSF zE56pt;wACN6F;5$(bToW#m*fi-_Chqr8#){;H86$yGzZHvNtk!_;ayw!G5{nQbWlX zF8g|mzTT2=Yf0>1=KCugY1sBu#fx7U?Hg;QxBBT=Gk($E|!#Lb*IH{ zKp*18K;LhGG0|6`sWtq;WVLD4=mMC;Co!63krbNXnYJ#KvZ8&B#70C}dY;3L4bUpq zY^$;sB4)V)r}7e&Y)N{Y6gUGJorsL<+_WypjAhx=#tF1nIFr@Fr?UBs6tS~5(6bI3 z7J#8{OG_=Uzl~GpkUhD!UYTAqzeO#vB1DnNDw~t8$b*k z+&2w+b!4gUXRnrfMv6Tnzsi4d?w99ozFgchS?ZZ8H6JN^kIWrj@idh^eML`S$}Ty_qwh`zb0iir=wf=TS5r5g1{M#;vd`nJCV^3O&diA$0KNv&BOM5@ht{j1b``K$DM6k-dl7n#;$0wo|( z2Pc-cXKioD*NIf?8td$^83^ct_~10+13zsz_KE8xBA6kpabeUpI$+qm7i`{+e29*2%}dGs9=3ahJffoQj^mvRvJ)=XpFstD_;f@ahQQ@9o!hr7$RKf&d4*m za5k>wWfff%(2lK&_`FdORS~}zmhm9<6CeiSD<0C)feGtBjcyPvD-GCt=wOo;2=7 zPa1cCCyjQ);C4S9ck#Ce+JXO!&~cIfj5?5aQKTZ$hrffp5z4kfS3j8USa-Dl*F;*^ z(F&a9W_939U*vkv%gyA@#$n3c!!wQ7qzX66p zUU#c+DRCunT29XxwT&KFpBR$QX+v@RPn66uEXQ;YRI@`+B*#z2RheO))!jH{q({}v zl_E~4s4=cG5fAbp!O|bfWAk4a2Zax7#~J=)Hmev_u+Akjno$Sod@ieM4AZ2}R<7;SB$=A z_!0KFb(B_Yz*btGJ#y7aLcQjV9bVYm;IDAd-v=E6^zeflf{$>edI6jZqmt11(ERMR pp1Dwo4&6O4aq~>+z_C?gryYyU73{7R9$|;uhF5LCu}OIBe*&04sh0o% literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/programs.cpython-312.pyc b/backend/app/api/__pycache__/programs.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..595095d07a0436c4945a692ca8e786b69adae40b GIT binary patch literal 4830 zcmdT|Yiv`=6`r{d+wm(-9Gt`&qr zmD(TeNIr9B=FFM7bG~!VjDPF#I0=-q-(8pdM++f;!iH5GHRj10fsiXiAqq{AG>uS- zypR&oVnj?!5h*Q4WTQ_^+0yoiJ?)4%jJA|&NjoD>gO^jTv^(N9cw5So_C~x0Z%?(R zeGy-}Ez*{5kF--F5GLNNEgkVI4%RUxC@pVGk!8%E9JV&MSSwD}$=bW2=X;= zkQKM$x#ex>k(m1?HOE1tgHhIjvy@imoRSpZ+j4!?t}j`sjkQ0wMx#cmw6l(yE`Nir zZqT*aT(yzHuG^K4Tgz^WW-sjcd2Ipat7+?O(AERmUToB3^2)#7S%9?}`fN&9gT7wS zx1*UprTbR!OU|zGOM4o0t(hVX8Y0m+wdTQ(h zXEBY{o3G^*Y_`@$4>L8F&8SQlmDqH=W`E<05i5B5PlkY8A)3*NBw0gIy#ts6ISyiz z@M)(vA7)yfXF}RB7MeWt%2+CXjHRbTQ;8IOVQ4DLL$T1&#G5P=f)%6Lspv_@j*o-| z?gBl!tD!SYc`Nj(xU0}Je=L_9jpY)fIr9_Mk=(TIilV%!oBKh|ixZG3l0UoJE3OqK z*NWNxl56$6wE78j6P=F2v3YszyQAr>!czP5gD|wm+~Sex#+`+RHtzUlHWT_|@#mqf zJ0|m)FrK=?)HqKVHq`C4S8RF3QGp0co)uIoo!BTtLu?@r?J|7h#G2P*D?l6&CV>y_2pN~^bhIQY@} z57w96dyBgl?9SrZ1>dqO?#u3+ z+ZFF+Z`l{B_y$V8fwFH+*)=#X4OT_sUHeSMEu9}*KfavKjnMI+G&hR;Y0v1H+76=$ zWkz={Hl70`!p8vPZ-6nu2KhUtKzT$4?*pRSE#p5A3?t&!hWH2wu7scZIuHXE z_f6wo+j490XFDtX+e-c0KJ5G`_(AaF)1?;=mHQ8u{gV~XWO2`ex1-`6EO`gZ-f(g6 zU03^puXDb8{e0{2ykmI5)n43RZ6V%Qsbzd&te?(FbZnJ0C-(yX@Yk>;gv|g{x=Yn! zT3(IDA)3L)W7DZ@OlgjR-+}znfTYj_gz#kD`XEv(mKCniA5bGGGznCTL4gv$RTG?8 zT9(@0kcW%a(z2+r$qd0jz)A=Z4kJJa`)`o2Y(@vC5zeDZKAi)9R}|eA&!*D}hyt8V zfuA1>i+m%jW||qVODcYJ-Rr$^BU@U2&$q!K=07z8#B_THX$_RT!&mK4w zzzKatYHEhIX6dR>-&d0vl7Y|jfX`L%a~c?x_j9~qaKzH9aDni!Q}I+@VbMI?y);O0 z&&MhrM&Y$c)*;yhM3)m9ORIR@0AJTQ--07sfrRa5=vo9e#xIWO*2Uv&T9@C9rSeQg z#{{HfF>{AR?nx{OZ*`8VX{)QK&v99^d9N?D;X}4;m=9`Ju>PdUEV*tue^N2BiTWR7Fyaa^jzNJ_Paax ze0-+7bMg_9seiV=iq&oRW2|u6j;a7$)rZr1&h=cFoL%{Tyx3Ew;}14{{r!FAP2YGV z+NpoR6RaBb>Wz=F#;FsuDgayUSi0mR2k9L!cWn2-T%W%xLVX`}$WT{p#2b8!Bee}+ yUkH`-p6k7E_WDGzw@f$O9e?>(>&oNbdPHorXLhoR)$Q$%vBGH^9|^!22Kgt6dhhrE literal 0 HcmV?d00001 diff --git a/backend/app/api/__pycache__/tiers.cpython-312.pyc b/backend/app/api/__pycache__/tiers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..546c029e5a8b2126e4ae8f8dca5b34f347813be1 GIT binary patch literal 4268 zcmdT{O>7&-6`t8$E>~QVqAZD$Dan#0Sr#3cYGTKZQN=bT#c83`O=Aa07#A$VUBy(E zB0IZmtcC(;r)Xre-jA!2$Wxb=dqczM9819;TCAyW&Q7zkZVLC3e`xK zCMZRn)3_|3;Il$Pu-m-m$%+Zl)&btqJ$$Vf4la|M2p%ov#-`@s1!+URYSX>=T6gY&KZDtg6g{sYs4~56`v|ibBg=} z&!)GwFYxA9Lp%5hsO`2dK?yea3WKjv=Ut5wRu)p4?}TpiPVeVd)uIM#-gY&3>jZCy z8vQuB^5rXQQJZalqSD&nF9QCKZs$*FyVL%KJ68YFaD%VzxsGV(Lv$9Umh{cMk;%=b zv?yma#h?HB3%P=!GRr%u=G9q62MbYGH8pL3=8x-hv*|OcuEPvy%de?MQr~R)C&3Ro zJDX8il(&3XPcSuQsE+<(UP0ZD{V%9`J~ykY799moK~`%fvpGf8EN{(gx)y!=%RM09 zf@l3tAf^Zf>=wyPqlIloh*66bXhN-vxHy{ex;^!}ZZ+d_qdvGf)AjHcu;fj6!%w+u z{0}`IGEWWJH80YcfUDPQ7q2tT^*&c~$6W7c$We0Tv7hm8&?r3}l`OA%K}};wM3}*v zKv+_aDJo08I%frxjGot0b4k0$610qNz}}@(T8ilwpVAwuvzUPheDpM^kqgcC~KqqsvDgYok3-v95MbI8&&N@I?f*n$%K_3*>iG33C z7W>&CG{f-dFN0`|^o=8TM$Cbum4T zr6LcOpg0IjGeHVDY6zAdtbV;D`y~2{G z2lR)(f^GmPLd}wOBV`oyWE%KydGo0`EtgWZ5B_h#-U+_3Nq&W9Fd1wTA53b+-ToW& z$8??pf?^ukQi_~QKT5!2n-t-C*-C!f9$C4i)IDx9gR?{53q+S}qI0Kjk*LQZx6e+1 zNu`j@1A!IA64SYCHUqR~>NJq}e3WPV;bl&U2SC&Dqc>wKr|pF6gf|8wy=amEVTboq ziTFFq^629JN@S!Q88IUGf2PuHUrp?5>aWn4NQAEZl!#`}aN+KKvZ8t)gDB3{({DabUg)GeD_ zeiwpR<1+U@!)4&=j$9UsLIan#&H4&|M{r@aQ|6x}-v)l1gFH9ynHLS@yIqPx(Nh%N zdj};~+5`d_rqvy<*E@idn`y7xxms~^UE6?Du)%o=z_}M5pAAsYY1RXi*&YyQ;kXW{ z7t&fmQIiEY!HuZAB}p;F9P2}|7sUY(mM3GVSshPyAiBZW2#y>C5%oGbtBz_+TnsT~ z@vp0MmgjpZt)S}2N`N^QCymJk`##}7{Ye}Tny(61(~D33UfNsbNyjtv!M{kM7R-;2V5KgT z@Qw$Waw344a>5IlGTsl3yZh-xlW=#q6ZDS=osfl(s2BAnic&`T5c6V`vLUFpPn=KG znfCvBYHrK4BJc8S8$8pUxc7PcIlO;82M?y_CPY8$#(kA`#<(TIwN-^{nhooIn4y36 z%$1#S2du|n{HthF0`Z?cb{*g?wwb;?6=Bb^u;(+`yevejvV%E^czPjwM>g~$un551 zcn4x`yag~9L;VmPZxxmfcYuCZrsM6xUB8HWD@ECk@^rMv5{EG2-TKc@2owKhoR=C~~Q!f~?6#i6Xat!%x`Qc(3gTI8-t0`S&cpJBT z*kzxJx^qOz*rB7xl+N%sm&qvnXe=`4|AmJu4r{}1yv$mBX4bGTJC=~o=?24t(c<$3 zgJIraPocn|uqcRicu4K0GzKp&XzFqHEcD@RL;o7cDo-iBPrCj}hVGM|`(*SlWbi&2 z_#+uBld(^lcKxWUBvwVjNpD9kMiwTn3)jz;`<^I8OnT_v!Nc#pVjhgI5`hjcYHL`n z^MpHG_+<|_l}+Xu**F5jaA~Ip~ZN?j&B@yX*)z29X%{>&=xrJJ8xBP?d-J0r>Dh4IdVatl>y)0od1vPI@l(EEI3HmU>Kj@ZQ+Ohg0U*^Q%OpU5k1Z TtDEQ6u)=AvRSq6LT3c%V literal 0 HcmV?d00001 diff --git a/backend/app/api/events.py b/backend/app/api/events.py new file mode 100644 index 0000000..8e6ce0f --- /dev/null +++ b/backend/app/api/events.py @@ -0,0 +1,70 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_session +from app.models import Event +from app.schemas import EventCreate, EventUpdate, EventResponse + +router = APIRouter() + + +@router.get("/", response_model=list[EventResponse]) +async def list_events( + active: Optional[bool] = Query(None, description="Filter by active status"), + session: AsyncSession = Depends(get_session), +): + query = select(Event).order_by(Event.date) + if active is not None: + query = query.where(Event.active == active) + result = await session.execute(query) + return list(result.scalars().all()) + + +@router.get("/{event_id}", response_model=EventResponse) +async def get_event(event_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Event).where(Event.id == event_id)) + event = result.scalar_one_or_none() + if not event: + return {"error": "Event not found"} + return event + + +@router.post("/", response_model=EventResponse, status_code=201) +async def create_event( + payload: EventCreate, session: AsyncSession = Depends(get_session) +): + event = Event(**payload.model_dump(), active=True) + session.add(event) + await session.commit() + await session.refresh(event) + return event + + +@router.put("/{event_id}", response_model=EventResponse) +async def update_event( + event_id: int, + payload: EventUpdate, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(Event).where(Event.id == event_id)) + event = result.scalar_one_or_none() + if not event: + return {"error": "Event not found"} + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(event, key, value) + await session.commit() + await session.refresh(event) + return event + + +@router.delete("/{event_id}", status_code=204) +async def delete_event(event_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Event).where(Event.id == event_id)) + event = result.scalar_one_or_none() + if not event: + return {"error": "Event not found"} + await session.delete(event) + await session.commit() diff --git a/backend/app/api/programs.py b/backend/app/api/programs.py new file mode 100644 index 0000000..20908d5 --- /dev/null +++ b/backend/app/api/programs.py @@ -0,0 +1,75 @@ +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_session +from app.models import Program +from app.schemas import ProgramCreate, ProgramUpdate, ProgramResponse + +router = APIRouter() + + +def _day_filter(day: int): + """Return the SQLAlchemy filter for a given day_of_week.""" + return Program.day_of_week == day + + +@router.get("/", response_model=list[ProgramResponse]) +async def list_programs( + day: Optional[int] = Query(None, description="Filter by day_of_week (1=Mon … 7=Sun)"), + session: AsyncSession = Depends(get_session), +): + query = select(Program).order_by(Program.day_of_week, Program.time) + if day is not None: + query = query.filter(_day_filter(day)) + result = await session.execute(query) + return list(result.scalars().all()) + + +@router.get("/{program_id}", response_model=ProgramResponse) +async def get_program(program_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Program).where(Program.id == program_id)) + program = result.scalar_one_or_none() + if not program: + return {"error": "Program not found"} + return program + + +@router.post("/", response_model=ProgramResponse, status_code=201) +async def create_program( + payload: ProgramCreate, session: AsyncSession = Depends(get_session) +): + program = Program(**payload.model_dump()) + session.add(program) + await session.commit() + await session.refresh(program) + return program + + +@router.put("/{program_id}", response_model=ProgramResponse) +async def update_program( + program_id: int, + payload: ProgramUpdate, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(Program).where(Program.id == program_id)) + program = result.scalar_one_or_none() + if not program: + return {"error": "Program not found"} + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(program, key, value) + await session.commit() + await session.refresh(program) + return program + + +@router.delete("/{program_id}", status_code=204) +async def delete_program(program_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(Program).where(Program.id == program_id)) + program = result.scalar_one_or_none() + if not program: + return {"error": "Program not found"} + await session.delete(program) + await session.commit() diff --git a/backend/app/api/tiers.py b/backend/app/api/tiers.py new file mode 100644 index 0000000..a7702c7 --- /dev/null +++ b/backend/app/api/tiers.py @@ -0,0 +1,66 @@ +from typing import Optional + +from fastapi import APIRouter, Depends +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_session +from app.models import DonationTier +from app.schemas import TierCreate, TierUpdate, TierResponse + +router = APIRouter() + + +@router.get("/", response_model=list[TierResponse]) +async def list_tiers(session: AsyncSession = Depends(get_session)): + result = await session.execute( + select(DonationTier).order_by(DonationTier.display_order) + ) + return list(result.scalars().all()) + + +@router.get("/{tier_id}", response_model=TierResponse) +async def get_tier(tier_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id)) + tier = result.scalar_one_or_none() + if not tier: + return {"error": "Tier not found"} + return tier + + +@router.post("/", response_model=TierResponse, status_code=201) +async def create_tier( + payload: TierCreate, session: AsyncSession = Depends(get_session) +): + tier = DonationTier(**payload.model_dump()) + session.add(tier) + await session.commit() + await session.refresh(tier) + return tier + + +@router.put("/{tier_id}", response_model=TierResponse) +async def update_tier( + tier_id: int, + payload: TierUpdate, + session: AsyncSession = Depends(get_session), +): + result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id)) + tier = result.scalar_one_or_none() + if not tier: + return {"error": "Tier not found"} + for key, value in payload.model_dump(exclude_unset=True).items(): + setattr(tier, key, value) + await session.commit() + await session.refresh(tier) + return tier + + +@router.delete("/{tier_id}", status_code=204) +async def delete_tier(tier_id: int, session: AsyncSession = Depends(get_session)): + result = await session.execute(select(DonationTier).where(DonationTier.id == tier_id)) + tier = result.scalar_one_or_none() + if not tier: + return {"error": "Tier not found"} + await session.delete(tier) + await session.commit() diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000..91bf5f5 --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,11 @@ +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/kmountain" + CORS_ORIGINS: list[str] = ["http://localhost:4200", "http://localhost:3000"] + + model_config = {"env_prefix": "KMTN_"} + + +settings = Settings() diff --git a/backend/app/database.py b/backend/app/database.py new file mode 100644 index 0000000..d843672 --- /dev/null +++ b/backend/app/database.py @@ -0,0 +1,11 @@ +from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine + +from app.config import settings + +engine = create_async_engine(settings.DATABASE_URL, echo=False) +async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + +async def get_session() -> AsyncSession: + async with async_session() as session: + yield session diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..9e0b8f9 --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,38 @@ +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.database import engine +from app.models import Base +from app.api import programs, events, tiers + + +@asynccontextmanager +async def lifespan(app: FastAPI): + # Create tables on startup (seed.py handles data seeding) + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + yield + + +app = FastAPI( + title="KMountain Flower Radio API", + description="Backend API for the KMountain Flower Radio Station website.", + version="1.0.0", + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# Register routers +app.include_router(programs.router, prefix="/api/programs", tags=["programs"]) +app.include_router(events.router, prefix="/api/events", tags=["events"]) +app.include_router(tiers.router, prefix="/api/tiers", tags=["tiers"]) diff --git a/backend/app/models.py b/backend/app/models.py new file mode 100644 index 0000000..48af1e3 --- /dev/null +++ b/backend/app/models.py @@ -0,0 +1,51 @@ +from sqlalchemy import ( + Column, + Integer, + String, + Text, + Numeric, + Boolean, + Date, + ARRAY, +) +from sqlalchemy.orm import DeclarativeBase + + +class Base(DeclarativeBase): + pass + + +class Program(Base): + __tablename__ = "programs" + + id = Column(Integer, primary_key=True, autoincrement=True) + day_of_week = Column(Integer, nullable=False) # 1=Mon … 7=Sun + day_label = Column(String(10), nullable=False) # "Monday", … + time = Column(String(10), nullable=False) # "6:00 AM" + title = Column(String(200), nullable=False) + host = Column(String(100), nullable=False) + genre = Column(String(80), nullable=False) + + +class Event(Base): + __tablename__ = "events" + + id = Column(Integer, primary_key=True, autoincrement=True) + date = Column(Date, nullable=False) + title = Column(String(200), nullable=False) + description = Column(Text, nullable=False) + location = Column(String(200), nullable=False) + icon = Column(String(10), nullable=False) + rsvp_url = Column(String(500), nullable=True) + active = Column(Boolean, default=True) + + +class DonationTier(Base): + __tablename__ = "donation_tiers" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(50), unique=True, nullable=False) + amount = Column(Numeric(10, 2), nullable=False) + icon = Column(String(10), nullable=False) + benefits = Column(ARRAY(Text), nullable=False) + display_order = Column(Integer, nullable=False) diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..1b2de88 --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,99 @@ +from datetime import date +from typing import Optional + +from pydantic import BaseModel, field_validator + + +# ── Program ────────────────────────────────────────────── + +class ProgramCreate(BaseModel): + day_of_week: int + day_label: str + time: str + title: str + host: str + genre: str + + +class ProgramUpdate(BaseModel): + day_of_week: Optional[int] = None + day_label: Optional[str] = None + time: Optional[str] = None + title: Optional[str] = None + host: Optional[str] = None + genre: Optional[str] = None + + +class ProgramResponse(BaseModel): + id: int + day_of_week: int + day_label: str + time: str + title: str + host: str + genre: str + + model_config = {"from_attributes": True} + + +# ── Event ──────────────────────────────────────────────── + +class EventCreate(BaseModel): + date: date + title: str + description: str + location: str + icon: str + rsvp_url: Optional[str] = None + + +class EventUpdate(BaseModel): + date: Optional[date] = None + title: Optional[str] = None + description: Optional[str] = None + location: Optional[str] = None + icon: Optional[str] = None + rsvp_url: Optional[str] = None + active: Optional[bool] = None + + +class EventResponse(BaseModel): + id: int + date: date + title: str + description: str + location: str + icon: str + rsvp_url: Optional[str] + active: bool + + model_config = {"from_attributes": True} + + +# ── DonationTier ───────────────────────────────────────── + +class TierCreate(BaseModel): + name: str + amount: float + icon: str + benefits: list[str] + display_order: int + + +class TierUpdate(BaseModel): + name: Optional[str] = None + amount: Optional[float] = None + icon: Optional[str] = None + benefits: Optional[list[str]] = None + display_order: Optional[int] = None + + +class TierResponse(BaseModel): + id: int + name: str + amount: float + icon: str + benefits: list[str] + display_order: int + + model_config = {"from_attributes": True} diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..69a6773 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,9 @@ +fastapi==0.115.6 +uvicorn[standard]==0.34.0 +psycopg2-binary==2.9.10 +asyncpg==0.30.0 +sqlalchemy[asyncio]==2.0.36 +alembic==1.14.1 +python-multipart==0.0.20 +pydantic==2.10.4 +pydantic-settings==2.7.1 diff --git a/backend/seed.py b/backend/seed.py new file mode 100644 index 0000000..4204ec2 --- /dev/null +++ b/backend/seed.py @@ -0,0 +1,118 @@ +""" +Seed script: populates the database with station data. + +Usage: + DATABASE_URL=postgresql://... python -m app.seed + (or from the repo root: cd backend && python seed.py) +""" + +import asyncio +from datetime import date + +from app.database import async_session +from app.models import Program, Event, DonationTier + +# ── Seed data ────────────────────────────────────────────── + +PROGRAMS: list[dict] = [ + {"day_of_week": 1, "day_label": "Monday", "time": "6:00 AM", "title": "Morning Mountain Mist", "host": "Diana Walsh", "genre": "Ambient / Nature"}, + {"day_of_week": 1, "day_label": "Monday", "time": "8:00 AM", "title": "Community Roundup", "host": "Tom Breen", "genre": "News / Talk"}, + {"day_of_week": 1, "day_label": "Monday", "time": "10:00 AM", "title": "Bluegrass Trails", "host": "Sarah Lynn", "genre": "Bluegrass"}, + {"day_of_week": 1, "day_label": "Monday", "time": "12:00 PM", "title": "Lunchtime Jazz", "host": "Mike Darrow", "genre": "Jazz"}, + {"day_of_week": 1, "day_label": "Monday", "time": "2:00 PM", "title": "Folk Roots Hour", "host": "Nadia Cole", "genre": "Folk"}, + {"day_of_week": 1, "day_label": "Monday", "time": "4:00 PM", "title": "Afternoon Acoustics", "host": "Jen Reeves", "genre": "Acoustic"}, + {"day_of_week": 1, "day_label": "Monday", "time": "6:00 PM", "title": "Evening Echoes", "host": "Carlos Mendez", "genre": "Indie / Alternative"}, + {"day_of_week": 1, "day_label": "Monday", "time": "8:00 PM", "title": "Classical Mountains", "host": "Elena Cross", "genre": "Classical"}, + {"day_of_week": 1, "day_label": "Monday", "time": "10:00 PM", "title": "Night Owl Session", "host": "DJ Kofi", "genre": "Electronic"}, + {"day_of_week": 1, "day_label": "Monday", "time": "12:00 AM", "title": "Late Night Jazz", "host": "Mike Darrow", "genre": "Jazz"}, +] + +EVENTS: list[dict] = [ + { + "date": date(2026, 7, 12), + "title": "Summer Sounds Festival", + "description": "A full day of live local music, community booths, and station meet-and-greet at the foothills park. Free admission — donations welcome.", + "location": "Foothills Community Park", + "icon": "🎵", + }, + { + "date": date(2026, 8, 5), + "title": "Annual Fundraiser Gala", + "description": "Our biggest fundraiser of the year! Enjoy dinner, silent auction, and performances from past guest artists.", + "location": "Mountain View Ballroom", + "icon": "🎙️", + }, + { + "date": date(2026, 9, 18), + "title": "Volunteer Open House", + "description": "Tour the studio, meet the team, and learn how you can help keep our free stream alive. New volunteers always welcome!", + "location": "KMTN Studio — 42 Pine St", + "icon": "🤝", + }, + { + "date": date(2026, 10, 30), + "title": "Harvest Moon Broadcast", + "description": "Special live broadcast from the harvest moon gathering. Featuring folk, bluegrass, and indigenous music.", + "location": "Ridgefield Amphitheater", + "icon": "🌙", + }, +] + +TIERS: list[dict] = [ + { + "name": "Seed", + "amount": 5.00, + "icon": "🌱", + "benefits": ["Digital thank-you card", "Name listed on our website", "Quarterly newsletter"], + "display_order": 1, + }, + { + "name": "Stream", + "amount": 15.00, + "icon": "🎵", + "benefits": ["All Seed benefits", "KMountain Flower pin", "Priority show-request line", "Annual station update call"], + "display_order": 2, + }, + { + "name": "Ridge", + "amount": 30.00, + "icon": "🏔️", + "benefits": ["All Stream benefits", "Official member patch", "Exclusive show invitations", "Annual station tour"], + "display_order": 3, + }, + { + "name": "Summit", + "amount": 60.00, + "icon": "⭐", + "benefits": ["All Ridge benefits", "Featured on the Summit Wall", "Name on-air during anniversary special", "Lifetime patron recognition"], + "display_order": 4, + }, +] + + +# ── Main ────────────────────────────────────────────────── + +async def seed(): + async with async_session() as session: + # Programs + for data in PROGRAMS: + session.add(Program(**data)) + await session.commit() + print(f" ✓ Seeded {len(PROGRAMS)} programs") + + # Events + for data in EVENTS: + event = Event(**data, active=True) + session.add(event) + await session.commit() + print(f" ✓ Seeded {len(EVENTS)} events") + + # Tiers + for data in TIERS: + session.add(DonationTier(**data)) + await session.commit() + print(f" ✓ Seeded {len(TIERS)} donation tiers") + + +if __name__ == "__main__": + asyncio.run(seed()) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cbdb3f5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,42 @@ +services: + db: + image: docker.io/library/postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: kmountain + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + volumes: + - pgdata:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U postgres"] + interval: 5s + timeout: 5s + retries: 5 + + api: + build: + context: ./backend + dockerfile: Dockerfile + restart: unless-stopped + environment: + KMTN_DATABASE_URL: postgresql+asyncpg://postgres:postgres@db:5432/kmountain + KMTN_CORS_ORIGINS: '["http://localhost:4200"]' + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + + frontend: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + ports: + - "4200:80" + depends_on: + - api + +volumes: + pgdata: diff --git a/nginx.conf b/nginx.conf new file mode 100644 index 0000000..6d20403 --- /dev/null +++ b/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name _; + root /usr/share/nginx/html; + index index.html; + + # SPA fallback — Angular router handles its own paths + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API calls to the backend service (only when running inside Docker) + location /api/ { + # When the env var is set, nginx resolves "api" to the backend container. + # In dev (HOST_MODE=dev) we proxy to localhost:8000 instead. + if ($host = localhost) { + # Only proxy when the port matches our dev setup + # See: https://serverfault.com/questions/777768/conditional-proxy-pass-in-nginx + } + proxy_pass http://api:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/src/app/app.config.ts b/src/app/app.config.ts index e75614a..3660a5e 100644 --- a/src/app/app.config.ts +++ b/src/app/app.config.ts @@ -1,8 +1,13 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners } from '@angular/core'; +import { provideHttpClient } from '@angular/common/http'; import { provideRouter } from '@angular/router'; import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { - providers: [provideBrowserGlobalErrorListeners(), provideRouter(routes)], + providers: [ + provideBrowserGlobalErrorListeners(), + provideRouter(routes), + provideHttpClient(), + ], }; diff --git a/src/app/donate/donate.component.html b/src/app/donate/donate.component.html index 669d483..6a1e874 100644 --- a/src/app/donate/donate.component.html +++ b/src/app/donate/donate.component.html @@ -17,18 +17,24 @@