From f0519768813917e3ed1aabca8aec5956669a1643 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Wed, 10 Dec 2025 17:52:32 +0700 Subject: [PATCH] Update New Features --- .env.example | 17 + __pycache__/calendar_service.cpython-312.pyc | Bin 0 -> 6206 bytes __pycache__/email_service.cpython-312.pyc | Bin 20213 -> 20333 bytes __pycache__/models.cpython-312.pyc | Bin 11048 -> 16671 bytes .../ms_calendar_service.cpython-312.pyc | Bin 0 -> 10873 bytes __pycache__/r2_storage.cpython-312.pyc | Bin 0 -> 8607 bytes __pycache__/server.cpython-312.pyc | Bin 66113 -> 125405 bytes calendar_service.py | 127 ++ email_service.py | 81 +- migrations/README.md | 138 ++ migrations/add_calendar_uid.sql | 38 + migrations/complete_fix.sql | 161 ++ migrations/fix_storage_usage.sql | 17 + migrations/sprint_1_2_3_migration.sql | 117 ++ migrations/verify_columns.sql | 54 + models.py | 92 +- ms_calendar_service.py | 320 ++++ r2_storage.py | 243 +++ requirements.txt | 6 +- server.py | 1422 ++++++++++++++++- 20 files changed, 2776 insertions(+), 57 deletions(-) create mode 100644 __pycache__/calendar_service.cpython-312.pyc create mode 100644 __pycache__/ms_calendar_service.cpython-312.pyc create mode 100644 __pycache__/r2_storage.cpython-312.pyc create mode 100644 calendar_service.py create mode 100644 migrations/README.md create mode 100644 migrations/add_calendar_uid.sql create mode 100644 migrations/complete_fix.sql create mode 100644 migrations/fix_storage_usage.sql create mode 100644 migrations/sprint_1_2_3_migration.sql create mode 100644 migrations/verify_columns.sql create mode 100644 ms_calendar_service.py create mode 100644 r2_storage.py diff --git a/.env.example b/.env.example index 01689db..a20ff27 100644 --- a/.env.example +++ b/.env.example @@ -31,3 +31,20 @@ FRONTEND_URL=http://localhost:3000 # Stripe Configuration (for future payment integration) # STRIPE_SECRET_KEY=sk_test_... # STRIPE_WEBHOOK_SECRET=whsec_... + +# Cloudflare R2 Storage Configuration +R2_ACCOUNT_ID=your_r2_account_id +R2_ACCESS_KEY_ID=your_r2_access_key_id +R2_SECRET_ACCESS_KEY=your_r2_secret_access_key +R2_BUCKET_NAME=loaf-membership-storage +R2_PUBLIC_URL=https://your-r2-public-url.com + +# Storage Limits (in bytes) +MAX_STORAGE_BYTES=10737418240 # 10GB default +MAX_FILE_SIZE_BYTES=52428800 # 50MB per file default + +# Microsoft Calendar API Configuration +MS_CALENDAR_CLIENT_ID=your_microsoft_client_id +MS_CALENDAR_CLIENT_SECRET=your_microsoft_client_secret +MS_CALENDAR_TENANT_ID=your_microsoft_tenant_id +MS_CALENDAR_REDIRECT_URI=http://localhost:8000/membership/api/calendar/callback diff --git a/__pycache__/calendar_service.cpython-312.pyc b/__pycache__/calendar_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..636a4c25c4599ebecab5a2eaec76181c46535733 GIT binary patch literal 6206 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 TU;t7Fm_LK44_=d}_$2}Wvl1ip delta 906 zcmaDmkMZkVM&8rByj%=GP~v8q$+wZ$P=@itWP6!-CKunyd!0<#j9p9&Or0j*m+2OA zGjevea8oc))lpD(H86KHb(^fH+s*A(lvtXVoLa1|qcB<5MUU0R&DqUja)L{?2t<*! zLUMjievy?zc}8YQ>g0zm{cJ7&_H)B_W$#1Qs zV6Nn~&SYkVIZAW$5<6)|M)u7I?C&yiK~ss69oOWK3SyJ>P1z<#+j31l>nH}*Jo%+# z9+MN@LcPpYm(9%-1ShX_wS>9hg=;&ewQ@fcZ0s`rt8{$3GDO%L)-7 diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc index fc20acecae9f94c72bad7ac60c46649691173814..32fb823eb7c5d117b13fd731d803f455f07dd447 100644 GIT binary patch literal 16671 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 delta 3026 zcmb7GUu;uV7{8~z>$6YS}0-D zi8YL{Q^KJWt&9*T;RK?)?4qVy7O$FNlN^@{G6xT3y>)Deu|CR*b!;1B{gho($F?)J zm9l|4wqv0w7W@ld5o^&bPt7T%Twb&6C&Q-xYRM!Z-bbg%X>*WVG9R&c=>U5P;uDq* z-Mq~YksRNBI0(ZKKpQ|8z*>NAfOY`3A`DjK9(bss0-WJ)IDD0f8-l;GlUk=Ibnghc z%^!Zk5W5#vM*-FY7-IJT*F)x;!ul3_Xow;5Q+k?KWO54GC#Pd`h{i6?D0vqGu*QEs)>5iPimNy zZNagvCQcraoRVwVU7Oj)IFID5<3<_hlf06+?5|b87`bjcAa0`Jt{^-J4-zz zhC04^gwEk@0EUB)Q*Kc>sLD7oqNH=jr1a<^aN7>R)^2EMEqPpsSfMQ7DC9Ze1+&97iDQCUDRx) zf^^r$0x5Xny@mrNVB-K0`c+F*E?S6U8~LR>RMk9g6WJh6>SdGwjY%<-B%cqq5_hDR zg#DGWAkn6-)0wuh%*z6(FVXH^cGT1h(3zklNX}(fO?%qClygg{N zv&bjqd{I4sH_{Ovps*7UQM_XrOF9`733jlW`b^z=HmYC9;yP7Je1N4+Ait~3aG+x{7JL+?Ht15prfd{6tDecMq%}if&(N}#VUpeD>&c1m zz<@!@*I;@Iz#t_kg8>TU$M9D+R%S5FT=4j-S33t$*w9OwWkF73WuZ=>(Eq20{Jb`7 zKL_tvmE&RVuQ%jgX6}YmKTAVsd_hU)S(7AVfk#R6YLgDV_7R~C^69YJSztZ?vL=_MbJ ON9_M{K{SQf@5q0hGF*TF diff --git a/__pycache__/ms_calendar_service.cpython-312.pyc b/__pycache__/ms_calendar_service.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b7c70de7d8a775fd7b3e83bf1e25569041f9838 GIT binary patch literal 10873 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 literal 0 HcmV?d00001 diff --git a/__pycache__/r2_storage.cpython-312.pyc b/__pycache__/r2_storage.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9ffbb1705ded15d22d94ca0902563e6007b1f3cb GIT binary patch literal 8607 zcmcgxYiu0Xb-uH+?yk`A{TUqA1x4MN%|Fk(5Qsves_K%bi(rsNJ3Q z%&bVRmx5sn1rwwVjRp|~#V~~yFb%0FgT%0k_J`djL4f|~Y-E6&v5dH=f7E}9RY)L# z>mNP$&g^hW$*}{}oyFegx#ym__uTKCbLZcDJ~x6VR?{Q=MQ2> zD1tbOvm~ep1v#CtB;oa?qlmmpHfS)x^(o3D&pIL|d}B)DJ+d$VzO ze?rV~@kCVOne&I42}P8m(>yc5OIL&#?|vql;u5^f#DxUUWYP&S%E|2vC(fmeBJ?vH zpWqciOvy|}7E;s9#F37em`q0%VG2g|of~$4hJ_dlQ<}YBOvQz1D0%u50-sU_B}tUR z7R@=H#w?-<&3Rf#MWuz|acFg4AXA=!*)`uYlauEL=VLq}1P<17QDr8kzn-4@jmqY~ zK;bv($bSU4&|a~6)Cws6*ISVj2T;qUl~HDcwQP;$ZF4Sx^(z3}(_1a^Y+UqcbQ z3%L;2z*X=Lu9A0hRlJLLhk!d5gp!Aps(CN(gr6r)<9gi9?YU-)__!LNujc)6D_0Ba z9Wd%nsMkTg!l-+o9)fyh8GSv}s}=&`M$L8pu(2iCQ*H*HKe=6By8d`%*4jCnp3d4J z<)@*NPT|TNKb3~cv=9$lvu+^?o8LtUTzVN(8O?;$Xa;)Bg2O2%#()u|^H7;j$WVcF zK5WYdqUm%(h=C%AsV+=hRo;-4=R}Fi4wbiENpYQGnorFq6LC>WMise3jK@I}fqfb^>Ox+G_O%gu+%zR zT`FzooP%>dTDF@2lW)?0j)Y2Wi7)5A<0(mS$9vmYL)dBa4HkT1fA;8bN>GGoLdX)7 zjS=W$<^*MiiAg*MdJUa`vXYo&_|z3a5>r^&HQ)KeY+rx>_=T}ac6dOm(5r(J6YR*~ zCEQXCEfa(N=LaV-ozbEBq4o5I{t;*$>l+;;Y|dRceRjB?y>R|)_7nhp`LrofVcNl9Fx5O$nb>9EmWn2M&7ICn z0XmV{>1ZOMxu1(BGCZ+Jnr%u{#3L{l*(wPiLTqwmJ`tDkd1gL;uIqvf@6rVpP`DiJ z8Zcl;?z$*Sm*uVzF@;SPGa@KmBO_y7Nj^CR$u z`V*v~q3NyTH;$_Zp1jv^bjkXmv(6Y}^7u4RwLEZr{EhL|$+y{C>^k?J_*3zHWn<`K ze(0haeE!40rTbNlYSVY`Rk5m<-Lj+F#_NeU5(RHXp`mqk;O+5S<7#WS+C8d<#tO|X zZ;3a=Ld#=scirl`9}KOX`7n57%VV!~FIoS}vk7m3#xyH-Ei`*vOmMuU`CuQ!Ii6#c zg)}IdKN+28WjHnL)Pe$aJE%fNO2tg90u~rSUWfl?J#5klq5zRyC`Zkjs0@9^bKA&Y z2Z!qwp#Cv^o&HOk4K3OfyuswjnfSJ7&sn7dIY=)LAS5w4D`&}i5L$HP>_*9{n38Zb zXU#b|+f6HH&pAl$c++;rS#rPtpB$w*W*>!mHqLd&U7}ue4$>=>beQwZ!o0hm z96jsX-G)%<9ZRUR=q{7d%lUFHz&n4=eG|QD-67pJf>W~d*OezMMTyPoQIHnfgIXD^glYBlaVy zc`;D7>Q!7&IZ*1&1xj4-+m!DBM=b`f2IeRQA9C_Mf>ZUM!)Xr9)0fa3_4o7~72Xs5 zci2KVb_MaHd)7=;ckLAsd;bOsxlN)zXzbTf*EX6V4{P3=X7T~(*{-tGnCt)JPk*mmlnGCD`5FL1g+1R zmO+772-e}?wh3uBRsb8XcEYd>xWZ-ZqJqrt;1BrFMySQaqXM`EqNEEb$ROSo z@FF<#rN#t=Dv}>qo?1o zXdYPst1PoIkpm3ocm?L0#C9$zB>AkRoe`EX=isnDtu6*$Wnf;}$@J-9t*?78BVqSe zqq@R2&3>8~T8$n7t7|fYX`m%XJK4y{+O+ts5(zxKX=Rc&}~2jAwF)%|aG-0Ha3bZDdLSib4lM$=RIrl;;T zomv|EZE)YM$`1VomXoQ zl@S(cVD^PP2wVFab;d(o3l5w_*QtTy)|CMUiofW=t-m-<;qpn>87KWqTWGME{$;Zl zmmd$Eq3K`IHYoqfNdu+yEO3gMK`m5FXKWoRrZa|Sp|<;(HC?Xl9x%N4OznDd)E%ni z7*S}!0>;)1CW(o(DEMfT2fJa4=`7{UK$;j^TZzkSa09exxoVlCc9=*j0KU}X zOZJgOW?#=jZXd*6W^x9u(vBFGfq}CL76Ytc7+Wp{`tb2BGYyZjG*hCO07g;bgIE8mQlKG@Zg@orU)*R^XSV)JZxXsdi5N_Z?XU#5>*jcPq zGF~%7pQIruXY00Y+D@DOcbI$x60m922$bm5_fLINQ-582Lsa)Xwi;b?ujULGwxgQn z6>g)sGvC~)Hg&DNyk7m@-kkq!dC9U5W6;1|&IO~Bxy zC1>F*IDVQyyWsHKi!?-rSb|*-N3N$iD~Y{qmqBRBw+)q?opY2&BV|1<&aIe{X3o54 zd?#<&9T1YCH3_ox1?Iu4@y~b^fJiFa$TQjOT;xs<&N- zVmk~CRQxdez3fU`KG3pyG#_|;jsH#H2sm-;w0iRFhc%;W#c0XpIsEe@>o5Q8==wpm z_Cz^cJAls@@_rBr;QAzW?TJI^A8=va)pwl!#}j?s^atHGD1LC9hSnX26R*JQ@F#Hn zO&rcg_$cs=?-E*|cN`6C_Fv7T(Lck-E@K04oC$s&ynY<+{r?_~oz4c)a9xsi2>BB- zMET&gNOKUunvxTJpF9(`68)4|SXdpT@iI#hNXpR}f-|gaI@5A%U)G$*V+J76m95(0 z3YpfvHScZRrEI?aW!C;aw)T`O^xI%vk%s&W8QY52s1t@kKLPf3m==HIoNmh?D)mO0d{z_Z)lL6 zymW4GLaQk?4vhC-7#)Ngd(tkofdCS|`XkPeWP_4s<>2~4qXCID8@Wo5CP*`0f9WEo z#a2v;;v@#iJBZjA;xI|ryCq2_6y!fZat%GOSZvOs7u7Yd%&9ePg__3Y=L@yX%K|Aq zaM>E{sPDe`tHB$-A{haaxC2Y_a{o$j zX)*6=+Vl~kmn?NQ|5|?$LAE}yiL+vNz$Q>@!6L%>dT^8E#g=*?j1>`N>#1#rkqXSj}FO>OUkAQ~%W+x=@jn3?N)xCY!d)sJ@mi2=lMP-vjy|KN* z9~mJK5L0j#;j#qo^s(=5!lZ7t9=akj`{6Rj{PM$L&&#-CV*shi2X+j_@S)qoZhr*O zH#h~dZEw1M^-xChGIW0ie;I#@dKGO|AZJi@)Zee!`!|=DJoh~{sVbu(teb8` z`94N1AEPHeLavXH=Og6(9Xjwk)bTN@`-A;inhHHYI4#2W#X#^?PtoqCYFAua2r}~i F{|m>&WxfCa literal 0 HcmV?d00001 diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 6c451b6ff1285452846b28e07a145dc307d5e2cd..73f53e86de239a70584f47b4e1541b1b4c0f82ca 100644 GIT binary patch literal 125405 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

m$kMYm5C?_rUsFb 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%0D~o| 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 I1eziM06mLVD*ylh delta 14769 zcmbta33yY-wbp2}@ouoe1{=#@g9T;>1IA&;4j63qMG%badu`jmoU5Qk-c!x+yt|Tg3y~$FFD}~B7Z>p5$N|VxE=~9O4zJ0kNA}4rawqzYGsbc^d2X})W|w7|81+GThbN{d{Jq{Xhq(h}DaW-^<^ zJaOp>rWtJKJyFBy->yn%Gg7P+GP{yZrf5rM636ZQmfng=@-L2BL-)lIp(+-g9BSw| zS{Np>MzfIBV?7aR>{L#K(fjYrJ-AvSpKrJ;;w-9ww zPsk)pHjrIJo&t@h8mL=|It{4P4b*K!odMLD2I_4@odwh~19dx5X9IPPfm&xa<$y8g z0=e8k-XT`+{I&eUFNe*V7d1OL(;^l}YmH2(5Z4H|h)IoBVSbM-78>=?vyXZ%h_=0^ z=R#44^ju`3^DVSg)yjT72iLbk+!)i@ zF03-}+6BBctJjE|<8+Sk1D}D#U@F%ddU|6$SKmO-HHMy2O#an3EdLgR{A&%od{HS@ ztrLfd+j3z}$7wOU-x#`Ij8U-Oz|kM&SiM2qcEh|jLS#mx$}Vg&@M{5nG$X1v_mgCB zxsX?dcw3BLqOirlZ+BF_T33{xkla5;sa6(!+Gr8Bidmw7w!vs3U)qIj2A(|1v+A~J zuj_I&3){7s7~xfC;1vK~6rHPfM0pAB>vFRYw|X%clP*zcFmP;*a;z4j9IwkyldeLn zGw>4){Mvw@=8?vm;6Dqq#ko8YHM z-!22M&M2>H?={gpxO^3&D<+#=kPQ5~V!HZnP&O@^x5oG-3Vs8>J;0CFpQ@Id;HT@l z+rXvba* z_=bT`Pn1v9?Kj9L)Nd8tYv6Sdc+s4#x>1=b#Qib9+J$`vUUvX5TA`|MMlK`2L}9;y z-<`mZJpFI#dBDKyu9%)Z{cIN7i?3NrD#U#TJr5dq-7T)rY$k6>i_b{xznN)!`?yTk%$v8X!BtWDa~yM=qAX%alx@qCt?k)Bi_ z+|$JHbMnv$n-+=8)S2x3=4Grn?ec2=ru{6SX9a8_41B0%auzdzXH9{uBN`zGas;^b);yx^+ zsC-crDtlj;)WPiZWRW~=>0&8mrN7DJTdRr3w_`)(R^zmDj+FpH5P^nk!%{0k2ZBCC z7giDW!Y7adaL{xydDwy4i)ooXtF(q)@@G?S-x9}`m?2x$DtH=#@qD}C2}kIGPhcnj z@Ew}{rZxLw269hI>sg_3?*{I<^2sFy-<~#NH7Zi$v<SA)*ffZUfr4Kz&b5YJK zo0XAOd@Oi=Sn0l%n#PC8pqCabkUuW#41K*O&eV8$sSnL*JucI_8HGRa?*#^olj|o{AYlr8=agwchIqG>d{WC0-JJ{D+Vw$wW3h3RTtO@df!Y ze9(>%C&U9tLwHi|T2Q44@j%2jKTm!TpAyrD<)8XTgq#`iV^fPbD`*|CD@pNvEd2lh z<>5a>IEFBgJdfZ5gkQlY&c%G70cr$vhK(@FFk==5H7$ckO%-)$;`T7&C;Uz z30V7gt{s%yK!*MqvP5_XSwg`#C!0$u&w*1f9gL!B$=Ty}O`2z3cc~DdBjUI1MSe_lr-DY|B$t?L7AE(Rj9UW$lR5LE5n1g27@l@XTHnY*n zj!n#&VClB-;lUi`TdP=JMz^^x8G6&7Z(=Yr6kj zv@_?B8h%!8-{MF-2kcblSTBFFWp1^Z_d%7Tzn#PRZ?KHsl^Yb$Z=rZ(CBRELph8z^>DQzP6G6J5kJGUKWwzE*}lAqr)GJ&I_`~|sW zdok;iYql?4Ny3pTui&HKA&`zFFhv*X6O-C$;qPN368B9g1<)51U-Adr3v3@kZBwtg zZmFe=tZE6g)Q~5o2fNN1BT$T5BqKpZr>L;mA^CNC8A%EXL{`2rtS> zp6NxXD+M3;O+ST=D0#caCZP9lkjh=f+sMfGV9ys3;zo*% zaXd0cEI{-zpqrZB(Cqd#iMpC!0QGUXZ&!D(k3Y^r$IUg)v;iYy@H7;~(c%V66wR0x z9M7Z#(hvx7u{Hq5sz_!VW2r}s&X-a22f#O`$cl=tu9HOmPar)9^v~r>!5=4mjyBrA z#InTJyNy4`%K6`s?LQE{1TZp4Mg}e151WU`{{;_U1upz6Z2CU{y@y*jW8i(WkAtuvtyS{Fz zb{YnzDHj8+-k@ruI!Cc-@r30J{UXt2*RWIl$JEolhj1J zMRk>*^{1|hDGCKd7(Mi-kdGm*bAzBc19}lV>^(%~ZrY!I(Rxwt&35Q__uVM8(0PGYr;Atehk8lP7 zHBwW>4#;FMA)1BBzG_9JRF%9J^O^0w zq}nhX0@`jc4qNv(97Y7p%sQ==1H{^44qqO@b}~CUvcV$nelII5<+A^_gfLFNO4|VZ z;wJzF!$3+bL-8sRst|5Pz!bd3n0_O%1_5=_S9;h^aZOgU;ch436@wwjXzL);0P}h3 zML{zIZUhuh&e8GtLtKQCwQoj7o{vtd#sTP<5L$)BI6?b+iI2rDia1FJXxskc=fg7|CRK&g2&n{(un66gldz zrnk5|C0x@ZrxzX?bk^g9*K*`3P=a-gY*UQt=uwOY;_m`nvx23-w!r;uBH4;Nq1$6< zB?~~rN~V;w!*jxQC5%}!`kRXPDJ`XJ94!KT6m*3P5n9v+wG)|$jCBM0;B1fTLrYnI zebgkpqJ){LWKUx0Mdi?19U9q-$?=iZs1yWy7j^m%ld`6SEe#)<#7Yw5RRM3$VwY)( z@~4&FGB$I#ngkqpunw~TH4*ksDk-0pu`KqiVxP@6o5wR{$80ty>9PQ$cUdM+Z&ZFX zn=KCYL6sb#rbp&iOVH!@K_EJvUt=OYluFQtG#$nuyQdMJL--}auMh^XqMBOo33&qj zkrrR)5Yjr+kTugP8d9#zVGCKgGHos^u-^)b{mmTSIG0_pjp!dzms5aY4F|g}TEM2* z;RjeIh_bc?to&OM5%gx_}mhrb3xo$!7_!QeRmTj*A1zId&$1--&5_hke zGvhH7Hjd?v4Z>1oI>oUr?PPV%tu08tI!c7Qm_pg zRgYp5+FoKTqeo9&PfTaxj-Dx+w2`D`4Kz3m=j^~$U`LDD7b&JxC4U9`hdmdbb>+wf zr*p#7tJ(jWH7CYWHi)_THEfLY`YBqRW{m^PMlCaoDGEFAV3}FGw^AZYnU-X)19_i9 zU9y>+c4}F8Wi6|=QvRUlnfz_gGkQyayFes!^|>VxPBVDFt6hSfK=b|r8zjR^2w2$Y zkmJnO!jhae&?Yw;yOH>mY~^A0r3D}S7Bu27npsP*A%!W8q(+D1o3I@w0r0+q81?t2 zYNKS7r(&}a2sj7nf;W<5MNSv#TH2kBJEYcS3$%UA-tZPjhE1GSfWmnUU7E9Xw^ktm(_lwvK!{FN7yy6T#qvM9I zGj@;YtR1M*B{$!N?~K4*ZeN4Qw}Qr3EaAefOk~Nlt?#dzTQ!e`@7>1IEw`_G4#lq1wbD}tp~&X|ZJV4qK%56zrr?uO@TlH~5F^NPNv zU^B-JvF7p@_>}QRDJIb9Rdn~sR`U1};_@cM# z0mxb)3V69v*~n&?S6h`mjcj5FWp!r8zj{i;UO2k8aJFHI*5*ulfW!R|OG6QMVU;q0 zcC4l&_>dN73;}5fx1uqE(CnZ|DQ;qc1TvVwl%q}T$`o=NB`Fk4#25n%l)>OuZ7p86 zFQBV)rWtO;cSD1X*6^if)?z83&?D(d=AAh1A_PrR#qVXqLo$lVkZun% z*kiFJ$wFBZait(QV^hD^eYo3mTPYX@^))@ zg~VnvD@vhU<+Dfcjloa?aKDQ%~dZgJvO0{B> zLG?i+R%%wXX*s372`jB;6ZD6V>`RuuI;()K%vtjKa28oVRbw}SGpr|Yv{n9)Whf zS`jkd_?*YVUq^`LgWA43P=HXtlWou}un$oHoyOi#v%pUmP?J3f6xJAC7zOaB1wwN4 zghhtA9UAso!~b(v8S9@etWbNbP;l`l>bjaSt2A!keCB zuST{rUHP-h%u{Tu`Lap5?-bjSNtwe&v?>j}iz(*QY%Ob2Hk^)LEyO8L?ev*IbBC6~ zPtdG?L70xP9Y7tjtfN7s117}*T+DLr=h}fRK|6}+x{|6X@Cy_lrV@3nyv(a61jIl9 z&ZjC9ue}OPk4%MzX0c(Hzct7Yfg%OA@Z>Y>nM9cUAHBqi**PV-kJaY?3B|Gd>vxI` zK{ZhlgKm6-r6q|^EA4%3Cc8^H*~jt|`cV2RrksaLG1-Sy#&>hP41rd**;tx`FqbJq zf5TR>Bg&57u&R)n&@8&)Hax&ZWJIgt3@qUus?CPGuv;ruX?q&zPJ)b1bA`s$H-~{@ zY9_K;giLp`TL5VhDnKHM>cG-sd>nUdo{JCAPw=Ce(^x*E)V&N#AZ2{_VuyPWbQ@sO zOA}Gej;>MBTRP3jRnR_=3awyT>VJ`yIsm^aY7y(&#rkHye;0oM+TIGQdEhimNbN=^ z$&{U^tPKC-W%h}gtyli|JGO}(R_4CS#w-3;*~rik;H;(vc6;4k@VV4E87@hA5)zYi zBE7#=6I(hV8x49IIG)?JAJI`Fl8(*o!Oj-AhtPiXM`;KpA7os*mB^-6f}h^{4aZ7C ziyN{iucw}mz-kiw)+s1<1d)&f1didaCcqm;c*WSHT3cJ;YVQ@KC1Rk}yWxfW z2t5dQBOF3FjG!Pqfba+cosXZu($5f{M0g57wYK=d{}-@M^MbHGx}|B3J~1blr(uWaaz2HpH~2hxfV+OY}V5^}s&<9L(9@iK(hAgn>aqbkSq z7#&eKX8pVlVFv=F*QHuY$uR-sm?lv=LAxx+9fj{hz*PpKSK0eIn^>XR0xe!o{Ld%V z#0qe3t5=-QpNH1)ZwfqIT>uyk-1-(;OmQ`1UBY=D8ttmLzM^LWIrh^VC&fZ4OdL~eXRX&^!>TT zWFB|LjHD}z!wnbMa+dQMFtFbr{2PAZ^SM{dP`WZsdE+g%hs{y$P}wJJVL0IfxNl*v uhcEq+g&BKYx&KdWY1({~IpxlReFf(;C%nlfDw%J?uQA46X3tnL$NoP str: + """ + Generate unique event identifier (UUID4 hex-encoded per RFC 7986) + + Returns: + str: Unique identifier in format {uuid}@{domain} + """ + return f"{uuid.uuid4().hex}@{self.domain}" + + def event_to_ical_event(self, event, include_reminder: bool = True): + """ + Convert database Event model to iCalendar Event component + + Args: + event: Event model instance from database + include_reminder: Whether to add 1-hour reminder alarm + + Returns: + icalendar.Event: iCalendar event component + """ + ical_event = iCalEvent() + + # Required properties + ical_event.add('uid', event.calendar_uid or self.generate_event_uid()) + ical_event.add('dtstamp', datetime.now(self.timezone)) + ical_event.add('dtstart', event.start_at) + ical_event.add('dtend', event.end_at) + ical_event.add('summary', event.title) + + # Optional properties + if event.description: + ical_event.add('description', event.description) + if event.location: + ical_event.add('location', event.location) + + # Metadata + ical_event.add('url', f"https://{self.domain}/events/{event.id}") + ical_event.add('status', 'CONFIRMED') + ical_event.add('sequence', 0) + + # Add 1-hour reminder (VALARM component) + if include_reminder: + alarm = Alarm() + alarm.add('action', 'DISPLAY') + alarm.add('description', f"Reminder: {event.title}") + alarm.add('trigger', timedelta(hours=-1)) + ical_event.add_component(alarm) + + return ical_event + + def create_calendar(self, name: str, description: str = None): + """ + Create base calendar with metadata + + Args: + name: Calendar name (X-WR-CALNAME) + description: Optional calendar description + + Returns: + icalendar.Calendar: Base calendar object + """ + cal = Calendar() + cal.add('prodid', '-//LOAF Membership Platform//EN') + cal.add('version', '2.0') + cal.add('x-wr-calname', name) + cal.add('x-wr-timezone', str(self.timezone)) + if description: + cal.add('x-wr-caldesc', description) + cal.add('method', 'PUBLISH') + cal.add('calscale', 'GREGORIAN') + return cal + + def create_single_event_calendar(self, event) -> bytes: + """ + Create calendar with single event for download + + Args: + event: Event model instance + + Returns: + bytes: iCalendar data as bytes + """ + cal = self.create_calendar(event.title) + ical_event = self.event_to_ical_event(event) + cal.add_component(ical_event) + return cal.to_ics() + + def create_subscription_feed(self, events: list, feed_name: str) -> bytes: + """ + Create calendar subscription feed with multiple events + + Args: + events: List of Event model instances + feed_name: Name for the calendar feed + + Returns: + bytes: iCalendar data as bytes + """ + cal = self.create_calendar( + feed_name, + description="LOAF Community Events - Auto-syncing calendar feed" + ) + + for event in events: + ical_event = self.event_to_ical_event(event) + cal.add_component(ical_event) + + return cal.to_ics() diff --git a/email_service.py b/email_service.py index 1933a26..735c27b 100644 --- a/email_service.py +++ b/email_service.py @@ -135,13 +135,13 @@ async def send_verification_email(to_email: str, token: str): @@ -156,7 +156,7 @@ async def send_verification_email(to_email: str, token: str): Verify Email

Or copy and paste this link into your browser:

-

{verification_url}

+

{verification_url}

This link will expire in 24 hours.

If you didn't create an account, please ignore this email.

@@ -175,12 +175,13 @@ async def send_approval_notification(to_email: str, first_name: str): @@ -211,17 +212,17 @@ async def send_payment_prompt_email(to_email: str, first_name: str): @@ -250,7 +251,7 @@ async def send_payment_prompt_email(to_email: str, first_name: str):

We're excited to have you join the LOAF community!

-

+

Questions? Contact us at support@loaf.org

@@ -269,14 +270,14 @@ async def send_password_reset_email(to_email: str, first_name: str, reset_url: s @@ -297,7 +298,7 @@ async def send_password_reset_email(to_email: str, first_name: str, reset_url: s

If you didn't request this, please ignore this email.

-

+

Or copy and paste this link into your browser:
{reset_url}

@@ -320,8 +321,8 @@ async def send_admin_password_reset_email( force_change_text = ( """ -
-

⚠️ You will be required to change this password when you log in.

+
+

⚠️ You will be required to change this password when you log in.

""" ) if force_change else "" @@ -333,15 +334,15 @@ async def send_admin_password_reset_email( @@ -365,7 +366,7 @@ async def send_admin_password_reset_email( Go to Login

-

+

Questions? Contact us at support@loaf.org

diff --git a/migrations/README.md b/migrations/README.md new file mode 100644 index 0000000..4670569 --- /dev/null +++ b/migrations/README.md @@ -0,0 +1,138 @@ +# Database Migrations + +This folder contains SQL migration scripts for the membership platform. + +## Running the Sprint 1-3 Migration + +The `sprint_1_2_3_migration.sql` file adds all necessary columns and tables for the Members Only features (Sprints 1, 2, and 3). + +### Prerequisites + +- PostgreSQL installed and running +- Database created (e.g., `membership_db`) +- Database connection credentials from your `.env` file + +### Option 1: Using psql command line + +```bash +# Navigate to the migrations directory +cd /Users/andika/Documents/Works/Koncept\ Kit/KKN/membership-website/backend/migrations + +# Run the migration (replace with your database credentials) +psql -U your_username -d membership_db -f sprint_1_2_3_migration.sql + +# Or if you have a connection string +psql "postgresql://user:password@localhost:5432/membership_db" -f sprint_1_2_3_migration.sql +``` + +### Option 2: Using pgAdmin or another GUI tool + +1. Open pgAdmin and connect to your database +2. Open the Query Tool +3. Load the `sprint_1_2_3_migration.sql` file +4. Execute the script + +### Option 3: Using Python script + +```bash +cd /Users/andika/Documents/Works/Koncept\ Kit/KKN/membership-website/backend + +# Run the migration using Python +python3 -c " +import psycopg2 +import os +from dotenv import load_dotenv + +load_dotenv() +DATABASE_URL = os.getenv('DATABASE_URL') + +conn = psycopg2.connect(DATABASE_URL) +cur = conn.cursor() + +with open('migrations/sprint_1_2_3_migration.sql', 'r') as f: + sql = f.read() + cur.execute(sql) + +conn.commit() +cur.close() +conn.close() +print('Migration completed successfully!') +" +``` + +### What Gets Added + +**Users Table:** +- `profile_photo_url` - Stores Cloudflare R2 URL for profile photos +- `social_media_facebook` - Facebook profile/page URL +- `social_media_instagram` - Instagram handle or URL +- `social_media_twitter` - Twitter/X handle or URL +- `social_media_linkedin` - LinkedIn profile URL + +**Events Table:** +- `microsoft_calendar_id` - Microsoft Calendar event ID for syncing +- `microsoft_calendar_sync_enabled` - Boolean flag for sync status + +**New Tables:** +- `event_galleries` - Stores event photos with captions +- `newsletter_archives` - Stores newsletter documents +- `financial_reports` - Stores annual financial reports +- `bylaws_documents` - Stores organization bylaws +- `storage_usage` - Tracks Cloudflare R2 storage usage + +### Verification + +After running the migration, verify it worked: + +```sql +-- Check users table columns +SELECT column_name, data_type +FROM information_schema.columns +WHERE table_name = 'users' +AND column_name IN ('profile_photo_url', 'social_media_facebook'); + +-- Check new tables exist +SELECT table_name +FROM information_schema.tables +WHERE table_name IN ('event_galleries', 'storage_usage'); + +-- Check storage_usage has initial record +SELECT * FROM storage_usage; +``` + +### Troubleshooting + +**Error: "relation does not exist"** +- Make sure you're connected to the correct database +- Verify the `users` and `events` tables exist first + +**Error: "column already exists"** +- This is safe to ignore - the script uses `IF NOT EXISTS` clauses + +**Error: "permission denied"** +- Make sure your database user has ALTER TABLE privileges +- You may need to run as a superuser or database owner + +### Rollback (if needed) + +If you need to undo the migration: + +```sql +-- Remove new columns from users +ALTER TABLE users DROP COLUMN IF EXISTS profile_photo_url; +ALTER TABLE users DROP COLUMN IF EXISTS social_media_facebook; +ALTER TABLE users DROP COLUMN IF EXISTS social_media_instagram; +ALTER TABLE users DROP COLUMN IF EXISTS social_media_twitter; +ALTER TABLE users DROP COLUMN IF EXISTS social_media_linkedin; + +-- Remove new columns from events +ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_id; +ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_sync_enabled; + +-- Remove new tables +DROP TABLE IF EXISTS event_galleries; +DROP TABLE IF EXISTS newsletter_archives; +DROP TABLE IF EXISTS financial_reports; +DROP TABLE IF EXISTS bylaws_documents; +DROP TABLE IF EXISTS storage_usage; +``` diff --git a/migrations/add_calendar_uid.sql b/migrations/add_calendar_uid.sql new file mode 100644 index 0000000..6a90f88 --- /dev/null +++ b/migrations/add_calendar_uid.sql @@ -0,0 +1,38 @@ +-- Migration: Add calendar_uid to events table and remove Microsoft Calendar columns +-- Sprint 2: Universal Calendar Export + +-- Add new calendar_uid column +ALTER TABLE events ADD COLUMN IF NOT EXISTS calendar_uid VARCHAR; + +-- Remove old Microsoft Calendar columns (if they exist) +ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_id; +ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_sync_enabled; + +-- Verify migration +DO $$ +BEGIN + -- Check if calendar_uid exists + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'calendar_uid' + ) THEN + RAISE NOTICE '✅ calendar_uid column added successfully'; + ELSE + RAISE NOTICE '⚠️ calendar_uid column not found'; + END IF; + + -- Check if old columns are removed + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'microsoft_calendar_id' + ) THEN + RAISE NOTICE '✅ microsoft_calendar_id column removed'; + END IF; + + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'microsoft_calendar_sync_enabled' + ) THEN + RAISE NOTICE '✅ microsoft_calendar_sync_enabled column removed'; + END IF; +END $$; diff --git a/migrations/complete_fix.sql b/migrations/complete_fix.sql new file mode 100644 index 0000000..625b97f --- /dev/null +++ b/migrations/complete_fix.sql @@ -0,0 +1,161 @@ +-- Complete Fix for Sprint 1-3 Migration +-- Safe to run multiple times + +-- ============================================== +-- Step 1: Add columns to users table +-- ============================================== + +DO $$ +BEGIN + -- Add profile_photo_url + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'profile_photo_url' + ) THEN + ALTER TABLE users ADD COLUMN profile_photo_url VARCHAR; + RAISE NOTICE 'Added profile_photo_url to users table'; + ELSE + RAISE NOTICE 'profile_photo_url already exists in users table'; + END IF; + + -- Add social_media_facebook + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'social_media_facebook' + ) THEN + ALTER TABLE users ADD COLUMN social_media_facebook VARCHAR; + RAISE NOTICE 'Added social_media_facebook to users table'; + ELSE + RAISE NOTICE 'social_media_facebook already exists in users table'; + END IF; + + -- Add social_media_instagram + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'social_media_instagram' + ) THEN + ALTER TABLE users ADD COLUMN social_media_instagram VARCHAR; + RAISE NOTICE 'Added social_media_instagram to users table'; + ELSE + RAISE NOTICE 'social_media_instagram already exists in users table'; + END IF; + + -- Add social_media_twitter + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'social_media_twitter' + ) THEN + ALTER TABLE users ADD COLUMN social_media_twitter VARCHAR; + RAISE NOTICE 'Added social_media_twitter to users table'; + ELSE + RAISE NOTICE 'social_media_twitter already exists in users table'; + END IF; + + -- Add social_media_linkedin + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'social_media_linkedin' + ) THEN + ALTER TABLE users ADD COLUMN social_media_linkedin VARCHAR; + RAISE NOTICE 'Added social_media_linkedin to users table'; + ELSE + RAISE NOTICE 'social_media_linkedin already exists in users table'; + END IF; +END $$; + +-- ============================================== +-- Step 2: Add columns to events table +-- ============================================== + +DO $$ +BEGIN + -- Add microsoft_calendar_id + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'microsoft_calendar_id' + ) THEN + ALTER TABLE events ADD COLUMN microsoft_calendar_id VARCHAR; + RAISE NOTICE 'Added microsoft_calendar_id to events table'; + ELSE + RAISE NOTICE 'microsoft_calendar_id already exists in events table'; + END IF; + + -- Add microsoft_calendar_sync_enabled + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'events' AND column_name = 'microsoft_calendar_sync_enabled' + ) THEN + ALTER TABLE events ADD COLUMN microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE; + RAISE NOTICE 'Added microsoft_calendar_sync_enabled to events table'; + ELSE + RAISE NOTICE 'microsoft_calendar_sync_enabled already exists in events table'; + END IF; +END $$; + +-- ============================================== +-- Step 3: Fix storage_usage initialization +-- ============================================== + +-- Delete any incomplete records +DELETE FROM storage_usage WHERE id IS NULL; + +-- Insert initial record if table is empty +INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated) +SELECT + gen_random_uuid(), + 0, + 10737418240, -- 10GB default + CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM storage_usage); + +-- ============================================== +-- Step 4: Verify everything +-- ============================================== + +DO $$ +DECLARE + user_col_count INT; + event_col_count INT; + storage_count INT; +BEGIN + -- Count users columns + SELECT COUNT(*) INTO user_col_count + FROM information_schema.columns + WHERE table_name = 'users' + AND column_name IN ( + 'profile_photo_url', + 'social_media_facebook', + 'social_media_instagram', + 'social_media_twitter', + 'social_media_linkedin' + ); + + -- Count events columns + SELECT COUNT(*) INTO event_col_count + FROM information_schema.columns + WHERE table_name = 'events' + AND column_name IN ( + 'microsoft_calendar_id', + 'microsoft_calendar_sync_enabled' + ); + + -- Count storage_usage records + SELECT COUNT(*) INTO storage_count FROM storage_usage; + + -- Report results + RAISE NOTICE ''; + RAISE NOTICE '========================================'; + RAISE NOTICE 'Migration Verification Results:'; + RAISE NOTICE '========================================'; + RAISE NOTICE 'Users table: %/5 columns added', user_col_count; + RAISE NOTICE 'Events table: %/2 columns added', event_col_count; + RAISE NOTICE 'Storage usage: % record(s)', storage_count; + RAISE NOTICE ''; + + IF user_col_count = 5 AND event_col_count = 2 AND storage_count > 0 THEN + RAISE NOTICE '✅ Migration completed successfully!'; + ELSE + RAISE NOTICE '⚠️ Migration incomplete. Please check the logs above.'; + END IF; + RAISE NOTICE '========================================'; +END $$; diff --git a/migrations/fix_storage_usage.sql b/migrations/fix_storage_usage.sql new file mode 100644 index 0000000..5ed6467 --- /dev/null +++ b/migrations/fix_storage_usage.sql @@ -0,0 +1,17 @@ +-- Fix storage_usage table initialization +-- This script safely initializes storage_usage if empty + +-- Delete any incomplete records first +DELETE FROM storage_usage WHERE id IS NULL; + +-- Insert with explicit UUID generation if table is empty +INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated) +SELECT + gen_random_uuid(), + 0, + 10737418240, -- 10GB default + CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM storage_usage); + +-- Verify the record was created +SELECT * FROM storage_usage; diff --git a/migrations/sprint_1_2_3_migration.sql b/migrations/sprint_1_2_3_migration.sql new file mode 100644 index 0000000..0448f70 --- /dev/null +++ b/migrations/sprint_1_2_3_migration.sql @@ -0,0 +1,117 @@ +-- Sprint 1, 2, 3 Database Migration +-- This script adds all new columns and tables for Members Only features + +-- ============================================== +-- Step 1: Add new columns to users table +-- ============================================== + +-- Add profile photo and social media columns (Sprint 1) +ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_photo_url VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_facebook VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_instagram VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_twitter VARCHAR; +ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_linkedin VARCHAR; + +-- ============================================== +-- Step 2: Add new columns to events table +-- ============================================== + +-- Add Microsoft Calendar integration columns (Sprint 2) +ALTER TABLE events ADD COLUMN IF NOT EXISTS microsoft_calendar_id VARCHAR; +ALTER TABLE events ADD COLUMN IF NOT EXISTS microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE; + +-- ============================================== +-- Step 3: Create new tables +-- ============================================== + +-- EventGallery table (Sprint 3) +CREATE TABLE IF NOT EXISTS event_galleries ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + image_url VARCHAR NOT NULL, + image_key VARCHAR NOT NULL, + caption TEXT, + uploaded_by UUID NOT NULL REFERENCES users(id), + file_size_bytes INTEGER NOT NULL, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Create index for faster queries +CREATE INDEX IF NOT EXISTS idx_event_galleries_event_id ON event_galleries(event_id); +CREATE INDEX IF NOT EXISTS idx_event_galleries_uploaded_by ON event_galleries(uploaded_by); + +-- NewsletterArchive table (Sprint 4 - preparing ahead) +CREATE TABLE IF NOT EXISTS newsletter_archives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR NOT NULL, + description TEXT, + published_date TIMESTAMP WITH TIME ZONE NOT NULL, + document_url VARCHAR NOT NULL, + document_type VARCHAR DEFAULT 'google_docs', + file_size_bytes INTEGER, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_newsletter_archives_published_date ON newsletter_archives(published_date DESC); +CREATE INDEX IF NOT EXISTS idx_newsletter_archives_created_by ON newsletter_archives(created_by); + +-- FinancialReport table (Sprint 4 - preparing ahead) +CREATE TABLE IF NOT EXISTS financial_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + year INTEGER NOT NULL, + title VARCHAR NOT NULL, + document_url VARCHAR NOT NULL, + document_type VARCHAR DEFAULT 'google_drive', + file_size_bytes INTEGER, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_financial_reports_year ON financial_reports(year DESC); +CREATE INDEX IF NOT EXISTS idx_financial_reports_created_by ON financial_reports(created_by); + +-- BylawsDocument table (Sprint 4 - preparing ahead) +CREATE TABLE IF NOT EXISTS bylaws_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR NOT NULL, + version VARCHAR NOT NULL, + effective_date TIMESTAMP WITH TIME ZONE NOT NULL, + document_url VARCHAR NOT NULL, + document_type VARCHAR DEFAULT 'google_drive', + file_size_bytes INTEGER, + is_current BOOLEAN DEFAULT TRUE, + created_by UUID NOT NULL REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_bylaws_documents_is_current ON bylaws_documents(is_current); +CREATE INDEX IF NOT EXISTS idx_bylaws_documents_created_by ON bylaws_documents(created_by); + +-- StorageUsage table (Sprint 1) +CREATE TABLE IF NOT EXISTS storage_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + total_bytes_used BIGINT DEFAULT 0, + max_bytes_allowed BIGINT NOT NULL, + last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Insert initial storage usage record +INSERT INTO storage_usage (total_bytes_used, max_bytes_allowed) +SELECT 0, 10737418240 +WHERE NOT EXISTS (SELECT 1 FROM storage_usage); + +-- ============================================== +-- Migration Complete +-- ============================================== + +-- Verify migrations +DO $$ +BEGIN + RAISE NOTICE 'Migration completed successfully!'; + RAISE NOTICE 'New columns added to users table: profile_photo_url, social_media_*'; + RAISE NOTICE 'New columns added to events table: microsoft_calendar_*'; + RAISE NOTICE 'New tables created: event_galleries, newsletter_archives, financial_reports, bylaws_documents, storage_usage'; +END $$; diff --git a/migrations/verify_columns.sql b/migrations/verify_columns.sql new file mode 100644 index 0000000..0ad033e --- /dev/null +++ b/migrations/verify_columns.sql @@ -0,0 +1,54 @@ +-- Verification script to check which columns exist +-- Run this to see what's missing + +-- Check users table columns +SELECT + 'users' as table_name, + column_name, + data_type, + is_nullable +FROM information_schema.columns +WHERE table_name = 'users' +AND column_name IN ( + 'profile_photo_url', + 'social_media_facebook', + 'social_media_instagram', + 'social_media_twitter', + 'social_media_linkedin' +) +ORDER BY column_name; + +-- Check events table columns +SELECT + 'events' as table_name, + column_name, + data_type, + is_nullable +FROM information_schema.columns +WHERE table_name = 'events' +AND column_name IN ( + 'microsoft_calendar_id', + 'microsoft_calendar_sync_enabled' +) +ORDER BY column_name; + +-- Check which tables exist +SELECT + table_name, + 'EXISTS' as status +FROM information_schema.tables +WHERE table_name IN ( + 'event_galleries', + 'newsletter_archives', + 'financial_reports', + 'bylaws_documents', + 'storage_usage' +) +ORDER BY table_name; + +-- Check storage_usage contents +SELECT + COUNT(*) as record_count, + SUM(total_bytes_used) as total_bytes, + MAX(max_bytes_allowed) as max_bytes +FROM storage_usage; diff --git a/models.py b/models.py index 79c812f..abd7973 100644 --- a/models.py +++ b/models.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, ForeignKey, JSON +from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import relationship from datetime import datetime, timezone @@ -82,6 +82,13 @@ class User(Base): password_reset_expires = Column(DateTime, nullable=True) force_password_change = Column(Boolean, default=False, nullable=False) + # Members Only - Profile Photo & Social Media + profile_photo_url = Column(String, nullable=True) # Cloudflare R2 URL + social_media_facebook = Column(String, nullable=True) + social_media_instagram = Column(String, nullable=True) + social_media_twitter = Column(String, nullable=True) + social_media_linkedin = Column(String, nullable=True) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) @@ -92,7 +99,7 @@ class User(Base): class Event(Base): __tablename__ = "events" - + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) title = Column(String, nullable=False) description = Column(Text, nullable=True) @@ -102,12 +109,17 @@ class Event(Base): capacity = Column(Integer, nullable=True) created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) published = Column(Boolean, default=False) + + # Members Only - Universal Calendar Export + calendar_uid = Column(String, nullable=True) # Unique iCalendar UID (UUID4-based) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) - + # Relationships creator = relationship("User", back_populates="events_created") rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan") + gallery_images = relationship("EventGallery", back_populates="event", cascade="all, delete-orphan") class EventRSVP(Base): __tablename__ = "event_rsvps" @@ -167,3 +179,77 @@ class Subscription(Base): # Relationships user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id]) plan = relationship("SubscriptionPlan", back_populates="subscriptions") + +class EventGallery(Base): + __tablename__ = "event_galleries" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + event_id = Column(UUID(as_uuid=True), ForeignKey("events.id"), nullable=False) + image_url = Column(String, nullable=False) # Cloudflare R2 URL + image_key = Column(String, nullable=False) # R2 object key for deletion + caption = Column(Text, nullable=True) + uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + file_size_bytes = Column(Integer, nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + event = relationship("Event", back_populates="gallery_images") + uploader = relationship("User") + +class NewsletterArchive(Base): + __tablename__ = "newsletter_archives" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String, nullable=False) + description = Column(Text, nullable=True) + published_date = Column(DateTime, nullable=False) + document_url = Column(String, nullable=False) # Google Docs URL or R2 URL + document_type = Column(String, default="google_docs") # google_docs, pdf, upload + file_size_bytes = Column(Integer, nullable=True) # For uploaded files + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + creator = relationship("User") + +class FinancialReport(Base): + __tablename__ = "financial_reports" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + year = Column(Integer, nullable=False) + title = Column(String, nullable=False) # e.g., "2024 Annual Report" + document_url = Column(String, nullable=False) # Google Drive URL or R2 URL + document_type = Column(String, default="google_drive") # google_drive, pdf, upload + file_size_bytes = Column(Integer, nullable=True) # For uploaded files + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationships + creator = relationship("User") + +class BylawsDocument(Base): + __tablename__ = "bylaws_documents" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String, nullable=False) + version = Column(String, nullable=False) # e.g., "v1.0", "v2.0" + effective_date = Column(DateTime, nullable=False) + document_url = Column(String, nullable=False) # Google Drive URL or R2 URL + document_type = Column(String, default="google_drive") # google_drive, pdf, upload + file_size_bytes = Column(Integer, nullable=True) # For uploaded files + is_current = Column(Boolean, default=True) # Only one should be current + created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False) + created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc)) + + # Relationships + creator = relationship("User") + +class StorageUsage(Base): + __tablename__ = "storage_usage" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + total_bytes_used = Column(BigInteger, default=0) + max_bytes_allowed = Column(BigInteger, nullable=False) # From .env + last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc)) diff --git a/ms_calendar_service.py b/ms_calendar_service.py new file mode 100644 index 0000000..af54919 --- /dev/null +++ b/ms_calendar_service.py @@ -0,0 +1,320 @@ +""" +Microsoft Calendar Service +Handles OAuth2 authentication and event synchronization with Microsoft Graph API +""" + +from msal import ConfidentialClientApplication +import requests +import os +from datetime import datetime, timezone +from typing import Optional, Dict, Any +from fastapi import HTTPException + + +class MSCalendarService: + """ + Microsoft Calendar Service using MSAL and Microsoft Graph API + """ + + def __init__(self): + """Initialize MSAL client with credentials from environment""" + self.client_id = os.getenv('MS_CALENDAR_CLIENT_ID') + self.client_secret = os.getenv('MS_CALENDAR_CLIENT_SECRET') + self.tenant_id = os.getenv('MS_CALENDAR_TENANT_ID') + self.redirect_uri = os.getenv('MS_CALENDAR_REDIRECT_URI') + + if not all([self.client_id, self.client_secret, self.tenant_id]): + raise ValueError("Microsoft Calendar credentials not properly configured in environment variables") + + # Initialize MSAL Confidential Client + self.app = ConfidentialClientApplication( + client_id=self.client_id, + client_credential=self.client_secret, + authority=f"https://login.microsoftonline.com/{self.tenant_id}" + ) + + # Microsoft Graph API endpoints + self.graph_url = "https://graph.microsoft.com/v1.0" + self.scopes = ["https://graph.microsoft.com/.default"] + + def get_access_token(self) -> str: + """ + Get access token using client credentials flow + + Returns: + str: Access token for Microsoft Graph API + + Raises: + HTTPException: If token acquisition fails + """ + try: + result = self.app.acquire_token_for_client(scopes=self.scopes) + + if "access_token" in result: + return result["access_token"] + else: + error = result.get("error_description", "Unknown error") + raise HTTPException( + status_code=500, + detail=f"Failed to acquire access token: {error}" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Microsoft authentication error: {str(e)}" + ) + + def _make_graph_request( + self, + method: str, + endpoint: str, + data: Optional[Dict[Any, Any]] = None + ) -> Dict[Any, Any]: + """ + Make an authenticated request to Microsoft Graph API + + Args: + method: HTTP method (GET, POST, PATCH, DELETE) + endpoint: API endpoint path (e.g., "/me/events") + data: Request body data + + Returns: + Dict: Response JSON + + Raises: + HTTPException: If request fails + """ + token = self.get_access_token() + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json" + } + + url = f"{self.graph_url}{endpoint}" + + try: + if method.upper() == "GET": + response = requests.get(url, headers=headers) + elif method.upper() == "POST": + response = requests.post(url, headers=headers, json=data) + elif method.upper() == "PATCH": + response = requests.patch(url, headers=headers, json=data) + elif method.upper() == "DELETE": + response = requests.delete(url, headers=headers) + else: + raise ValueError(f"Unsupported HTTP method: {method}") + + response.raise_for_status() + + # DELETE requests may return 204 No Content + if response.status_code == 204: + return {} + + return response.json() + + except requests.exceptions.HTTPError as e: + raise HTTPException( + status_code=e.response.status_code, + detail=f"Microsoft Graph API error: {e.response.text}" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Request to Microsoft Graph failed: {str(e)}" + ) + + async def create_event( + self, + title: str, + description: str, + location: str, + start_at: datetime, + end_at: datetime, + calendar_id: str = "primary" + ) -> str: + """ + Create an event in Microsoft Calendar + + Args: + title: Event title + description: Event description + location: Event location + start_at: Event start datetime (timezone-aware) + end_at: Event end datetime (timezone-aware) + calendar_id: Calendar ID (default: "primary") + + Returns: + str: Microsoft Calendar Event ID + + Raises: + HTTPException: If event creation fails + """ + event_data = { + "subject": title, + "body": { + "contentType": "HTML", + "content": description or "" + }, + "start": { + "dateTime": start_at.isoformat(), + "timeZone": "UTC" + }, + "end": { + "dateTime": end_at.isoformat(), + "timeZone": "UTC" + }, + "location": { + "displayName": location + } + } + + # Use /me/events for primary calendar or /me/calendars/{id}/events for specific calendar + endpoint = "/me/events" if calendar_id == "primary" else f"/me/calendars/{calendar_id}/events" + + result = self._make_graph_request("POST", endpoint, event_data) + return result.get("id") + + async def update_event( + self, + event_id: str, + title: Optional[str] = None, + description: Optional[str] = None, + location: Optional[str] = None, + start_at: Optional[datetime] = None, + end_at: Optional[datetime] = None + ) -> bool: + """ + Update an existing event in Microsoft Calendar + + Args: + event_id: Microsoft Calendar Event ID + title: Updated event title (optional) + description: Updated description (optional) + location: Updated location (optional) + start_at: Updated start datetime (optional) + end_at: Updated end datetime (optional) + + Returns: + bool: True if successful + + Raises: + HTTPException: If update fails + """ + event_data = {} + + if title: + event_data["subject"] = title + if description is not None: + event_data["body"] = { + "contentType": "HTML", + "content": description + } + if location: + event_data["location"] = {"displayName": location} + if start_at: + event_data["start"] = { + "dateTime": start_at.isoformat(), + "timeZone": "UTC" + } + if end_at: + event_data["end"] = { + "dateTime": end_at.isoformat(), + "timeZone": "UTC" + } + + if not event_data: + return True # Nothing to update + + endpoint = f"/me/events/{event_id}" + self._make_graph_request("PATCH", endpoint, event_data) + return True + + async def delete_event(self, event_id: str) -> bool: + """ + Delete an event from Microsoft Calendar + + Args: + event_id: Microsoft Calendar Event ID + + Returns: + bool: True if successful + + Raises: + HTTPException: If deletion fails + """ + endpoint = f"/me/events/{event_id}" + self._make_graph_request("DELETE", endpoint) + return True + + async def get_event(self, event_id: str) -> Dict[Any, Any]: + """ + Get event details from Microsoft Calendar + + Args: + event_id: Microsoft Calendar Event ID + + Returns: + Dict: Event details + + Raises: + HTTPException: If retrieval fails + """ + endpoint = f"/me/events/{event_id}" + return self._make_graph_request("GET", endpoint) + + async def sync_event( + self, + loaf_event, + existing_ms_event_id: Optional[str] = None + ) -> str: + """ + Sync a LOAF event to Microsoft Calendar + Creates new event if existing_ms_event_id is None, otherwise updates + + Args: + loaf_event: SQLAlchemy Event model instance + existing_ms_event_id: Existing Microsoft Calendar Event ID (optional) + + Returns: + str: Microsoft Calendar Event ID + + Raises: + HTTPException: If sync fails + """ + if existing_ms_event_id: + # Update existing event + await self.update_event( + event_id=existing_ms_event_id, + title=loaf_event.title, + description=loaf_event.description, + location=loaf_event.location, + start_at=loaf_event.start_at, + end_at=loaf_event.end_at + ) + return existing_ms_event_id + else: + # Create new event + return await self.create_event( + title=loaf_event.title, + description=loaf_event.description or "", + location=loaf_event.location, + start_at=loaf_event.start_at, + end_at=loaf_event.end_at + ) + + +# Singleton instance +_ms_calendar = None + + +def get_ms_calendar_service() -> MSCalendarService: + """ + Get singleton instance of MSCalendarService + + Returns: + MSCalendarService: Initialized Microsoft Calendar service + """ + global _ms_calendar + if _ms_calendar is None: + _ms_calendar = MSCalendarService() + return _ms_calendar diff --git a/r2_storage.py b/r2_storage.py new file mode 100644 index 0000000..5d8928b --- /dev/null +++ b/r2_storage.py @@ -0,0 +1,243 @@ +""" +Cloudflare R2 Storage Service +Handles file uploads, downloads, and deletions using S3-compatible API +""" + +import boto3 +from botocore.client import Config +from botocore.exceptions import ClientError +import os +import uuid +import magic +from typing import Optional, BinaryIO +from fastapi import UploadFile, HTTPException +from pathlib import Path + + +class R2Storage: + """ + Cloudflare R2 Storage Service using S3-compatible API + """ + + # Allowed MIME types for uploads + ALLOWED_IMAGE_TYPES = { + 'image/jpeg': ['.jpg', '.jpeg'], + 'image/png': ['.png'], + 'image/webp': ['.webp'], + 'image/gif': ['.gif'] + } + + ALLOWED_DOCUMENT_TYPES = { + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'] + } + + def __init__(self): + """Initialize R2 client with credentials from environment""" + self.account_id = os.getenv('R2_ACCOUNT_ID') + self.access_key = os.getenv('R2_ACCESS_KEY_ID') + self.secret_key = os.getenv('R2_SECRET_ACCESS_KEY') + self.bucket_name = os.getenv('R2_BUCKET_NAME') + self.public_url = os.getenv('R2_PUBLIC_URL') + + if not all([self.account_id, self.access_key, self.secret_key, self.bucket_name]): + raise ValueError("R2 credentials not properly configured in environment variables") + + # Initialize S3 client for R2 + self.client = boto3.client( + 's3', + endpoint_url=f'https://{self.account_id}.r2.cloudflarestorage.com', + aws_access_key_id=self.access_key, + aws_secret_access_key=self.secret_key, + config=Config(signature_version='s3v4'), + ) + + async def upload_file( + self, + file: UploadFile, + folder: str, + allowed_types: Optional[dict] = None, + max_size_bytes: Optional[int] = None + ) -> tuple[str, str, int]: + """ + Upload a file to R2 storage + + Args: + file: FastAPI UploadFile object + folder: Folder path in R2 (e.g., 'profiles', 'gallery/event-id') + allowed_types: Dict of allowed MIME types and extensions + max_size_bytes: Maximum file size in bytes + + Returns: + tuple: (public_url, object_key, file_size_bytes) + + Raises: + HTTPException: If upload fails or file is invalid + """ + try: + # Read file content + content = await file.read() + file_size = len(content) + + # Check file size + if max_size_bytes and file_size > max_size_bytes: + max_mb = max_size_bytes / (1024 * 1024) + actual_mb = file_size / (1024 * 1024) + raise HTTPException( + status_code=413, + detail=f"File too large: {actual_mb:.2f}MB exceeds limit of {max_mb:.2f}MB" + ) + + # Detect MIME type + mime = magic.from_buffer(content, mime=True) + + # Validate MIME type + if allowed_types and mime not in allowed_types: + allowed_list = ', '.join(allowed_types.keys()) + raise HTTPException( + status_code=400, + detail=f"Invalid file type: {mime}. Allowed types: {allowed_list}" + ) + + # Generate unique filename + file_extension = Path(file.filename).suffix.lower() + if not file_extension and allowed_types and mime in allowed_types: + file_extension = allowed_types[mime][0] + + unique_filename = f"{uuid.uuid4()}{file_extension}" + object_key = f"{folder}/{unique_filename}" + + # Upload to R2 + self.client.put_object( + Bucket=self.bucket_name, + Key=object_key, + Body=content, + ContentType=mime, + ContentLength=file_size + ) + + # Generate public URL + public_url = self.get_public_url(object_key) + + return public_url, object_key, file_size + + except ClientError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to upload file to R2: {str(e)}" + ) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"Upload error: {str(e)}" + ) + + async def delete_file(self, object_key: str) -> bool: + """ + Delete a file from R2 storage + + Args: + object_key: The S3 object key (path) of the file + + Returns: + bool: True if successful + + Raises: + HTTPException: If deletion fails + """ + try: + self.client.delete_object( + Bucket=self.bucket_name, + Key=object_key + ) + return True + + except ClientError as e: + raise HTTPException( + status_code=500, + detail=f"Failed to delete file from R2: {str(e)}" + ) + + def get_public_url(self, object_key: str) -> str: + """ + Generate public URL for an R2 object + + Args: + object_key: The S3 object key (path) of the file + + Returns: + str: Public URL + """ + if self.public_url: + # Use custom domain if configured + return f"{self.public_url}/{object_key}" + else: + # Use default R2 public URL + return f"https://{self.bucket_name}.{self.account_id}.r2.cloudflarestorage.com/{object_key}" + + async def get_file_size(self, object_key: str) -> int: + """ + Get the size of a file in R2 + + Args: + object_key: The S3 object key (path) of the file + + Returns: + int: File size in bytes + + Raises: + HTTPException: If file not found + """ + try: + response = self.client.head_object( + Bucket=self.bucket_name, + Key=object_key + ) + return response['ContentLength'] + + except ClientError as e: + if e.response['Error']['Code'] == '404': + raise HTTPException(status_code=404, detail="File not found") + raise HTTPException( + status_code=500, + detail=f"Failed to get file info: {str(e)}" + ) + + async def file_exists(self, object_key: str) -> bool: + """ + Check if a file exists in R2 + + Args: + object_key: The S3 object key (path) of the file + + Returns: + bool: True if file exists, False otherwise + """ + try: + self.client.head_object( + Bucket=self.bucket_name, + Key=object_key + ) + return True + except ClientError: + return False + + +# Singleton instance +_r2_storage = None + + +def get_r2_storage() -> R2Storage: + """ + Get singleton instance of R2Storage + + Returns: + R2Storage: Initialized R2 storage service + """ + global _r2_storage + if _r2_storage is None: + _r2_storage = R2Storage() + return _r2_storage diff --git a/requirements.txt b/requirements.txt index 64b874f..49de466 100644 --- a/requirements.txt +++ b/requirements.txt @@ -9,7 +9,7 @@ certifi==2025.11.12 cffi==2.0.0 charset-normalizer==3.4.4 click==8.3.1 -cryptography==46.0.3 +cryptography==44.0.0 dnspython==2.8.0 ecdsa==0.19.1 email-validator==2.3.0 @@ -17,6 +17,7 @@ fastapi==0.110.1 flake8==7.3.0 greenlet==3.2.4 h11==0.16.0 +icalendar==6.0.1 idna==3.11 iniconfig==2.3.0 isort==7.0.0 @@ -26,6 +27,7 @@ markdown-it-py==4.0.0 mccabe==0.7.0 mdurl==0.1.2 motor==3.3.1 +msal==1.27.0 mypy==1.18.2 mypy_extensions==1.1.0 numpy==2.3.5 @@ -34,6 +36,7 @@ packaging==25.0 pandas==2.3.3 passlib==1.7.4 pathspec==0.12.1 +pillow==10.2.0 platformdirs==4.5.0 pluggy==1.6.0 psycopg2-binary==2.9.11 @@ -50,6 +53,7 @@ pytest==9.0.1 python-dateutil==2.9.0.post0 python-dotenv==1.2.1 python-jose==3.5.0 +python-magic==0.4.27 python-multipart==0.0.20 pytokens==0.3.0 pytz==2025.2 diff --git a/server.py b/server.py index d6e45a2..710300b 100644 --- a/server.py +++ b/server.py @@ -1,5 +1,6 @@ -from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request +from fastapi import FastAPI, APIRouter, Depends, HTTPException, status, Request, UploadFile, File, Form from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import StreamingResponse from sqlalchemy.orm import Session from sqlalchemy import or_ from pydantic import BaseModel, EmailStr, Field, validator @@ -14,7 +15,7 @@ import uuid import secrets from database import engine, get_db, Base -from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus +from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument from auth import ( get_password_hash, verify_password, @@ -33,6 +34,8 @@ from email_service import ( send_admin_password_reset_email ) from payment_service import create_checkout_session, verify_webhook_signature, get_subscription_end_date +from r2_storage import get_r2_storage +from calendar_service import CalendarService # Load environment variables ROOT_DIR = Path(__file__).parent @@ -59,6 +62,9 @@ app = FastAPI( # Create a router with the /api prefix api_router = APIRouter(prefix="/api") +# Initialize calendar service +calendar_service = CalendarService() + # Configure logging logging.basicConfig( level=logging.INFO, @@ -163,6 +169,10 @@ class UserResponse(BaseModel): role: str email_verified: bool created_at: datetime + # Subscription info (optional) + subscription_start_date: Optional[datetime] = None + subscription_end_date: Optional[datetime] = None + subscription_status: Optional[str] = None model_config = {"from_attributes": True} @@ -175,6 +185,36 @@ class UpdateProfileRequest(BaseModel): state: Optional[str] = None zipcode: Optional[str] = None +class EnhancedProfileUpdateRequest(BaseModel): + """Members Only - Enhanced profile update with social media and directory settings""" + social_media_facebook: Optional[str] = None + social_media_instagram: Optional[str] = None + social_media_twitter: Optional[str] = None + social_media_linkedin: Optional[str] = None + show_in_directory: Optional[bool] = None + directory_email: Optional[str] = None + directory_bio: Optional[str] = None + directory_address: Optional[str] = None + directory_phone: Optional[str] = None + directory_dob: Optional[datetime] = None + directory_partner_name: Optional[str] = None + +class CalendarEventResponse(BaseModel): + """Calendar view response with user RSVP status""" + id: str + title: str + description: Optional[str] + start_at: datetime + end_at: datetime + location: str + capacity: Optional[int] + user_rsvp_status: Optional[str] = None + microsoft_calendar_synced: bool + +class SyncEventRequest(BaseModel): + """Request to sync event to Microsoft Calendar""" + event_id: str + class EventCreate(BaseModel): title: str description: Optional[str] = None @@ -308,11 +348,22 @@ async def register(request: RegisterRequest, db: Session = Depends(get_db)): @api_router.get("/auth/verify-email") async def verify_email(token: str, db: Session = Depends(get_db)): + """Verify user email with token (idempotent - safe to call multiple times)""" user = db.query(User).filter(User.email_verification_token == token).first() - + if not user: raise HTTPException(status_code=400, detail="Invalid verification token") - + + # If user is already verified, return success (idempotent behavior) + # This handles React Strict Mode's double-execution in development + if user.email_verified: + logger.info(f"Email already verified for user: {user.email}") + return { + "message": "Email is already verified", + "status": user.status.value + } + + # Proceed with first-time verification # Check if referred by current member - skip validation requirement if user.referred_by_member_name: referrer = db.query(User).filter( @@ -329,13 +380,13 @@ async def verify_email(token: str, db: Session = Depends(get_db)): user.status = UserStatus.pending_approval else: user.status = UserStatus.pending_approval - + user.email_verified = True user.email_verification_token = None - + db.commit() db.refresh(user) - + logger.info(f"Email verified for user: {user.email}") return {"message": "Email verified successfully", "status": user.status.value} @@ -439,7 +490,13 @@ async def change_password( return {"message": "Password changed successfully"} @api_router.get("/auth/me", response_model=UserResponse) -async def get_me(current_user: User = Depends(get_current_user)): +async def get_me(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + # Get user's active subscription if exists + active_subscription = db.query(Subscription).filter( + Subscription.user_id == current_user.id, + Subscription.status == SubscriptionStatus.active + ).first() + return UserResponse( id=str(current_user.id), email=current_user.email, @@ -454,7 +511,10 @@ async def get_me(current_user: User = Depends(get_current_user)): status=current_user.status.value, role=current_user.role.value, email_verified=current_user.email_verified, - created_at=current_user.created_at + created_at=current_user.created_at, + subscription_start_date=active_subscription.start_date if active_subscription else None, + subscription_end_date=active_subscription.end_date if active_subscription else None, + subscription_status=active_subscription.status.value if active_subscription else None ) # User Profile Routes @@ -497,14 +557,565 @@ async def update_profile( current_user.state = request.state if request.zipcode: current_user.zipcode = request.zipcode - + current_user.updated_at = datetime.now(timezone.utc) - + db.commit() db.refresh(current_user) - + return {"message": "Profile updated successfully"} +# ==================== MEMBERS ONLY ROUTES ==================== + +# Enhanced Profile Routes (Active Members Only) +@api_router.get("/members/profile") +async def get_enhanced_profile( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get enhanced profile with all member-only fields""" + return { + "id": str(current_user.id), + "email": current_user.email, + "first_name": current_user.first_name, + "last_name": current_user.last_name, + "phone": current_user.phone, + "address": current_user.address, + "city": current_user.city, + "state": current_user.state, + "zipcode": current_user.zipcode, + "date_of_birth": current_user.date_of_birth, + "profile_photo_url": current_user.profile_photo_url, + "social_media_facebook": current_user.social_media_facebook, + "social_media_instagram": current_user.social_media_instagram, + "social_media_twitter": current_user.social_media_twitter, + "social_media_linkedin": current_user.social_media_linkedin, + "show_in_directory": current_user.show_in_directory, + "directory_email": current_user.directory_email, + "directory_bio": current_user.directory_bio, + "directory_address": current_user.directory_address, + "directory_phone": current_user.directory_phone, + "directory_dob": current_user.directory_dob, + "directory_partner_name": current_user.directory_partner_name, + "status": current_user.status.value, + "role": current_user.role.value + } + +@api_router.put("/members/profile") +async def update_enhanced_profile( + request: EnhancedProfileUpdateRequest, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Update enhanced profile with social media and directory settings""" + if request.social_media_facebook is not None: + current_user.social_media_facebook = request.social_media_facebook + if request.social_media_instagram is not None: + current_user.social_media_instagram = request.social_media_instagram + if request.social_media_twitter is not None: + current_user.social_media_twitter = request.social_media_twitter + if request.social_media_linkedin is not None: + current_user.social_media_linkedin = request.social_media_linkedin + if request.show_in_directory is not None: + current_user.show_in_directory = request.show_in_directory + if request.directory_email is not None: + current_user.directory_email = request.directory_email + if request.directory_bio is not None: + current_user.directory_bio = request.directory_bio + if request.directory_address is not None: + current_user.directory_address = request.directory_address + if request.directory_phone is not None: + current_user.directory_phone = request.directory_phone + if request.directory_dob is not None: + current_user.directory_dob = request.directory_dob + if request.directory_partner_name is not None: + current_user.directory_partner_name = request.directory_partner_name + + current_user.updated_at = datetime.now(timezone.utc) + db.commit() + db.refresh(current_user) + + return {"message": "Enhanced profile updated successfully"} + +@api_router.post("/members/profile/upload-photo") +async def upload_profile_photo( + file: UploadFile = File(...), + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Upload profile photo to Cloudflare R2""" + r2 = get_r2_storage() + + # Get storage quota + storage = db.query(StorageUsage).first() + if not storage: + # Initialize storage tracking + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + # Get max file size from env + max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) + + # Delete old profile photo if exists + if current_user.profile_photo_url: + # Extract object key from URL + old_key = current_user.profile_photo_url.split('/')[-1] + old_key = f"profiles/{old_key}" + try: + old_size = await r2.get_file_size(old_key) + await r2.delete_file(old_key) + # Update storage usage + storage.total_bytes_used -= old_size + except: + pass # File might not exist + + # Upload new photo + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="profiles", + allowed_types=r2.ALLOWED_IMAGE_TYPES, + max_size_bytes=max_file_size + ) + + # Check storage quota + if storage.total_bytes_used + file_size > storage.max_bytes_allowed: + # Rollback upload + await r2.delete_file(object_key) + raise HTTPException( + status_code=507, + detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" + ) + + # Update user profile + current_user.profile_photo_url = public_url + current_user.updated_at = datetime.now(timezone.utc) + + # Update storage usage + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + + db.commit() + db.refresh(current_user) + + logger.info(f"Profile photo uploaded for user {current_user.email}: {file_size} bytes") + + return { + "message": "Profile photo uploaded successfully", + "profile_photo_url": public_url + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error uploading profile photo: {str(e)}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@api_router.delete("/members/profile/delete-photo") +async def delete_profile_photo( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Delete profile photo from R2 and profile""" + if not current_user.profile_photo_url: + raise HTTPException(status_code=404, detail="No profile photo to delete") + + r2 = get_r2_storage() + storage = db.query(StorageUsage).first() + + # Extract object key from URL + object_key = current_user.profile_photo_url.split('/')[-1] + object_key = f"profiles/{object_key}" + + try: + file_size = await r2.get_file_size(object_key) + await r2.delete_file(object_key) + + # Update storage usage + if storage: + storage.total_bytes_used -= file_size + storage.last_updated = datetime.now(timezone.utc) + + # Update user profile + current_user.profile_photo_url = None + current_user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Profile photo deleted for user {current_user.email}") + + return {"message": "Profile photo deleted successfully"} + except Exception as e: + logger.error(f"Error deleting profile photo: {str(e)}") + raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") + +# Calendar Routes (Active Members Only) +@api_router.get("/members/calendar/events", response_model=List[CalendarEventResponse]) +async def get_calendar_events( + start_date: Optional[datetime] = None, + end_date: Optional[datetime] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get calendar events with user RSVP status""" + query = db.query(Event).filter(Event.published == True) + + if start_date: + query = query.filter(Event.start_at >= start_date) + if end_date: + query = query.filter(Event.end_at <= end_date) + + events = query.order_by(Event.start_at).all() + + result = [] + for event in events: + # Get user's RSVP status for this event + rsvp = db.query(EventRSVP).filter( + EventRSVP.event_id == event.id, + EventRSVP.user_id == current_user.id + ).first() + + user_rsvp_status = rsvp.rsvp_status.value if rsvp else None + + result.append(CalendarEventResponse( + id=str(event.id), + title=event.title, + description=event.description, + start_at=event.start_at, + end_at=event.end_at, + location=event.location, + capacity=event.capacity, + user_rsvp_status=user_rsvp_status, + microsoft_calendar_synced=event.microsoft_calendar_sync_enabled + )) + + return result + +# Members Directory Route +@api_router.get("/members/directory") +async def get_members_directory( + search: Optional[str] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get members directory - only shows active members who opted in""" + query = db.query(User).filter( + User.show_in_directory == True, + User.status == UserStatus.active + ) + + if search: + search_term = f"%{search}%" + query = query.filter( + or_( + User.first_name.ilike(search_term), + User.last_name.ilike(search_term), + User.directory_bio.ilike(search_term) + ) + ) + + members = query.order_by(User.first_name, User.last_name).all() + + return [ + { + "id": str(member.id), + "first_name": member.first_name, + "last_name": member.last_name, + "profile_photo_url": member.profile_photo_url, + "directory_email": member.directory_email, + "directory_bio": member.directory_bio, + "directory_address": member.directory_address, + "directory_phone": member.directory_phone, + "directory_partner_name": member.directory_partner_name, + "social_media_facebook": member.social_media_facebook, + "social_media_instagram": member.social_media_instagram, + "social_media_twitter": member.social_media_twitter, + "social_media_linkedin": member.social_media_linkedin + } + for member in members + ] + +# Admin Calendar Sync Routes +@api_router.post("/admin/calendar/sync/{event_id}") +async def sync_event_to_microsoft( + event_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Sync event to Microsoft Calendar""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + ms_calendar = get_ms_calendar_service() + + try: + # Sync event + ms_event_id = await ms_calendar.sync_event( + loaf_event=event, + existing_ms_event_id=event.microsoft_calendar_id + ) + + # Update event with MS Calendar ID + event.microsoft_calendar_id = ms_event_id + event.microsoft_calendar_sync_enabled = True + event.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Event {event.title} synced to Microsoft Calendar by {current_user.email}") + + return { + "message": "Event synced to Microsoft Calendar successfully", + "microsoft_calendar_id": ms_event_id + } + except Exception as e: + logger.error(f"Error syncing event to Microsoft Calendar: {str(e)}") + raise HTTPException(status_code=500, detail=f"Sync failed: {str(e)}") + +@api_router.delete("/admin/calendar/unsync/{event_id}") +async def unsync_event_from_microsoft( + event_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Remove event from Microsoft Calendar""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + if not event.microsoft_calendar_id: + raise HTTPException(status_code=400, detail="Event is not synced to Microsoft Calendar") + + ms_calendar = get_ms_calendar_service() + + try: + # Delete from Microsoft Calendar + await ms_calendar.delete_event(event.microsoft_calendar_id) + + # Update event + event.microsoft_calendar_id = None + event.microsoft_calendar_sync_enabled = False + event.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Event {event.title} unsynced from Microsoft Calendar by {current_user.email}") + + return {"message": "Event removed from Microsoft Calendar successfully"} + except Exception as e: + logger.error(f"Error removing event from Microsoft Calendar: {str(e)}") + raise HTTPException(status_code=500, detail=f"Unsync failed: {str(e)}") + +# Event Gallery Routes (Members Only) +@api_router.get("/members/gallery") +async def get_events_with_galleries( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get all events that have gallery images""" + # Get events that have at least one gallery image + events_with_galleries = db.query(Event).join(EventGallery).filter( + Event.published == True + ).distinct().order_by(Event.start_at.desc()).all() + + result = [] + for event in events_with_galleries: + gallery_count = db.query(EventGallery).filter( + EventGallery.event_id == event.id + ).count() + + # Get first image as thumbnail + first_image = db.query(EventGallery).filter( + EventGallery.event_id == event.id + ).order_by(EventGallery.created_at).first() + + result.append({ + "id": str(event.id), + "title": event.title, + "description": event.description, + "start_at": event.start_at, + "location": event.location, + "gallery_count": gallery_count, + "thumbnail_url": first_image.image_url if first_image else None + }) + + return result + +@api_router.get("/events/{event_id}/gallery") +async def get_event_gallery( + event_id: str, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """Get all gallery images for a specific event""" + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + gallery_images = db.query(EventGallery).filter( + EventGallery.event_id == event_id + ).order_by(EventGallery.created_at.desc()).all() + + return [ + { + "id": str(img.id), + "image_url": img.image_url, + "image_key": img.image_key, + "caption": img.caption, + "uploaded_by": str(img.uploaded_by), + "file_size_bytes": img.file_size_bytes, + "created_at": img.created_at + } + for img in gallery_images + ] + +# Admin Event Gallery Routes +@api_router.post("/admin/events/{event_id}/gallery") +async def upload_event_gallery_image( + event_id: str, + file: UploadFile = File(...), + caption: Optional[str] = None, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Upload image to event gallery (Admin only)""" + # Validate event exists + event = db.query(Event).filter(Event.id == event_id).first() + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + r2 = get_r2_storage() + + # Get storage quota + storage = db.query(StorageUsage).first() + if not storage: + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 10737418240)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + # Get max file size from env + max_file_size = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) + + try: + # Upload to R2 + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder=f"gallery/{event_id}", + allowed_types=r2.ALLOWED_IMAGE_TYPES, + max_size_bytes=max_file_size + ) + + # Check storage quota + if storage.total_bytes_used + file_size > storage.max_bytes_allowed: + # Rollback upload + await r2.delete_file(object_key) + raise HTTPException( + status_code=507, + detail=f"Storage limit exceeded. Used: {storage.total_bytes_used / (1024**3):.2f}GB, Limit: {storage.max_bytes_allowed / (1024**3):.2f}GB" + ) + + # Create gallery record + gallery_image = EventGallery( + event_id=event.id, + image_url=public_url, + image_key=object_key, + caption=caption, + uploaded_by=current_user.id, + file_size_bytes=file_size + ) + db.add(gallery_image) + + # Update storage usage + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + + db.commit() + db.refresh(gallery_image) + + logger.info(f"Gallery image uploaded for event {event.title} by {current_user.email}: {file_size} bytes") + + return { + "message": "Image uploaded successfully", + "image": { + "id": str(gallery_image.id), + "image_url": gallery_image.image_url, + "caption": gallery_image.caption, + "file_size_bytes": gallery_image.file_size_bytes + } + } + except HTTPException: + raise + except Exception as e: + logger.error(f"Error uploading gallery image: {str(e)}") + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@api_router.delete("/admin/event-gallery/{image_id}") +async def delete_gallery_image( + image_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Delete image from event gallery (Admin only)""" + gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() + if not gallery_image: + raise HTTPException(status_code=404, detail="Gallery image not found") + + r2 = get_r2_storage() + storage = db.query(StorageUsage).first() + + try: + # Delete from R2 + await r2.delete_file(gallery_image.image_key) + + # Update storage usage + if storage: + storage.total_bytes_used -= gallery_image.file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + + # Delete from database + db.delete(gallery_image) + db.commit() + + logger.info(f"Gallery image deleted by {current_user.email}: {gallery_image.image_key}") + + return {"message": "Image deleted successfully"} + except Exception as e: + logger.error(f"Error deleting gallery image: {str(e)}") + raise HTTPException(status_code=500, detail=f"Deletion failed: {str(e)}") + +@api_router.put("/admin/event-gallery/{image_id}") +async def update_gallery_image_caption( + image_id: str, + caption: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Update gallery image caption (Admin only)""" + gallery_image = db.query(EventGallery).filter(EventGallery.id == image_id).first() + if not gallery_image: + raise HTTPException(status_code=404, detail="Gallery image not found") + + gallery_image.caption = caption + db.commit() + db.refresh(gallery_image) + + return { + "message": "Caption updated successfully", + "image": { + "id": str(gallery_image.id), + "caption": gallery_image.caption + } + } + # Event Routes @api_router.get("/events", response_model=List[EventResponse]) async def get_events( @@ -601,10 +1212,339 @@ async def rsvp_to_event( db.add(rsvp) db.commit() - + return {"message": "RSVP updated successfully"} +# ============================================================================ +# Calendar Export Endpoints (Universal iCalendar .ics format) +# ============================================================================ + +@api_router.get("/events/{event_id}/download.ics") +async def download_event_ics( + event_id: str, + db: Session = Depends(get_db) +): + """ + Download single event as .ics file (RFC 5545 iCalendar format) + No authentication required for published events + Works with Google Calendar, Apple Calendar, Microsoft Outlook, etc. + """ + event = db.query(Event).filter( + Event.id == event_id, + Event.published == True + ).first() + + if not event: + raise HTTPException(status_code=404, detail="Event not found") + + # Generate UID if not exists + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + ics_content = calendar_service.create_single_event_calendar(event) + + # Sanitize filename + safe_filename = "".join(c for c in event.title if c.isalnum() or c in (' ', '-', '_')).rstrip() + safe_filename = safe_filename.replace(' ', '_') or 'event' + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": f"attachment; filename={safe_filename}.ics", + "Cache-Control": "public, max-age=300" # Cache for 5 minutes + } + ) + +@api_router.get("/calendars/subscribe.ics") +async def subscribe_calendar( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Subscribe to user's RSVP'd events (live calendar feed) + Auto-syncs events marked as "Yes" RSVP + Use webcal:// protocol for auto-sync in calendar apps + """ + # Get all upcoming events user RSVP'd "yes" to + rsvps = db.query(EventRSVP).filter( + EventRSVP.user_id == current_user.id, + EventRSVP.rsvp_status == RSVPStatus.yes + ).join(Event).filter( + Event.start_at > datetime.now(timezone.utc), + Event.published == True + ).all() + + events = [rsvp.event for rsvp in rsvps] + + # Generate UIDs for events that don't have them + for event in events: + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + feed_name = f"{current_user.first_name}'s LOAF Events" + ics_content = calendar_service.create_subscription_feed(events, feed_name) + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": "inline; filename=loaf-events.ics", + "Cache-Control": "public, max-age=3600", # Cache for 1 hour + "ETag": f'"{hash(ics_content)}"' + } + ) + +@api_router.get("/calendars/all-events.ics") +async def download_all_events( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Download all upcoming published events as .ics file (one-time download) + Useful for importing all events at once + """ + events = db.query(Event).filter( + Event.published == True, + Event.start_at > datetime.now(timezone.utc) + ).order_by(Event.start_at).all() + + # Generate UIDs + for event in events: + if not event.calendar_uid: + event.calendar_uid = calendar_service.generate_event_uid() + db.commit() + + ics_content = calendar_service.create_subscription_feed(events, "All LOAF Events") + + return StreamingResponse( + iter([ics_content]), + media_type="text/calendar", + headers={ + "Content-Disposition": "attachment; filename=loaf-all-events.ics", + "Cache-Control": "public, max-age=600" # Cache for 10 minutes + } + ) + +# ============================================================================ +# Newsletter Archive Routes (Members Only) +# ============================================================================ +@api_router.get("/newsletters") +async def get_newsletters( + year: Optional[int] = None, + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all newsletters, optionally filtered by year + Members only + """ + from models import NewsletterArchive + + query = db.query(NewsletterArchive) + + if year: + query = query.filter( + db.func.extract('year', NewsletterArchive.published_date) == year + ) + + newsletters = query.order_by(NewsletterArchive.published_date.desc()).all() + + return [{ + "id": str(n.id), + "title": n.title, + "description": n.description, + "published_date": n.published_date.isoformat(), + "document_url": n.document_url, + "document_type": n.document_type, + "file_size_bytes": n.file_size_bytes, + "created_at": n.created_at.isoformat() + } for n in newsletters] + +@api_router.get("/newsletters/years") +async def get_newsletter_years( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get list of years that have newsletters + Members only + """ + from models import NewsletterArchive + + years = db.query( + db.func.extract('year', NewsletterArchive.published_date).label('year') + ).distinct().order_by(db.text('year DESC')).all() + + return [int(y.year) for y in years] + +# ============================================================================ +# Financial Reports Routes (Members Only) +# ============================================================================ +@api_router.get("/financials") +async def get_financial_reports( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all financial reports sorted by year (newest first) + Members only + """ + from models import FinancialReport + + reports = db.query(FinancialReport).order_by( + FinancialReport.year.desc() + ).all() + + return [{ + "id": str(r.id), + "year": r.year, + "title": r.title, + "document_url": r.document_url, + "document_type": r.document_type, + "file_size_bytes": r.file_size_bytes, + "created_at": r.created_at.isoformat() + } for r in reports] + +# ============================================================================ +# Bylaws Routes (Members Only) +# ============================================================================ +@api_router.get("/bylaws/current") +async def get_current_bylaws( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get current bylaws document + Members only + """ + from models import BylawsDocument + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.is_current == True + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="No current bylaws found") + + return { + "id": str(bylaws.id), + "title": bylaws.title, + "version": bylaws.version, + "effective_date": bylaws.effective_date.isoformat(), + "document_url": bylaws.document_url, + "document_type": bylaws.document_type, + "file_size_bytes": bylaws.file_size_bytes, + "is_current": bylaws.is_current, + "created_at": bylaws.created_at.isoformat() + } + +@api_router.get("/bylaws/history") +async def get_bylaws_history( + current_user: User = Depends(get_active_member), + db: Session = Depends(get_db) +): + """ + Get all bylaws versions (historical) + Members only + """ + from models import BylawsDocument + + history = db.query(BylawsDocument).order_by( + BylawsDocument.effective_date.desc() + ).all() + + return [{ + "id": str(b.id), + "title": b.title, + "version": b.version, + "effective_date": b.effective_date.isoformat(), + "document_url": b.document_url, + "document_type": b.document_type, + "file_size_bytes": b.file_size_bytes, + "is_current": b.is_current, + "created_at": b.created_at.isoformat() + } for b in history] + +# ============================================================================ +# Configuration Endpoints +# ============================================================================ +@api_router.get("/config/limits") +async def get_config_limits(): + """Get configuration limits for file uploads""" + return { + "max_file_size_bytes": int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)), + "max_storage_bytes": int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) + } + +# ============================================================================ # Admin Routes +# ============================================================================ +@api_router.get("/admin/storage/usage") +async def get_storage_usage( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get current storage usage statistics""" + from models import StorageUsage + + storage = db.query(StorageUsage).first() + + if not storage: + # Initialize if doesn't exist + storage = StorageUsage( + total_bytes_used=0, + max_bytes_allowed=int(os.getenv('MAX_STORAGE_BYTES', 1073741824)) + ) + db.add(storage) + db.commit() + db.refresh(storage) + + percentage = (storage.total_bytes_used / storage.max_bytes_allowed) * 100 if storage.max_bytes_allowed > 0 else 0 + + return { + "total_bytes_used": storage.total_bytes_used, + "max_bytes_allowed": storage.max_bytes_allowed, + "percentage": round(percentage, 2), + "available_bytes": storage.max_bytes_allowed - storage.total_bytes_used + } + +@api_router.get("/admin/storage/breakdown") +async def get_storage_breakdown( + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """Get storage usage breakdown by category""" + from sqlalchemy import func + from models import User, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument + + # Count storage by category + profile_photos = db.query(func.coalesce(func.sum(User.profile_photo_size), 0)).scalar() or 0 + gallery_images = db.query(func.coalesce(func.sum(EventGallery.file_size_bytes), 0)).scalar() or 0 + newsletters = db.query(func.coalesce(func.sum(NewsletterArchive.file_size_bytes), 0)).filter( + NewsletterArchive.document_type == 'upload' + ).scalar() or 0 + financials = db.query(func.coalesce(func.sum(FinancialReport.file_size_bytes), 0)).filter( + FinancialReport.document_type == 'upload' + ).scalar() or 0 + bylaws = db.query(func.coalesce(func.sum(BylawsDocument.file_size_bytes), 0)).filter( + BylawsDocument.document_type == 'upload' + ).scalar() or 0 + + return { + "breakdown": { + "profile_photos": profile_photos, + "gallery_images": gallery_images, + "newsletters": newsletters, + "financials": financials, + "bylaws": bylaws + }, + "total": profile_photos + gallery_images + newsletters + financials + bylaws + } + + @api_router.get("/admin/users") async def get_all_users( status: Optional[str] = None, @@ -1308,6 +2248,462 @@ async def delete_plan( return {"message": "Plan deactivated successfully"} +# ============================================================================ +# Admin Document Management Routes +# ============================================================================ + +# Newsletter Archive Admin Routes +@api_router.post("/admin/newsletters") +async def create_newsletter( + title: str = Form(...), + description: str = Form(None), + published_date: str = Form(...), + document_type: str = Form("google_docs"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Create newsletter record + Admin only - supports both URL links and file uploads + """ + from models import NewsletterArchive, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="newsletters", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + newsletter = NewsletterArchive( + title=title, + description=description, + published_date=datetime.fromisoformat(published_date.replace('Z', '+00:00')), + document_url=final_url, + document_type=document_type, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(newsletter) + db.commit() + db.refresh(newsletter) + + return { + "id": str(newsletter.id), + "message": "Newsletter created successfully" + } + +@api_router.put("/admin/newsletters/{newsletter_id}") +async def update_newsletter( + newsletter_id: str, + title: str = Form(...), + description: str = Form(None), + published_date: str = Form(...), + document_type: str = Form("google_docs"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Update newsletter record + Admin only - supports both URL links and file uploads + """ + from models import NewsletterArchive, StorageUsage + from r2_storage import get_r2_storage + + newsletter = db.query(NewsletterArchive).filter( + NewsletterArchive.id == newsletter_id + ).first() + + if not newsletter: + raise HTTPException(status_code=404, detail="Newsletter not found") + + final_url = document_url + file_size = newsletter.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="newsletters", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and newsletter.file_size_bytes: + storage.total_bytes_used -= newsletter.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + newsletter.title = title + newsletter.description = description + newsletter.published_date = datetime.fromisoformat(published_date.replace('Z', '+00:00')) + newsletter.document_url = final_url + newsletter.document_type = document_type + newsletter.file_size_bytes = file_size + newsletter.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Newsletter updated successfully"} + +@api_router.delete("/admin/newsletters/{newsletter_id}") +async def delete_newsletter( + newsletter_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Delete newsletter record + Admin only + """ + from models import NewsletterArchive + + newsletter = db.query(NewsletterArchive).filter( + NewsletterArchive.id == newsletter_id + ).first() + + if not newsletter: + raise HTTPException(status_code=404, detail="Newsletter not found") + + db.delete(newsletter) + db.commit() + + return {"message": "Newsletter deleted successfully"} + +# Financial Reports Admin Routes +@api_router.post("/admin/financials") +async def create_financial_report( + year: int = Form(...), + title: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Create financial report record + Admin only - supports both URL links and file uploads + """ + from models import FinancialReport, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="financials", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + report = FinancialReport( + year=year, + title=title, + document_url=final_url, + document_type=document_type, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(report) + db.commit() + db.refresh(report) + + return { + "id": str(report.id), + "message": "Financial report created successfully" + } + +@api_router.put("/admin/financials/{report_id}") +async def update_financial_report( + report_id: str, + year: int = Form(...), + title: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Update financial report record + Admin only - supports both URL links and file uploads + """ + from models import FinancialReport, StorageUsage + from r2_storage import get_r2_storage + + report = db.query(FinancialReport).filter( + FinancialReport.id == report_id + ).first() + + if not report: + raise HTTPException(status_code=404, detail="Financial report not found") + + final_url = document_url + file_size = report.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="financials", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and report.file_size_bytes: + storage.total_bytes_used -= report.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + report.year = year + report.title = title + report.document_url = final_url + report.document_type = document_type + report.file_size_bytes = file_size + report.updated_at = datetime.now(timezone.utc) + + db.commit() + + return {"message": "Financial report updated successfully"} + +@api_router.delete("/admin/financials/{report_id}") +async def delete_financial_report( + report_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Delete financial report record + Admin only + """ + from models import FinancialReport + + report = db.query(FinancialReport).filter( + FinancialReport.id == report_id + ).first() + + if not report: + raise HTTPException(status_code=404, detail="Financial report not found") + + db.delete(report) + db.commit() + + return {"message": "Financial report deleted successfully"} + +# Bylaws Admin Routes +@api_router.post("/admin/bylaws") +async def create_bylaws( + title: str = Form(...), + version: str = Form(...), + effective_date: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + is_current: bool = Form(True), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Create bylaws document + If is_current=True, sets all others to is_current=False + Admin only - supports both URL links and file uploads + """ + from models import BylawsDocument, StorageUsage + from r2_storage import get_r2_storage + + final_url = document_url + file_size = None + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="bylaws", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + file_size = file_size_bytes + + # Update storage usage + storage = db.query(StorageUsage).first() + if storage: + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + if is_current: + # Set all other bylaws to not current + db.query(BylawsDocument).update({"is_current": False}) + + bylaws = BylawsDocument( + title=title, + version=version, + effective_date=datetime.fromisoformat(effective_date.replace('Z', '+00:00')), + document_url=final_url, + document_type=document_type, + is_current=is_current, + file_size_bytes=file_size, + created_by=current_user.id + ) + + db.add(bylaws) + db.commit() + db.refresh(bylaws) + + return { + "id": str(bylaws.id), + "message": "Bylaws created successfully" + } + +@api_router.put("/admin/bylaws/{bylaws_id}") +async def update_bylaws( + bylaws_id: str, + title: str = Form(...), + version: str = Form(...), + effective_date: str = Form(...), + document_type: str = Form("google_drive"), + document_url: str = Form(None), + is_current: bool = Form(False), + file: Optional[UploadFile] = File(None), + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Update bylaws document + If is_current=True, sets all others to is_current=False + Admin only - supports both URL links and file uploads + """ + from models import BylawsDocument, StorageUsage + from r2_storage import get_r2_storage + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.id == bylaws_id + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="Bylaws not found") + + final_url = document_url + file_size = bylaws.file_size_bytes + + # If file uploaded, upload to R2 + if file and document_type == 'upload': + r2 = get_r2_storage() + public_url, object_key, file_size_bytes = await r2.upload_file( + file=file, + folder="bylaws", + allowed_types=r2.ALLOWED_DOCUMENT_TYPES, + max_size_bytes=int(os.getenv('MAX_FILE_SIZE_BYTES', 5242880)) + ) + final_url = public_url + + # Update storage usage (subtract old, add new) + storage = db.query(StorageUsage).first() + if storage and bylaws.file_size_bytes: + storage.total_bytes_used -= bylaws.file_size_bytes + if storage: + storage.total_bytes_used += file_size_bytes + storage.last_updated = datetime.now(timezone.utc) + db.commit() + + file_size = file_size_bytes + + if is_current: + # Set all other bylaws to not current + db.query(BylawsDocument).filter( + BylawsDocument.id != bylaws_id + ).update({"is_current": False}) + + bylaws.title = title + bylaws.version = version + bylaws.effective_date = datetime.fromisoformat(effective_date.replace('Z', '+00:00')) + bylaws.document_url = final_url + bylaws.document_type = document_type + bylaws.is_current = is_current + bylaws.file_size_bytes = file_size + + db.commit() + + return {"message": "Bylaws updated successfully"} + +@api_router.delete("/admin/bylaws/{bylaws_id}") +async def delete_bylaws( + bylaws_id: str, + current_user: User = Depends(get_current_admin_user), + db: Session = Depends(get_db) +): + """ + Delete bylaws document + Admin only + """ + from models import BylawsDocument + + bylaws = db.query(BylawsDocument).filter( + BylawsDocument.id == bylaws_id + ).first() + + if not bylaws: + raise HTTPException(status_code=404, detail="Bylaws not found") + + db.delete(bylaws) + db.commit() + + return {"message": "Bylaws deleted successfully"} + @api_router.post("/subscriptions/checkout") async def create_checkout( request: CheckoutRequest,