From db13f0e9ded312f681610ef36ee632a4f7084358 Mon Sep 17 00:00:00 2001 From: Koncept Kit <63216427+konceptkit@users.noreply.github.com> Date: Thu, 18 Dec 2025 17:04:00 +0700 Subject: [PATCH] - Profile Picture\ Donation Tracking\ Validation Rejection\ Subscription Data Export\ Admin Dashboard Logo\ Admin Navbar Reorganization --- MIGRATIONS.md | 328 +++++++++++ README.md | Bin 0 -> 14934 bytes __pycache__/email_service.cpython-312.pyc | Bin 24024 -> 29338 bytes __pycache__/models.cpython-312.pyc | Bin 25268 -> 27894 bytes __pycache__/server.cpython-312.pyc | Bin 216383 -> 241947 bytes create_admin.py | 218 ++++++-- email_service.py | 114 ++++ migrations/000_initial_schema.sql | 578 ++++++++++++++++++++ migrations/009_create_donations.sql | 44 ++ migrations/010_add_rejection_fields.sql | 40 ++ models.py | 50 ++ seed_permissions_rbac.py | 15 +- server.py | 631 ++++++++++++++++++++-- 13 files changed, 1915 insertions(+), 103 deletions(-) create mode 100644 MIGRATIONS.md create mode 100644 migrations/000_initial_schema.sql create mode 100644 migrations/009_create_donations.sql create mode 100644 migrations/010_add_rejection_fields.sql diff --git a/MIGRATIONS.md b/MIGRATIONS.md new file mode 100644 index 0000000..63b4707 --- /dev/null +++ b/MIGRATIONS.md @@ -0,0 +1,328 @@ +# Database Migrations Guide + +This document explains how to set up the database for the LOAF membership platform on a fresh server. + +--- + +## Quick Start (Fresh Database Setup) + +For a **brand new deployment** on a fresh PostgreSQL database: + +```bash +# 1. Create PostgreSQL database +psql -U postgres +CREATE DATABASE membership_db; +CREATE USER membership_user WITH PASSWORD 'your_password'; +GRANT ALL PRIVILEGES ON DATABASE membership_db TO membership_user; +\q + +# 2. Run initial schema migration +psql -U postgres -d membership_db -f migrations/000_initial_schema.sql + +# 3. Seed permissions and RBAC roles +python seed_permissions_rbac.py + +# 4. Create superadmin user +python create_admin.py +``` + +**That's it!** The database is now fully configured and ready for use. + +--- + +## Migration Files Overview + +### Core Migration (Run This First) + +**`000_initial_schema.sql`** ✅ **START HERE** +- Creates all 10 ENUM types (userstatus, userrole, rsvpstatus, etc.) +- Creates all 17 base tables: + - Core: users, events, event_rsvps, event_galleries + - Financial: subscription_plans, subscriptions, donations + - Documents: newsletter_archives, financial_reports, bylaws_documents + - RBAC: permissions, roles, role_permissions + - System: storage_usage, user_invitations, import_jobs +- Creates 30+ performance indexes +- Initializes default storage_usage record +- **Status:** Required for fresh deployment +- **Run Order:** #1 + +--- + +### Incremental Migrations (Historical - Only for Existing Databases) + +These migrations were created to update existing databases incrementally. **If you're starting fresh with 000_initial_schema.sql, you DO NOT need to run these** as their changes are already included in the initial schema. + +| File | Purpose | Included in 000? | Run on Fresh DB? | +|------|---------|------------------|------------------| +| `001_add_member_since_field.sql` | Adds member_since field to users | ✅ Yes | ❌ No | +| `002_rename_approval_to_validation.sql` | Renames approval-related fields | ✅ Yes | ❌ No | +| `003_add_tos_acceptance.sql` | Adds TOS acceptance tracking | ✅ Yes | ❌ No | +| `004_add_reminder_tracking_fields.sql` | Adds reminder sent flags | ✅ Yes | ❌ No | +| `005_add_rbac_and_invitations.sql` | Adds RBAC permissions & invitations | ✅ Yes | ❌ No | +| `006_add_dynamic_roles.sql` | Adds dynamic roles table | ✅ Yes | ❌ No | +| `009_create_donations.sql` | Creates donations table | ✅ Yes | ❌ No | +| `010_add_rejection_fields.sql` | Adds rejection tracking to users | ✅ Yes | ❌ No | + +**Note:** These files are kept for reference and for updating existing production databases that were created before 000_initial_schema.sql existed. + +--- + +### Ad-Hoc Fix Migrations (Legacy - Do Not Run) + +These were one-time fixes for specific issues during development: + +- `add_calendar_uid.sql` - Added calendar UID field (now in 000) +- `complete_fix.sql` - Added various profile fields (now in 000) +- `fix_storage_usage.sql` - Fixed storage_usage initialization (now in 000) +- `sprint_1_2_3_migration.sql` - Combined early sprint migrations (obsolete) +- `verify_columns.sql` - Debugging script (not a migration) + +**Status:** Do NOT run these on any database. They are archived for historical reference only. + +--- + +## Python Migration Scripts (Data Migrations) + +These scripts migrate **data**, not schema. Run these AFTER the SQL migrations if you have existing data to migrate: + +| Script | Purpose | When to Run | +|--------|---------|-------------| +| `migrate_add_manual_payment.py` | Migrates manual payment data | Only if you have existing subscriptions with manual payments | +| `migrate_billing_enhancements.py` | Migrates billing cycle data | Only if you have existing subscription plans | +| `migrate_multistep_registration.py` | Migrates old registration format | Only if upgrading from Phase 0 | +| `migrate_password_reset.py` | Migrates password reset tokens | Only if you have password reset data | +| `migrate_role_permissions_to_dynamic_roles.py` | Migrates RBAC permissions | Run after seeding permissions (if upgrading) | +| `migrate_status.py` | Migrates user status enum values | Only if upgrading from old status values | +| `migrate_users_to_dynamic_roles.py` | Assigns users to dynamic roles | Run after seeding roles (if upgrading) | + +**For Fresh Deployment:** You do NOT need to run any of these Python migration scripts. They are only for migrating data from older versions of the platform. + +--- + +## Complete Deployment Workflow + +### Scenario 1: Fresh Server (Brand New Database) + +```bash +# Step 1: Create database +psql -U postgres << EOF +CREATE DATABASE membership_db; +CREATE USER membership_user WITH PASSWORD 'secure_password_here'; +GRANT ALL PRIVILEGES ON DATABASE membership_db TO membership_user; +EOF + +# Step 2: Run initial schema +psql postgresql://membership_user:secure_password_here@localhost/membership_db \ + -f migrations/000_initial_schema.sql + +# Expected output: +# Step 1/8 completed: ENUM types created +# Step 2/8 completed: Core tables created +# ... +# ✅ Migration 000 completed successfully! + +# Step 3: Seed permissions (59 permissions across 10 modules) +python seed_permissions_rbac.py + +# Expected output: +# ✅ Seeded 59 permissions +# ✅ Created 5 system roles +# ✅ Assigned permissions to roles + +# Step 4: Create superadmin user (interactive) +python create_admin.py + +# Follow prompts to create your first superadmin account + +# Step 5: Verify database +psql postgresql://membership_user:secure_password_here@localhost/membership_db -c " +SELECT + (SELECT COUNT(*) FROM users) as users, + (SELECT COUNT(*) FROM permissions) as permissions, + (SELECT COUNT(*) FROM roles) as roles, + (SELECT COUNT(*) FROM subscription_plans) as plans; +" + +# Expected output (fresh database): +# users | permissions | roles | plans +# ------+-------------+-------+------- +# 1 | 59 | 5 | 0 +``` + +### Scenario 2: Upgrading Existing Database + +If you already have a database with data and need to upgrade: + +```bash +# Check what migrations have been applied +psql -d membership_db -c "SELECT * FROM users LIMIT 1;" # Check if tables exist + +# Run missing migrations in order +# Example: If you're on migration 006, run 009 and 010 +psql -d membership_db -f migrations/009_create_donations.sql +psql -d membership_db -f migrations/010_add_rejection_fields.sql + +# Run data migrations if needed +python migrate_users_to_dynamic_roles.py # If upgrading RBAC +python migrate_billing_enhancements.py # If upgrading subscriptions + +# Update permissions +python seed_permissions_rbac.py +``` + +--- + +## Verification & Troubleshooting + +### Verify Database Schema + +```bash +# Check all tables exist (should show 17 tables) +psql -d membership_db -c "\dt" + +# Expected tables: +# users, events, event_rsvps, event_galleries +# subscription_plans, subscriptions, donations +# newsletter_archives, financial_reports, bylaws_documents +# permissions, roles, role_permissions +# storage_usage, user_invitations, import_jobs + +# Check ENUM types (should show 8 types) +psql -d membership_db -c "SELECT typname FROM pg_type WHERE typcategory = 'E';" + +# Expected ENUMs: +# userstatus, userrole, rsvpstatus, subscriptionstatus +# donationtype, donationstatus, invitationstatus, importjobstatus + +# Check indexes (should show 30+ indexes) +psql -d membership_db -c "SELECT indexname FROM pg_indexes WHERE schemaname = 'public';" +``` + +### Common Issues + +**Issue 1: "relation already exists"** +- **Cause:** Migration already run +- **Solution:** Safe to ignore. 000_initial_schema.sql uses `IF NOT EXISTS` checks. + +**Issue 2: "type already exists"** +- **Cause:** ENUM type already created +- **Solution:** Safe to ignore. The migration checks for existing types. + +**Issue 3: "permission denied"** +- **Cause:** Database user lacks privileges +- **Solution:** + ```bash + psql -U postgres -d membership_db + GRANT ALL PRIVILEGES ON ALL TABLES IN SCHEMA public TO membership_user; + GRANT ALL PRIVILEGES ON ALL SEQUENCES IN SCHEMA public TO membership_user; + ``` + +**Issue 4: "could not connect to database"** +- **Cause:** DATABASE_URL incorrect in .env +- **Solution:** Verify connection string format: + ``` + DATABASE_URL=postgresql://username:password@localhost:5432/database_name + ``` + +--- + +## Migration History & Rationale + +### Why 000_initial_schema.sql? + +The `000_initial_schema.sql` file was created to consolidate all incremental migrations (001-010) into a single comprehensive schema for fresh deployments. This approach: + +✅ **Simplifies fresh deployments** - One file instead of 10 +✅ **Reduces errors** - No risk of running migrations out of order +✅ **Faster setup** - Single transaction vs multiple files +✅ **Easier to maintain** - One source of truth for base schema +✅ **Preserves history** - Old migrations kept for existing databases + +### Schema Evolution Timeline + +``` +Phase 0 (Early Development) +├── Basic users table +├── Events and RSVPs +└── Email verification + +Phase 1 (Current - MVP) +├── 000_initial_schema.sql (COMPREHENSIVE) +│ ├── All ENUM types +│ ├── 17 tables +│ ├── 30+ indexes +│ └── Default data +├── seed_permissions_rbac.py (59 permissions, 5 roles) +└── create_admin.py (Interactive superadmin creation) + +Phase 2 (Future - Multi-tenant SaaS) +├── Add tenant_id to all tables +├── Tenant isolation middleware +├── Per-tenant customization +└── Tenant provisioning automation +``` + +--- + +## Database Backup & Restore + +### Backup + +```bash +# Full database backup +pg_dump -U postgres membership_db > backup_$(date +%Y%m%d).sql + +# Compressed backup +pg_dump -U postgres membership_db | gzip > backup_$(date +%Y%m%d).sql.gz + +# Schema only (no data) +pg_dump -U postgres --schema-only membership_db > schema_backup.sql + +# Data only (no schema) +pg_dump -U postgres --data-only membership_db > data_backup.sql +``` + +### Restore + +```bash +# From uncompressed backup +psql -U postgres -d membership_db < backup_20250118.sql + +# From compressed backup +gunzip -c backup_20250118.sql.gz | psql -U postgres -d membership_db +``` + +--- + +## Production Deployment Checklist + +Before deploying to production: + +- [ ] PostgreSQL 13+ installed +- [ ] Database created with secure credentials +- [ ] `000_initial_schema.sql` executed successfully +- [ ] `seed_permissions_rbac.py` completed (59 permissions created) +- [ ] Superadmin user created via `create_admin.py` +- [ ] DATABASE_URL configured in backend `.env` +- [ ] Backend server connects successfully (`uvicorn server:app`) +- [ ] Test API endpoints: GET /api/auth/me (should work after login) +- [ ] Database backup configured (daily cron job) +- [ ] SSL/TLS enabled for PostgreSQL connections +- [ ] Firewall rules restrict database access +- [ ] Connection pooling configured (if high traffic) + +--- + +## Additional Resources + +- **Backend README:** See `README.md` for complete backend setup guide +- **API Documentation:** http://localhost:8000/docs (Swagger UI) +- **PostgreSQL Docs:** https://www.postgresql.org/docs/13/ +- **SQLAlchemy Docs:** https://docs.sqlalchemy.org/en/20/ + +--- + +**Last Updated:** December 18, 2024 +**Version:** 1.0.0 +**Maintainer:** LOAF Development Team diff --git a/README.md b/README.md index e69de29bb2d1d6434b8b29ae775ad8c2e48c5391..e5066ea40a9aa12aa1445d32d24816cae14eabc9 100644 GIT binary patch literal 14934 zcmbVT+jiSXcAb?+Jm)bFwXB|Li55uR+}&n}vzn49nPXiTlCr0l$BTjph(enL&;Tfj zGoJiH-t&~t$V*oKB|k8~kiAb;;X=vXlekygWZ_hy>eRXJbGV_-F1sh{JXqWWX?7PT zYB2Zm+c;e)R~>rOPeJ6X?x62DCtjB0gL~s;fv;}Nqbx}8!)c%}L*;jYUTyR2^l+>$ z)0r2AfA;b)j+BRGPSOyoWm{@dzHQ+}-Yi%IQLYm6;-=$lY^bq!GY?dJtB&F*$K%X# zT=iiT z=dL@&V>h#p^#X&wI*zAHZqUBWOG4MLjtS*)kj&$i9OQHv`Tbk;%=T6H98)}dSY4Gzh%y2s4OrtSa<#%z^XtdRS^R>ct+=a8d zAO#!nEY9*-8jSwsjL&!X*B&3u33DBnA)SbmchL*VmBEJ7-!*Cx`TeJxoXgFAvna zFwK|VT=k;+FpVQ#SUGjWQsJ3U1r@497_8idy2r5mg~h?EJiO=1&&}CTmyud{)5}ro zER2?qZ6}MDDfER)v~I$v<(cVR3bgOT$d4bgwnKtHjiz91p8csHDh{;Yv^t<5dEikH zO+$);MeqYIQ>MTB{V-yBNFYsYVMGxJ38&-CV8)<$bO!C^XXl%Samvk&7J1K0f{b$ z=VC8>FZXCoNSS1-X`IaVT=2w8h5U7MSJo_rS9!jd+-_fww9rx66cicVfj(QdIKJjn9ugq9N zGSp_A&}w`0Eyql}8ufOF?=J2;~Ar=JUBv7v^`NS%j}CvFr+ZZ!Vq-~Z!p z|M_pKe?GVzj`3!wi5RVEyh!E&lz(B#Rn0YQLrel#$Pedz?0DS2ycjhX{>RN`t2VF& zt;1Vr22a4`yYe14FM zaF_7fS)ibF@IPBmu2#8zl}Y}Ma&OCXWv!i^orzg}qE~N{b7GRlcVVU|wUEh@6>uFe zj4);}40!wQj_O@povVD6U_YC@W>}egnQbe6PUCs7tn^dp>7!h^ZGN}v;9tz1(7RzYaNb}+$T!L;CVJY|(eTx%2{ zl7hZ@NitulED5IJZ8(Ke%Q9Ma7*v%U&EWK5>A7$9Ychp6s?oRz9&>~|L6WKNElg)A z*K3jlmpbBhz??-00|Yjz%~!8$-@R!XXBqEt5&KImMA|7r%Kb@Y>Lf*g*i7J+H&p3+ zvn5D8e}TN3(Z8xxI*FbUNx&uoA&B9oGrDII*xJg*G>4%YaOHD zHRPTRRs9f`$xxwP)u3bRgsl0RTn*1U*4+I(Z?{_ZOWWF?ALjAYo8Q4CwqG6W@3qQ! z76y+;rsJva4b*bw7H8Y$(`PL(JVj+>%s;2h){6@4rg`GPA`Wr zhUc9(qrF$(INhV8-e@!#U%u^KOnQGD^oPC4dH>>S+#7ZFclh=ZgR36Hr?pM+!Q{>5 zXxyP|YtBg0g!ypf0Zm)y)py_OZ#2pm-SeK_t^{R2tWPl3S(8ABUCSW7#L4jTe9}Ab z_Rl&|oWh2!z&P)=8K<)ffxP5PNeGW~f-nskfUj^d+M&vB)I5wn!K~rI(dBTo<=}M} z|>+?*DpxE_vIP8YLA>;+T z?fqELrhBA;-{w`4Mqf48G^Bb>(3MwCy2GoZx4rR1ctGB`r6}?7;Og+Ke^eYO!-+(C zu+GgAxmn&!_nLliFSjl+g|dOfpbR8N<6(c$vwOn6JK3iR>{LDxud0KQiQd?R2QU-l z?V~r{!?PYw+ex08l^~f3vpc$!>eTnW!#9_gZ>`8b++lV7wuj&bsm~|#I@!2{i)*4P z!uTEUOyree5B_s_(-Ww}Y5!u>dG?OCpBvrfdoE4f(T1Um@^qy(>2r{_!UBDQQQvY# zy^Hbi$I0>NLSGkB641GR7HKQgX!I9JoaP=cXnE=4mSt2nyD1}6EB#jwbgZuL;^9g3@Z07cvSm)*RqVx@R$Pd z^1wjaExlrnqyQ@ziep63H?fCU`fvaJU+}5-!5pimnS3?VO}P4v@i=`GvCtA%E%Q@% zv{+`jx`9(heCCNaD?B6PnO}EB#z{Q!LuE)>cS0p$%@ci#o#zP0?fE3@AL*>85}aw8 zhcLfW-ZGEf6uj_!$D~arO={!Hlxv>h1HFne;*PHrHzj&N2~OcR8Gg{MQA0HA>GJ*` ztA525tYGWitA~(LBBbvgu0aIT%e9arn{SKk)vT)Q%vGZYZ#F}4arGsd7NTMb%)~j`8>{=bwJ_8^dPYpIZO3q0T+T2nN`q z#;KPQCKI^;C<6sooBw(MBSc@rkeTZt^aFRt}l3YzHvmgL>&&^RvM+?h5 zoFj0@8xfzbr=xtW_&iumf!k^;0fKm%mf|KMc5lY#XDSbnqcAR&jo1LRwuEqIql{{& zIu}F;0@N&HK8jBzRHyl>UFS1fT6eMn80kzEa_|s|7Vu2#phS5=K)AFv14App4Sk)@ zN19|LL;YI7%CMCBqEiEXeKt|cTIK83&tI4og&Q@qGv^;b#x3mZ>^_$Wm5*OfU>?Eo zNs)#~rbL2&KBSc+l98B+`cplFL9{OBSsfLY&bWos}7#{{2{Y9#4MH-{K`!6-A zNRRu6OTZDIB^(zV2w7O9Mf5w z^Q@TS`O^nl+Oc8_f@)?WCXU!%PU`QR@^5-1w?f?_HEq8DdZb;zTC7Ot8U(qh1o>s1m>E1 z1U$?=yGnm6EweWJ!_m7z5!PY@hPNsYb3!aYgDg}6ej%0R)dOu{NF&?FHOs$KFY%^f zz5@Gnm=88k0+C`?;Fe-DK)8{RF|*HzYE&T^7Iy@N9W&U@zyvMjmg6^wJE)e^)l_z9 z;hW0lGs$lz)m0}f3lP()k50j%3I;Th?`Gr2X60hjqX5i@tuFW_&@}cH`JR**3LB2S zjEP*e4N6vmOzIvdB8R5sSY_wlg2fcQjS`xg5C#*j3j&7M#Hm(Jp}`jlwAr?JHlywg z>xaO~7=9nGK!n#)ViBweXtE7`hzM?B@d9wsWtTw#?nvy8;9qAW#ju6tn)hMwV9~Xv z_GTrBrr%#*?A(i1E`nUh;tI;3jWY|1U__%qP=(46hkC$w9Yf z0@P@gA&eoQMQCMAOur2JjD`CNdg%NGhUW!j3nY_dL;2ELNg6}A>%gHu<{}d zpVTJh1y36dAb6+tnoGJ2@bwcgk1*{uxsx>;EHKFJl%|DGv3IDJe!Oma_`Ir|hHO-| z6!p~#&C&}Ns%Z@}S4>LL)KsDgBIkf&E%?`H^z=1j(?D&FKmnb>SL%V@7B zh~G$O2y*uZ1RUE0fP4YYs|mGZ)5hNcM%zu`BjNW6RuG03ui&80IJCY_Z%sQqhs7pq zOLK%(p9pX`z-TK0%pyT;mB_!iQgIFHhKK{AfO=}8mlhOoidYp2!tzBRtpM;gI@JIp zb+f@{Eudn-+{OY*{JSlSsm+D=s9x=yAJY73ft|+GziUwj47;AJ*AfU!%or@X>gb=|O@?HmVi~)Q#1<@VIY_kAM3kbY! z6&>fCU;)H2iD5@Fn@?5ZU8Dq@6Q{Nh;||4$AZ?4()Sk4}Vc?;wK>hPi5BWr*<=>aG z)h~g1_Cvr(OWnUn`)SRe0B-~bY~l}>!PNiDbSC*{aKZStXATAH6)zL*6p-lFy~5@- z`PA|-dM$O;0kCZ)-vK7fU?76Duw;6`SPWQ}3N4ePh=gbSpl_OfM!0~JUZ3{tvZ6IB zR|J|fp3$Wm{URXMk+vkF;WiXCp8Z^UVG8**N78;M)2EWRn~|0YWVz~T0LP)@T5fW1 zg-t#~rmIplN@3F)X-jA5u4oXKzmnRrou`sex@P#Mg0TwUB$D7Fw=wEZ?83 zphHMeN5mWY4z*{goYxFsA7{A7G5|Fdzv5OjUF$EQ|5v#W36K3C2YyBUHrHICYO%sd zQh25X%D)Vj&0Tb*G<8J}VE0vV6_w3ZN9_)^_1+XSfwTmo2f)Z)OJ#y=6>>{!E5e}- zFKYRCZ@J?qGjxK%jcbEkv&MLjmQkKSd$txL2!Mye6#2xM{Al#9cGjLa^!i~9=VkMi zMa{51F3ZV!XnoHmp@CavV2u@;sJJ#GqrXDg@^Th}7~Aq#=U(~=h`1dx2df4gYu8u{ zPo7+7{i!`?ZIp+ou%z~^#at#6!NLdDi3RceGX0nCrx02#0`-?r&Rd8Aroo)AI+9Pd zRLiizI_EVO6fxJ6=RW$=UWuIyQob_BMO$*xejX8=WmeszOf zdWlmscMjCqc=Qru1}jG2qkKb9q?;WH2jcPG>ETN??4I-E4nL1Z$3{yK_x2+wE{vHD z?AP1qvZc@djNCM3sNVq(FNe?~gpN{ofIjRFFvAatF_Wc-*m)t;N4+YtWlme8PZo6V(Db+ixMc1yh z^0GPd`3G1~x0^oK##o%8-u?K+UdF$x`A~o)q!G4JBB)~9MMDSNqzR6d>FuD0Wv&Xn zX9csYaH`F34qif^J?(ibl2stYKr(DIE9ItKTY4roZL=vNY2XDAIbP4 zeLZ7|&ie0q?3tGCSImAh9uGz=)fH_FM*5H~5yzFTEF=6+uD9|sdkTHg?y`^{-NN<@ zZFpSd`!BWI@u8A-yWYjo@W;WpcU)<_gAD|i|G%wwvUa`sPVWWc#xA>}(2_Z5$tv1* z%HdXnRgv$3t0Cc)X2eoySOhOR$tPU*nGT(G5;nEH_=>pk3kN=c02>6q-uU#ZrQ}jc zS;Q7>%Eu~_ofeXN-9rCbE7MQSzcpoSBH&~5{IzaKLT{b(;R?m@kIr$4&~_v#J~Zw2 zkSa&|xH?ggvk$ik&=zPyAqqdw?W&H0Zh2Y%qeI*hJvhkgR}RD54(x(UadITRD!;zR zlE7h)ZL3u0d1KKl+oC%!2Ho+S=jmFsckD<+*nMk7!7W7vT>}?l<%bA!Q^X5{G=9{5>tg*KiUS-TAxwpeGqTqR zrT_2ct9~YsFx*jz4WNxxs11sb;)#CnJ zcHXqJ5DPIQ_3oFV-31-2zF*)pXtc3CJk^^ND8ojUHuS{OJxS5pMVt!#7C4cJw)7sO z1`DQbB^(BoHnebLj5P2YolPGZ_zJ%si6gY;%qISFkyP7@X|7S( zoc$Sb(C?*3bD2!O!Yq_xUjFvS-!6XZZ_)HOXMZlP0VY=C?dpg*0ab*||DZnFV4%R! zrJ0Htjy|!W(w5dH}vG};i*(5RSd}1%&nM1Ro3M3 zX8i1kSHM-+xrVR;?i4r*+zpa(vOqU_!o2~!Wk_w#Qpqy#QxR4mO%$k&kX>|W+Q~G} zv!{F4X@UjR6$2b}v~3w%hH4%P{R~Fbxc(y~2~f&AMTo}V1`r0-LA}*GM4`Ni$}a{? z5mie~X*^*WS&%NBH)ZA>Plj5y`QZTj99exs66KVH@bWa+rE8~EY7^RM6#g!RVIJaW~F zvDknvex-AC)-SaHW*SXcC~M4_)x+eWrpm;ZW)cfP}eey>I8K6nik6DwCG5dx1@V5nL0>y6C9M3wM0%v zK&!5g&bn8}J^b9{T8!cKV0g?g=CqB*8IELrvfy*laWK_b^zL`tNR;;ufMY;F*cHUb cyV8K$Xs~Av=qef-I4aR;rN#k9dkg1(0iqUDU;qFB literal 0 HcmV?d00001 diff --git a/__pycache__/email_service.cpython-312.pyc b/__pycache__/email_service.cpython-312.pyc index 4a6965b66201d12a550f3a04bfbca1c4fa421ff4..a4fb7d542259947174f7d5e53e6e32961a9bc8c0 100644 GIT binary patch delta 2333 zcmb_eT}&KR6rS1L+44^*|FpE11yZ2=ErkLLYzr+ADgkPN1zLl%J9n2Uvoph;nJ%m# zv^DX?huZs6t+n*U)cBx=$G%iw`p}A0O>8hGeKFC*#EtEP(U^GdEPpYvPfjxT?wq;z zoO{mszHj#Feg6HQ_^d~nnQ0tcZNK-(*Q9r|iun1@kDe21rB>{bj=|lA3&L)x{Vmtj zuFZ!%VCw)|-2z|Txwa^z`S_3N+b0M4v9<3`K1_3ugG&;ich64pQ)_>n)iTpBFv~?Y zyhg2wi+qHBGx2NTrhucOtZZh8QO#1#=@3>;BjluCl^;GM@k>t0vFMPT@pO)pT(?}e z9ZN1rSmbYUl6%nwhi{x($Jo(KDi3LfkK+1 z5g#gVZfa^e(bfT{*mZhBpWiaXupF5&eJH3YQU{8PBqFOm?TVsqyXmxSJfE&q3v730a<|7Xx?CJb%TO*|_6juXgqwDaAF%!JW}5Ur zQk_=wlKOe=`fV4hk$TIDAed{yZn9zNx<*VigB9IC(CiQtUREuPSh7*sFl@MJC}_!w zghT+5VG^uH%o)sJgwbGs&*-^hurC_5RM}jpr+1I%LoEdQb#>0xxpO0aJK9doWCNMv ze4T_PD`d(+1tU|lz2SKi2!KRI(PCIa!3CsP5J{Ei3B4FRx1lALPQgXEoFB#JF9F)Ss)0@65TnyouTbAh!q)!k*wMaVj7uc z-o5n2Kw)k`HfLG>XM&XG3x| zo%812m~hdCz@0oK1~toM#3VaprRxD}nT!JOs@-G;CNjVVnGN+YY`T+>Ar0_gxM4^# zjMTE1r3Mgxdb{w`Hu0yy|Fm5}qQaVoARh)(r=Ph$R=8(bEi3&`v-e*=wCa44Q@(n7 zV}I2K&a7VPOStljiW48 z_XfYsY5zXGeZ$qhk>36%8Dgh#6X>I+hPDI>OdKb`eGTsCL$svzpyRecyIMiB7r~b>00opw)l4}ohmtau6fX}eDSfk|D0NJipx0swQ>I=AN(OtWBzWkjeW~-|sWE0mJphlpGI>B`@BI@*U-xa!CQRq${k-lCv zlx_=*SPa$Q(U@Vo^RjAUf(?^_?~*2~GC+#uSdY5H@CJnd-3-tKUzTyK7R8cyw^VV9 zNusV3?J`CS^nTUBOc7wtIAh9D%rKl(soe~170Kz;{af($ziKy$#*bI*)BU39U^+D3 zFzFu%+n5g7gdaBHn0kjED!IV9}ikOr`$+2tk6c{WRSP_{*m zIfhQi-sp!PTjXe3=})-wfCp7OJSYMl6d$=hpWt$kkZ3t(^I+;fcwl6Jud9bD`49PW z*Bzmfudd&8&@U%H$`8Q{vJZw$Cjfw?ut$k5O${F$V@6j{(-bnrtPwUa=8`%3<5Wrd zRrtyN0vK-Rn&ZMFN6+)rexC12j5<1a-#sJ2!SujsPaEgQpR>orBJbsWtDyu3(`T>* OrU$2Au!&p&SN{NaS?u}% delta 198 zcmbRBl<~%HM!wU$yj%=GP^jpTc`0Bbp9EvTM)g2emP$rV^UX_GxvUs9C$G2nVGP;) z$No4MTPRRzQP|{@UXhHEo0YxudAXxNBGDisX7b#K7{;{8zari+Y3fXN%v&Lv1QKTl z61SK$^Gb?1K-}cX|ML9JLO_BnN%{FXMUf!UG$65(;WLnADAEBEw>WHa^HWN5QtgV$ cfm}u)EqNqXn5Y$x^5(Q;hcn?|K4|3n4f~LF7 z*p6*8P3-+6W-@K)bdq4Di3XF}G)=XW&(S7Yw~g*UP5#M0Ni#aqW+pT3^ql+H51vFM zQ-(cz?|tWEiM#?#ye2x;&+# zRl#SRQt+8)UbWI=+<&=j`V(b&R`&TMoTE^5uV5@yz_JQhipp8H8oD(?oT-A1u7=TC zV9Kjx-8Il%o4~YkRBf-LUn?(iwe)jME)A(w^p8myhNK8d8It8l>_}E1DMzvtNimWVB&AW31r0F|$SK9`G-IgwwmvJ= zBDFFKLhZK=IpeL;Je)EBAKIzTq@QW5v_oTkLbm)$cuuO2tU@ANv=YaVL(o&Ulr#ctb*e;i!ixO})S&c*{zXr#UAQVygY(IvaQ0wg_dE;%8 z{2G+cnE>9b;AoOv6-jII`&FFrQaah#GQPAlVX%u0 zmhq+h@`UM+kJ{}kXq$ewsRdLx$C0i0rDR{Eh5lB*k87bC!(%!eC&)u~Om`UcTyU&% zkKhvhKHD(yA9eErZ#y<5_-wA>VQ?AdNVa1x(H4z$5RKkGR@UkE3Sz+J9ez0fx7ra= zAmU0lKen`y_qu(+?vF{c1q?$%;#%~{`hI#P-LiGA*(2HpkIUQ7yEcvKyFlK`X!nPp z%Y9TRq`0*FcNG^a-ah`PlfOS1uGs;JXZ+w`-EQ1!2a>rGNhgfSg#8HFx{$EBm5Z87 zcV^hM#V{FBrz06H+(G(w#(Mn|iT*vEl-bN_Fs4Y^w8Pk)RK7$47UI=Om>nXzDAZ0@ zAfeMgN?*x#6w7w85k(=vOeNk=!Z@X&T?OaEJHAM$q#rZc&D| z)7=#|`cq4t2KPudVsKH~lSzl0JE&vb@@Y?=iYsx#vxr7GD)<6oC)tdgjX+w+Hf%bO zw4qtnJses?GfIdV`Ik5^ZkNSPesS*Pjzz7^ChtQDY}fm-#UP)=mJ3NgeY&s+Z2f9s zq1}oDHYAv~iGT!5q96t&gGde`8KVCx9N+R1HeW`Pj-(ICIUo_kA(zJ^kWr`G>pCQe za?f=lBa)xd%hoR?dpOY^(|@)JTzx-^&@mf0iFkB4(GcIJNVb7+OteWmN@Rwyd^bGw zv!yw@*N`)AiiOxOEQ-C8vCH6QyG2+exDkDaJvT9^@YzG4ex@W}H-WNbA_AnQQWyBno+?;Jfmb5%X?)<9tMupUa0#E zN_aFZk^SREu?vh{h9wUoBC27J%g4ZU*lmxFBd(5zUPR?{c?F`y{+o3Bif<}qVEzW5 z$Z))dV@Ted_Lu893CL^AXJ4r3P;NxU2u@5$8Z2E%UOJW~j`WKI#666KjT1M3CyIo% z5)(?(cl#|!9n0FAFh7=8zQR={17M2g1%Gzu{;)77~I4;G8-}*A-OgEPR&8Ch-F0V z^Sl|BaG!7@xIq$lvDAGRT%F3y*M zrP%6BUK`_5PHAFNj3`AgK`Ez+6;N5y80E^0!MRUz=HWKaGnUK7EOVs7RgCGI52GnD zT~eXU)5TUUkn;o)7d{P`^f9JFV6yTCs0EEs3#P}mVuSG_ehHru@@Ie5sN)dgGk9Cc6rb49)HyLsQ z=bGj`8Gy14E;IzQpu=9gU~qyXBm`#)3QTi({Ug4Ba{$XG!h&=Sj1zV-`vzOgFTn*@wff zQ7BWK8f8wa*`b0z`uYvNX@~gdUj;PG`W??-86Xa!7PK<$2>SPDXMc zK4P^N%n5f%la)?1_ z&R7|02Zp7~CpDK%;i649RE2k}%O(ad1j5#a8>;*}S$St$COgAfH6aJ^>5b3UPLzbx z%0kT`;dw1cGgsZL?+Ird2(>4StUnOWILIWYnPDB>mAWhT@MF3ip(YTLVz^-_ny9~} zt-PbvV{`K@?P}I6p4fLwyOK3aCOj~DH$|PT8Fxsrad*0?b`Mv12?0%)iZ`hWbPI_A zORfS#v~@yhqQj?OM$ogO#us zKwLySGVJlYcnCGw0fKuYNU}L|h__D5#E0$MOxa7hM+Auhz=>7vfk%e3zj>$mP zu^B*0;Um5R92i`72(D84r8SKXH$0ilPHG)TH|;jRzHu8@A_qDx3N&#a6&?}kuQnCv z8gcDMD`1g*%c9tWjD23Da~9{SEXS&_JWl{U6O}O{kzpRqb#)Ki&1A@D;bbIr@DX2_ zMZOG>w@`cIQxfV~4E5>v8auf>8SEqn1;qV&frBw;%c#e7OleoxB)tJXH#xv+#Tb0RT&-ys0fl)N}Hyn?H6$L^JQk*+k8Gd zwtJ#Pxwvo0V+%#-i!Bzem%i0vsYWMu*bR?vda7=1%$4a_f;uAEN1d$>Nyie&MAuu3 zxq_IIEu;yx+y(^R=d@tEbs=4!ZZj|O;sSANw9!j#wj&pD`s+X(c9Ts1NU{_28=RT~ zG9@9AzbwEpcFwB?{E$}vihU_(Kr5p0`uP!fTOj!@-Q1oh-*h?ZgoLMJdq~=JxwPrb z?b|u7hxYEMkJ~3<>!cm`-0<-0jDC;bBgEYCgd6}6u+U4mIRZ|`1>oWfM8(|2*<4x5 za3EalB8ACuERDTBB%m_XK!n|`s&K`ajo<|*mW~d)g_$at@IHr~EDPI-urp$=nTQ%~ zWpIm5aa0TlUMFfGudCQijYS#@vQ4OYaV=`shfSF;_uv>KRNVEhbei_C)AaG(BOF&n zuXj{e;OvAubqlFPaob_)m;rts@VZ4T2iawM!B{-ECqMW!PEYV})PW()6memfxf=s~ zJupXD9nJ8LuSZS-V?-Yx09!yE-wdPkoTCY_2XTS<;)4~Agl0cAGWZPB?|1yU}oNdFy8P!|`3+VSVM9`c zgq7>;5X;Bz)_G*J0~ELJP!34!U}q@2DH@ zE@0$Xh+aZX;x>$#YM$3Y3DDynWXNwG!a;U;xS_GwRQT_%b?0A%! zv;$d?T%}z-Rw*HNNC}bl+(xi1c>v4mo~ZY;hnT(2dS7bgcBlF?$tV*;mSq(XPHhZjip+R~Td_szgcbmCzb& zv5O}22q$*?kz7NrfklT|?M*k|V|3w)LbSj;fHGjlTKnPQ2C$N`VhHb|$} zCNcy%!oNa1PF=3$wp7mYtm(PjGr2PgXwI0`ocVO%nb9AQ&M2TcW1#0;wpB_l``Oy( z>d(~AD4;o`O@e;sbNkQipHV<_CiNi4dAZZIGw?eGG-iH4@3_{b6>*x=ff)sS&9u-Z J{SzvL=zq@( zFifqJOP|x-$eGa#LGd?wP_I)A$~t+y;MgigY)RL+#Yn$gCQC}qgmlB6>>r^14YK1# zdD6-t^*QB=w7x^sS1DJ>RVi)b=um8$Wne7vO8ohH6+Ycb!hKzK1yTzFU4`N4Lw)9AYe8#dPe>gqV;-6W%B_l;rO?+3$ z=o)^@deEU+SqoMG*eL!-X5x*uku}nvxa2D5f7`Gvo1&ye$ETIeys3AH2lv#(-*Xrw z*N_lT7?pEMFd7+VTVZ9JfF9N%@XMS@K~bd9C$rrkX8ZD+Gk3#GlF=7nEX7P_$}cWf z9?WQFQtpEV+V?@Uh@h9yIt)0%?^aX@w(}|-E=~F!#W*SsGXq8e#{f_Ap~~q!m(aWn zC;$utzAqqQp78qp3X6Gs0q=wo(Z&v#W`J+>OZ*$Pzfh?@67O)`mmHc1PZ(p0 zm-%B0T=v(UU5=b*>K&$=Fupo2A^uiI;tGjtxEv8C5{9tf8zeH_F6Zib#Owyr4|)R% z%fa_o_^S>q30ioOpZmy znJ*mmg@PU|p6W;^)buDO%kezhVk`UuBvqc#S~1Rrj_HpnomEV6g2XjsBU0G_fOd~! z;da$zMz{&;Kj-hZb`(+wdp18&9bj~$=qe}rnrNJpF0 za7_#)3e=jR79XFIUN=}=2rN{Ll?dtRw4L|_i%8=fUn!GcM~e!ln#t?`2SVQDKkGc) zLSaXh6X~1CnGE|wURhzHzZe=$q@ffM&d_Fe2L)7s0?CCq%XS;n9tr1x_9pSE51{l{S+-tGSi`? z38s6H1Kii~fu7e?Lhn&9IePa=8?~h7Es)B5=af~lx_18tlUWk;td3QP7i1(pMdC}c zTAEo%<_abf22}qvfGyjp!33Z+_tD*RcFK1OW!_JaAPd`Ew%q;{(G|nTBNc z1;kSkJN58$TnL^o-4pYBzaH5e8lMcL-?W@54q;DC(F_h-C{dJ2*ZFpLWl@R-uq3$P zXWiC@RG_6UKAPQs1#y6d&${Px_AB1uvht_w-?zHyhUa#fSMPjYHP~gI~LEZCsmbpwp9sW%i#zUgi-o|&emY~xZ2+5P;;;P0tKR#ThrN@Nz z0-E{7;b*1F@ANw2O@|LiQa^wCNL$LUX%w+eaMQjQQ$748g*_)(*{5Lz23C5CEi$k_ zWndMK$$wB+Q&+BF@kl22Jpt$Q=)K6ql0D)J(0S{I%?<}a$QzUf~P;RzjpvnScOC1IM7zXU- z7mwan!@Zx1RCIJwk_!0UW6kx@PP^OnuzZ;75?bfYso11T9p$jRyst|^cPhS+}t6-k)X!O2@W%X2j3axUi zZK3q*7!|+*op=>eK(Im#5A%20t=fd4xBv|DkB)z+5?sJvm)rS2PZZ@QvcpPH_5~++ zfydtNpqT(pKoyK`1laMl8m$Vn=qX!8E%a3iIKwAA<*M~c{{Emf{u58V-kDah$Ysg0 z#X6zA;??M>G5gdsrbu~3C13O{@%C5+!4%rZ#pH>sXy~jGWE#e?7j@$ytk9zq#|}G* zrk6i5?$ffn3F3fo{POs^`D^heLGQ?uiQMr}AnaG7ikvW>^7{PZGdt{MQJ>ep#4mWQ zu(TEM7+@Qq0`sQGZb2X51%6DKC%c8neo6V=g9kdDY@IMcJg;y#6z>m&LoE74Xe^lz zS25rJjd|`rJz^uaX&vXVNimTfkck%je5tt;)kMQ*t7sBnd;DL0#Nu2p>+g64=Z}x;C@ecQo1tH&4G?# zHRY)SP%2S?klSe+ZPcuQa?(-ahduQi>tKCiwc?*Ha)6&ZTkFh~>=!JTtrx9xI?#S4!j3@=9=UeA=)R>EMvwu4u^1p1r1pfuDy@JaC diff --git a/__pycache__/server.cpython-312.pyc b/__pycache__/server.cpython-312.pyc index 095a9386e873626b66b8f75f09510067d9e7d11e..72d31b1610e5afaeacefba20a776e6b0d3cc3ed5 100644 GIT binary patch delta 59033 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

GYSR2e}wef#Y?tYAJ8D$uerw-^z zEyVxR`@^Vp%giH#J*jX>)iF_Rh>p9-8mT9C0GH(|&1Z+E~baKH7-y zw;1t{s^OOVma??64AzpMu;ui0A}w>zgLQ7J--y7w60`&ueoFk3!9OJigUQvyt!Dbo z;1vkwP}g$}>P_Gxx9Wsz1eMXKNTm}vn@UVnU zJO}SvXkl$zStZS3<60wv=Fec4{e{J+j9k1_5?-LVsANv7IcNqAZ&bhx8eXsRugr#& zIdUV5g?rdIyNj^+=iSXFan?*O)fXQn$I}KuqE_j;@0VeCSW(6o@aSMfj*<3$Sd-I>!UNU zrn31p1B;ec2kOeiQ}46b1gl^0tuas&j5o0C9*g;5m4VHqJX(-NT{#6UF*N4K(wHAD zdYUm4%32*=HwQNslC)8fd0rs6fPxuOD^322Xv<&`Y#G>^v;CqH&#&^W8E`(rvg7>u zL45JBwqt{aH8wBymuQWuTOw^UJan^O+A~OSBs!8>ZN5E&YGU09&}Oy>mKOAd81`+{2Lc7y4=ON4Y`isy$e{R2y9*yNtdN?hrt`Y z0{(to^_p6Ez|ge@9^8c*X;YvIYa|y8m4eU2PuhA8TKo8&r4RlXmPMD#{r_Uc<5uLC z_84dfgj_t@N#2gc96~^w7&RCow+~l9yoqqb2sVb`{V3W+f?IZQ%G(W>J{;0xTh&&qd#(=$uxIxX z@2W4+`|aW~yx%Tvz*!0Y4aVREq-VJ2a&q$p;ThvkjAw)=jc1Lx{khmM7(1?AZ3&8g zSpPM;w2GrkA-WTCc+Cz^?p&YwjxlrcWpl=Hx*T$);4zskR3GCiOTdxSNvqeAe{!`a zzQ|)PMix5o!<7>S)l09-=G>Da$F$2O%GP|e`K!x@FV;#x_W z{h(;*!1^dJBd}cFgB(o&4`3GDg3xJ%B5NE8Cr&AFVWiZX$oYXn_PYyIsi-zeActS< zLSPnZK~;emc@BN@vNKb8IZ}T@*#gP+?d=`wn;JVCI~K29*0g+0=bDWxnpVs2BF8?I zydPi-qSzOFv2mV|@0-Z_qMrM+I4g@zJw_#vF5Q%-EJ9PbJz?u%A?e}>@{BNfz#$1G zvcON~DcEig+wyrH=)C|ybdX4#Q2Fj5v5*AcUp++M=02LQ&6d;e9TJTluTq!^&W7G@ z+LlLLwoDZgJP!fZED)17WdPG;@1qZZ&D;QWR%v2#he`OH69+fAlz~q6kmN< zEKvuAhJ!|z+jj5&^Prg6V|xf`CJm1Qt2~ZYXc_*yRC^1r{Vmd+d_>G3(*5iaBHgd) zg)xhJ{1MS4C3QsD{Z}X_#O{Cjx>%yJJCo%)SG~gYxb@&npaPi}FH+z1Mn3M+LpS50Wh&Gk|3QcxNN|64N^F*r)_{$9iFzOS zg~sfr$HhE#%rZ@HOT>v|mfNR|S$>hZ#6Z+%CLxz*-p56&@dK*KouIt;IL54d`}f3o zl8Y~q9EAPZkSn}x=(7_5tU{Cxrej%!9E6aQXgcMhrX1O?;W>`_)E;N~e?B?bNonLd zhFoABfIXepkdKQc(S|vpx>%3tF}%$6bJdYrX-BHHuR&S;gqT0OGhF2t_)mf(pkDbS zYPB!HefkNpKFS3;FChW*Teb27D)OveUJ3k$0p3L)!N3cpa(&#AI5L|@!W_N0*P#;y zyd__wI>I);m>63lP0@B^bcvPL}jK-Ny;; zzyG1wDC$w}$Wxb+-T(e0ab8$Qa1~{nI)yMPLasYBQcRfy80jgf-l|J97BI-1J;VpJ z4^w+YBpfF$PJ|_UDIzO5)B+6Mz}N3Z_H%eBqK$6?5gVWwm?|*i`x}V ztyr7r{^rlc;gI%*+Ab>Xh+Vn*9Nkr~{e`%IR5(U{Aj9o}P^i3wYtm+$$N&uYNQlAE9#n%W1A2`3 z$=b8~UJ~ub1ZvosMEAoliKh*!36w~~E{9ah7Trkm-eC9rloY+b+>C zXQF@cEWj<|*@BzSx-`3hJ8$QiAzhD(6z8^LRv;lc#^qMr_gD1yX1x9xLOQU7K%h5< z(o~KFR2R6EtuzhpBIEYxB5hjim2LFKFG*&xcZiz%d!4f7d2x~ZEst0ga$0?h{#FI= z=t>@Sb4#>do6>1KnM8U{eKG6{6K|u_WDAg{KSo+b*GiUulw+e}f-#vM2v+y^N5wZM zEPvY#>@w9&avU5}#{yTx+$kLBh0G3hiu$9~e!~?~T~DVrrdr)cu84oo zCp0kfRHnMqn5I|y-V!TZ6L@?d-8GOZd?ckPQC)pBp+>BP6S_wmM7JQCN-}L2a`U-t z(0~?n4##TkC5f50#iSW_LYj&Exn*6(Xk$=sb$7olt~XewH0oE1?_F_!ESLO85dH&Y z*>A+z>fqy42kO3cyLbR?L052RGQQkXPNZ>n@u=i-cvNz$wK0YvMm@;S7g0TtzNCYq z&B|xL5!0i%Je^)~{8r4hV8_nmgu%U5_v+t@zn9`$shB_%%)*h_sr)0Q^*s?b|9ajN z3ye5ylmAiq=6m9?RS`RQs@i|(6l(@(Q~&^uQLRlIsiH7vP%vO2zkkk2qqUS?_$RCT z{(l!gGYP*^?!Q4wY`h`m{izG2Y*6$ciB+*bBkV%TG|G~X#3e@6T&nW*kHmRKbwn!O zkH`vySyG}u7Bj7tz-2B#6#%Z2l)8^eEqPQq__4TgE?+496{PT>XfFW7ZUUwrGRs9W zzguZgPR*p4KM~i93-!v5PsHWIkCdlB5qGQB_0Hk_6F`_3G&Eh_4zLBF0Dyaz`;M!1 zA`Lo;>iy9xDBx}sun%c`Ao(iNW&?dz=Ve}q%swP$#g|RWw>}kZE(?_wFshp3Yd|j z8(@*WgYUXz62F$0;@M(6i=x-DTO}KQ%l;^)X7IOSd}yb)efN&-d%xDhcviAw zp0psDR6yuB5x`7CN3KR_f&LqRl-AuL1)#vdRpT=>QYh6@k8C_QVEoIC08IciW$qFr zM;dEQ{1f4F)0k7Zw2@RZUdD9NtXGZ+QfVv!@1?HRO(%rDsjxs z7yjhIfW#-~4bMA%x2L4hXKlK_J!mu;;@&nbd)qVv-d5v+rfS2T!n@Y=v9uh2T8THU zp0D*UUgurB?n*($P`k3XA2<6`i!Y}ZU({b&vH3XV zE%(~XJ!x~!JI=TJNnxV0)l<>tO=X6)uX12>*RbPSs{sodtC2k=bo%SU303&mt8jIC{ooo{Eh`)$AT*r z3%^})y#G8t)|g0#Y=ZBXvP+@K949#_0q{0ZK2f@_)mndzC;c_3(8NRzt# z0MnBNOZHc$szssXfO&UcPd8b`WuYW4E)cV$r-qEC(g54nr|vRIbTqWf41XM2NyWFP ztB+qKV-KBB>XN$%nIG8PBZ<;q1aUM**))g5nAB}p%TrC;xiPNQ<1F@OW`Ww!OG zU`utzcIm<^0Li49`=kDj796=($rgnyYd@tRe|XWQWqRaULlV?2m8z>^=E##2XnmPh z$j#eHhj`|vnPdSVTD}RX(rhWZGf{aZRmxUYbP3TGTClBD6#q#6Fj~oLCxd3uohYAw zv;%OwEo3BO>*0)dTDsGFG6rHuC^i~43sOjP0rm=%q&I~On=>A-(L$Us5KD4+F|-4y zQ&y5fp-8!*1DFw(wj)140=pB6_yOvpZ3|XKYbfh9r;XR-BwCY`DGhrV654a7Oj?v@ zJIj@vbSW>LbVVU!7bXd<*Yv21TwIgbCrGNqq)9P)GW?)MQ=Rs3Rx&ft9BOMgbFMSp z86T#9B;T}KpUmJzGR;yjC(XD}Iq0Ed6F#?2qfU_nCEM7a={xnH_#c z4a#vkoVjr7#=R}}wJ2p*Me-{JE!s@Uyti<~@o9mW5PI6GK5bQ>#;H%Ik-RAJ(YgLa zf@ecH+c{@!WP7n^IYc#;l2j>K$|JYpr_PI3Qas6+YwfoV3B5(c1^L~DJw*dHcC~Bk zPYffunHn^ZG?3hfYZv~Jh$wYj5;?NNKdsEyQl(7gAKnt8WBM>M`Nz_oiTz3a$^Dl8 zX(VP!W-j{^_iO+*)_CWPa|Pdr<^lZ%QfNoD3F%2G;krq15{EeaO3P#eOnqV3_V0Yiv|^v=Y-gl`B^PA)kY76uIW87TXUYG{fhtU1bG)1@P1 zKkjgbG@Yj5M>C`j*Dl%_k>7f@jYzvGnpp27K-SqON*ea5dpNK&`3KHq|G<%Dzi;=} zeS13(??0%T4h|TX$X%pzd8BBHUy<+&Is8Hny+H1PH`AOuKj%nNV_4=iV&{E+&IBVD z@+M%0E!`1YBrBcokY4Sd-ZUR>DPkE_z-$f-3fC7L*0Hy>V7CeKd z6d{dI7WhzsH?{dl+YKfOKx>|X>} z3G^b&CwC$Wlmk%nAnoRzU2<=qdP=BulXh~zT1_lPOV-#P%9heo8C|X8(CxjcgcJl- zQyuEr>9o+^`p}MTyZ7(kD<7r`4Rqnz`-)z8yS(`|K6~w8+ZZW4P1am`I@O*2RQknOZ+V+9rQM&h%A2x^ z^mztb-ic4SnmgN{JKvi-f5>z!eyp;_-S$+QXVD5@<;o$`sHJ?Yc=nLVHEJoiI-~S- z_o?outKGFv)%s>E!qdDlOQI4z9DOX7{4Xc07v8kBjV)d2U%L77(#h- zEbCH?@QuHK!+&&&8xD}n`Y}n!1vD1^W zYt*tkNEgs?Nkd(p*c^{O2V#m;ih0=V$*wrxcAnIH>PIaN!G2*vreLq2BS+Ju$a->~ zFS%r}c`P|=Ld>o@h|g~d;r z&YGUi@DF>ep{8-R&}B3nVKKhT)fXmir}_&q%l^M_`AfE;pro;0#X%A znDL=Wms;{0+p;S&TQ1prGnad9h2$2~M0(Ttv&VAsPe+}K3jZs+ze z%sEfioUJ*3#5c3yYIeClyV{#wJ(gEOA1`CYcZ|)hxjcLQg+Bkn_1=Z+#|rKkD=52> zV$M$SX0N_rd&d4_`vr4QCo~BeV;Kc!q!S&akDNaHeDsAf zVy^jMu6e{<?ADM=f`an{8pn z4bp*VeBMoX+*4BWq`9t}!8tT2Zj!=Ys)H1xX}n#fW|0!VC$IgL)t;5Be!1>ai^s9n zZ(DcSwhn&8Da)UGWtwaru&pJZYQpMVy;4e{XN~67({;}#E-s_U3u1lZ8uBM<6V{aI z|0PL}aN4pMdg7ZNy{1_5l~%2pE{#sFH&A4>SfU4`B?9FdogKZF9?WyB&5_=)8!7#b z90R4iF&%jV=EWVWR(E!+TH3sH`RagBEDtLu@}wq1p^4^MWjs%Ey5vh(I^qezz_i|b zy0>=IcYDv_QhdwsIRxJXCE&5?z+ti^Px`qsU3`(p5diwESFKtHjAR1??q-sak^+Dk zRu#Ww2W{yT4H%tt3$yR<KKj{6rid=hZA&mzKu+7$ysXFz0uo$2bfLF&Rgq8r{LqToo@0^AMY+6K4_0BYv) zA%FpZ$MN3d2%QD^0l;|<;lckw=na4?0RIN?7Qi?F{4~m+0{jW!uK*H8Run)Pz+3>5 zNFrE^U>(3>0Jvt5n*siS#g9s z>UAaV!DU=F%eak|anZ&XEqv(5hatR?3QX(Tx4rwIa|hj|E6qhxRuOMJzX!T@0q_x` zsaCxJu9HttIYbFvudYCOxJX((V}pTqdcy7Db$;p=ilsKeTrMOJ&AXwae>ZlzH_Vjs zq^t~`Xnrv3fvllupJ2Zx#tO#kl1@wyA{5MU_m@g-F-5t8aV*Xfl<5DsDL!bR|Fp@o zrhFJhA>=O((p{x7*rEixX{U=%`h!fE*sc~I({Ao)x%2JaNyKbR*a3$9w=n*BhwC*cmCP<73yOU*dG zc;xVvv>a5_?sL@p(i$Qa)e7Xp8dxJR9%Bm{5D3-9LA5djQCoFTa`_a3C21KY86s2duD2|$_kAu-1jG^{j=hs6xaOw(|7^y$kC*+Oi z6;S20&l=0hoq$E7&(Y*dTN1+J!}vPEK4vAu{lg1_5{0p^K&1u>6AB4w$F0M0!6?K` zy7-i#-lILiXvAW|=*1#tChT0nI0UBwI}{7#@`EDMXrryNHW|_L2}SQPmv}2D<8sYV zK?I>*l;}V4_I1Qj2yrxpf@B{qTu6~mDE?ggTwpzK;g02IDxWHWNXhI5Q}&GorX7qWk>@X>qi0O_|*;Wj9>OD}JKZmsfo) zYMCI=8FpNLP~s2|UJelogkI%v>2);^$}7Aks?Y8gm4oflTq^GSR9x;pL2#Tb2-10` c;5c*WI{!!h+-o9|g4-nJ{dTECkNN7q0OQbREdT%j delta 41933 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 u^N+|Qsmql|= 10 and cleaned.isdigit() + +def validate_zipcode(zipcode): + """Validate US zipcode format""" + return len(zipcode) == 5 and zipcode.isdigit() + +def get_input(prompt, validator=None, required=True, default=None): + """Get user input with optional validation""" + while True: + if default: + user_input = input(f"{prompt} [{default}]: ").strip() + if not user_input: + return default + else: + user_input = input(f"{prompt}: ").strip() + + if not user_input and not required: + return None + + if not user_input and required: + print("❌ This field is required. Please try again.") + continue + + if validator and not validator(user_input): + print("❌ Invalid format. Please try again.") + continue + + return user_input + +def get_password(): + """Get password with confirmation and validation""" + while True: + password = getpass.getpass("Password (min 8 characters): ") + + if len(password) < 8: + print("❌ Password must be at least 8 characters long.") + continue + + confirm = getpass.getpass("Confirm password: ") + + if password != confirm: + print("❌ Passwords do not match. Please try again.") + continue + + return password + +def create_superadmin(): + """Create a superadmin user interactively""" db = SessionLocal() try: - # Check if admin already exists - existing_admin = db.query(User).filter( - User.email == "admin@loaf.org" - ).first() + print("\n" + "="*60) + print("🔧 LOAF Membership Platform - Superadmin Creation") + print("="*60 + "\n") - if existing_admin: - print(f"⚠️ Admin user already exists: {existing_admin.email}") - print(f" Role: {existing_admin.role.value}") - print(f" Status: {existing_admin.status.value}") + # Get user information interactively + print("📝 Please provide the superadmin account details:\n") + + email = get_input( + "Email address", + validator=validate_email, + required=True + ) + + # Check if user already exists + existing_user = db.query(User).filter(User.email == email).first() + + if existing_user: + print(f"\n⚠️ User with email '{email}' already exists!") + print(f" Current Role: {existing_user.role.value}") + print(f" Current Status: {existing_user.status.value}") + + update = input("\n❓ Would you like to update this user to superadmin? (yes/no): ").strip().lower() + + if update in ['yes', 'y']: + existing_user.role = UserRole.superadmin + existing_user.status = UserStatus.active + existing_user.email_verified = True + + # Assign superadmin role in dynamic RBAC if roles table exists + try: + superadmin_role = db.query(Role).filter(Role.code == 'superadmin').first() + if superadmin_role and not existing_user.role_id: + existing_user.role_id = superadmin_role.id + except Exception: + pass # Roles table might not exist yet - # Update to admin role if not already - if existing_admin.role != UserRole.admin: - existing_admin.role = UserRole.admin - existing_admin.status = UserStatus.active - existing_admin.email_verified = True db.commit() - print("✅ Updated existing user to admin role") + print("✅ User updated to superadmin successfully!") + print(f" Email: {existing_user.email}") + print(f" Role: {existing_user.role.value}") + print(f" User ID: {existing_user.id}") + else: + print("❌ Operation cancelled.") return - print("Creating admin user...") + password = get_password() - # Create admin user - admin_user = User( - email="admin@loaf.org", - password_hash=get_password_hash("admin123"), # Change this password! - first_name="Admin", - last_name="User", - phone="555-0001", - address="123 Admin Street", - city="Admin City", - state="CA", - zipcode="90001", - date_of_birth=datetime(1990, 1, 1), + print("\n👤 Personal Information:\n") + + first_name = get_input("First name", required=True) + last_name = get_input("Last name", required=True) + phone = get_input("Phone number", validator=validate_phone, required=True) + + print("\n📍 Address Information:\n") + + address = get_input("Street address", required=True) + city = get_input("City", required=True) + state = get_input("State (2-letter code)", required=True, default="CA") + zipcode = get_input("ZIP code", validator=validate_zipcode, required=True) + + print("\n📅 Date of Birth (YYYY-MM-DD format):\n") + + while True: + dob_str = get_input("Date of birth (e.g., 1990-01-15)", required=True) + try: + date_of_birth = datetime.strptime(dob_str, "%Y-%m-%d") + break + except ValueError: + print("❌ Invalid date format. Please use YYYY-MM-DD format.") + + # Create superadmin user + print("\n⏳ Creating superadmin user...") + + superadmin_user = User( + email=email, + password_hash=get_password_hash(password), + first_name=first_name, + last_name=last_name, + phone=phone, + address=address, + city=city, + state=state.upper(), + zipcode=zipcode, + date_of_birth=date_of_birth, status=UserStatus.active, - role=UserRole.admin, + role=UserRole.superadmin, email_verified=True, newsletter_subscribed=False ) - db.add(admin_user) + db.add(superadmin_user) + db.flush() # Flush to get the user ID before looking up roles + + # Assign superadmin role in dynamic RBAC if roles table exists + try: + superadmin_role = db.query(Role).filter(Role.code == 'superadmin').first() + if superadmin_role: + superadmin_user.role_id = superadmin_role.id + print(" ✓ Assigned dynamic superadmin role") + except Exception as e: + print(f" ⚠️ Dynamic roles not yet set up (this is normal for fresh installs)") + db.commit() - db.refresh(admin_user) + db.refresh(superadmin_user) - print("✅ Admin user created successfully!") - print(f" Email: admin@loaf.org") - print(f" Password: admin123") - print(f" Role: {admin_user.role.value}") - print(f" User ID: {admin_user.id}") - print("\n⚠️ IMPORTANT: Change the password after first login!") + print("\n" + "="*60) + print("✅ Superadmin user created successfully!") + print("="*60) + print(f"\n📧 Email: {superadmin_user.email}") + print(f"👤 Name: {superadmin_user.first_name} {superadmin_user.last_name}") + print(f"🔑 Role: {superadmin_user.role.value}") + print(f"🆔 User ID: {superadmin_user.id}") + print(f"\n✨ You can now log in to the admin panel at /admin/login") + print("\n" + "="*60 + "\n") - except Exception as e: - print(f"❌ Error creating admin user: {e}") + except KeyboardInterrupt: + print("\n\n❌ Operation cancelled by user.") db.rollback() + sys.exit(1) + except Exception as e: + print(f"\n❌ Error creating superadmin user: {e}") + import traceback + traceback.print_exc() + db.rollback() + sys.exit(1) finally: db.close() if __name__ == "__main__": - create_admin() + create_superadmin() diff --git a/email_service.py b/email_service.py index 588086a..a645ca1 100644 --- a/email_service.py +++ b/email_service.py @@ -450,3 +450,117 @@ async def send_invitation_email( """ return await send_email(to_email, subject, html_content) + + +async def send_donation_thank_you_email(email: str, first_name: str, amount_cents: int): + """Send donation thank you email""" + subject = "Thank You for Your Generous Donation!" + amount = f"${amount_cents / 100:.2f}" + + html_content = f""" + + + + + + +

+
+

💜 Thank You!

+
+
+

Dear {first_name},

+ +

Thank you for your generous donation to LOAF!

+ +
+

Donation Amount

+
{amount}
+
+ +
+

+ Your support helps us continue our mission to build and strengthen the LGBTQ+ community. +

+
+ +

Your donation is tax-deductible to the extent allowed by law. Please keep this email for your records.

+ +

We are deeply grateful for your commitment to our community and your belief in our work.

+ +

+ With gratitude,
+ The LOAF Team +

+ +

+ Questions about your donation? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(email, subject, html_content) + + +async def send_rejection_email(email: str, first_name: str, reason: str): + """Send rejection notification email""" + subject = "LOAF Membership Application Update" + + html_content = f""" + + + + + + +
+
+

Membership Application Update

+
+
+

Dear {first_name},

+ +

Thank you for your interest in joining LOAF. After careful review, we are unable to approve your membership application at this time.

+ +
+

Reason:

+

{reason}

+
+ +

If you have questions or would like to discuss this decision, please don't hesitate to contact us at support@loaf.org.

+ +

+ Warm regards,
+ The LOAF Team +

+ +

+ Questions? Contact us at support@loaf.org +

+
+
+ + + """ + + return await send_email(email, subject, html_content) diff --git a/migrations/000_initial_schema.sql b/migrations/000_initial_schema.sql new file mode 100644 index 0000000..a795b24 --- /dev/null +++ b/migrations/000_initial_schema.sql @@ -0,0 +1,578 @@ +-- ============================================================================ +-- Migration 000: Initial Database Schema +-- ============================================================================ +-- Description: Creates all base tables, enums, and indexes for the LOAF +-- membership platform. This migration should be run first on +-- a fresh database. +-- Date: 2024-12-18 +-- Author: LOAF Development Team +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- SECTION 1: Create ENUM Types +-- ============================================================================ + +-- User status enum +CREATE TYPE userstatus AS ENUM ( + 'pending_email', + 'pending_validation', + 'pre_validated', + 'payment_pending', + 'active', + 'inactive', + 'canceled', + 'expired', + 'abandoned', + 'rejected' +); + +-- User role enum +CREATE TYPE userrole AS ENUM ( + 'guest', + 'member', + 'admin', + 'finance', + 'superadmin' +); + +-- RSVP status enum +CREATE TYPE rsvpstatus AS ENUM ( + 'yes', + 'no', + 'maybe' +); + +-- Subscription status enum +CREATE TYPE subscriptionstatus AS ENUM ( + 'active', + 'cancelled', + 'expired' +); + +-- Donation type enum +CREATE TYPE donationtype AS ENUM ( + 'member', + 'public' +); + +-- Donation status enum +CREATE TYPE donationstatus AS ENUM ( + 'pending', + 'completed', + 'failed' +); + +-- Invitation status enum +CREATE TYPE invitationstatus AS ENUM ( + 'pending', + 'accepted', + 'expired', + 'revoked' +); + +-- Import job status enum +CREATE TYPE importjobstatus AS ENUM ( + 'processing', + 'completed', + 'failed', + 'partial' +); + +COMMIT; + +-- Display progress +SELECT 'Step 1/8 completed: ENUM types created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 2: Create Core Tables +-- ============================================================================ + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Authentication + email VARCHAR NOT NULL UNIQUE, + password_hash VARCHAR NOT NULL, + email_verified BOOLEAN NOT NULL DEFAULT FALSE, + email_verification_token VARCHAR UNIQUE, + + -- Personal Information + first_name VARCHAR NOT NULL, + last_name VARCHAR NOT NULL, + phone VARCHAR, + address VARCHAR, + city VARCHAR, + state VARCHAR(2), + zipcode VARCHAR(10), + date_of_birth DATE, + bio TEXT, + + -- Profile + profile_photo_url VARCHAR, + + -- Social Media + social_media_facebook VARCHAR, + social_media_instagram VARCHAR, + social_media_twitter VARCHAR, + social_media_linkedin VARCHAR, + + -- Partner Information + partner_first_name VARCHAR, + partner_last_name VARCHAR, + partner_is_member BOOLEAN DEFAULT FALSE, + partner_plan_to_become_member BOOLEAN DEFAULT FALSE, + + -- Referral + referred_by_member_name VARCHAR, + lead_sources JSONB DEFAULT '[]'::jsonb, + + -- Status & Role + status userstatus NOT NULL DEFAULT 'pending_email', + role userrole NOT NULL DEFAULT 'guest', + role_id UUID, -- For dynamic RBAC (added in later migration) + + -- Rejection Tracking + rejection_reason TEXT, + rejected_at TIMESTAMP WITH TIME ZONE, + rejected_by UUID REFERENCES users(id), + + -- Membership + member_since DATE, + tos_accepted BOOLEAN DEFAULT FALSE, + tos_accepted_at TIMESTAMP WITH TIME ZONE, + newsletter_subscribed BOOLEAN DEFAULT TRUE, + + -- Reminder Tracking + reminder_30_days_sent BOOLEAN DEFAULT FALSE, + reminder_60_days_sent BOOLEAN DEFAULT FALSE, + reminder_85_days_sent BOOLEAN DEFAULT FALSE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Events table +CREATE TABLE IF NOT EXISTS events ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Event Details + title VARCHAR NOT NULL, + description TEXT, + location VARCHAR, + cover_image_url VARCHAR, + + -- Schedule + start_at TIMESTAMP WITH TIME ZONE NOT NULL, + end_at TIMESTAMP WITH TIME ZONE, + + -- Capacity + capacity INTEGER, + published BOOLEAN NOT NULL DEFAULT FALSE, + + -- Calendar Integration + calendar_uid VARCHAR UNIQUE, + microsoft_calendar_id VARCHAR, + microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE, + + -- Metadata + created_by UUID REFERENCES users(id), + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Event RSVPs table +CREATE TABLE IF NOT EXISTS event_rsvps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + + -- RSVP Details + rsvp_status rsvpstatus NOT NULL DEFAULT 'maybe', + attended BOOLEAN DEFAULT FALSE, + attended_at TIMESTAMP WITH TIME ZONE, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + + -- Unique constraint: one RSVP per user per event + UNIQUE(event_id, user_id) +); + +-- Event Gallery table +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 Details + image_url VARCHAR NOT NULL, + caption TEXT, + order_index INTEGER DEFAULT 0, + + -- Metadata + uploaded_by UUID REFERENCES users(id), + uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; + +-- Display progress +SELECT 'Step 2/8 completed: Core tables (users, events, rsvps, gallery) created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 3: Create Subscription & Payment Tables +-- ============================================================================ + +-- Subscription Plans table +CREATE TABLE IF NOT EXISTS subscription_plans ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Plan Details + name VARCHAR NOT NULL, + description TEXT, + price_cents INTEGER NOT NULL, + billing_cycle VARCHAR NOT NULL DEFAULT 'annual', + + -- Configuration + active BOOLEAN NOT NULL DEFAULT TRUE, + features JSONB DEFAULT '[]'::jsonb, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Subscriptions table +CREATE TABLE IF NOT EXISTS subscriptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + plan_id UUID NOT NULL REFERENCES subscription_plans(id), + + -- Stripe Integration + stripe_subscription_id VARCHAR, + stripe_customer_id VARCHAR, + + -- Status & Dates + status subscriptionstatus DEFAULT 'active', + start_date TIMESTAMP WITH TIME ZONE NOT NULL, + end_date TIMESTAMP WITH TIME ZONE, + next_billing_date TIMESTAMP WITH TIME ZONE, + + -- Payment Details + amount_paid_cents INTEGER, + base_subscription_cents INTEGER NOT NULL, + donation_cents INTEGER DEFAULT 0 NOT NULL, + + -- Manual Payment Support + manual_payment BOOLEAN DEFAULT FALSE NOT NULL, + manual_payment_notes TEXT, + manual_payment_admin_id UUID REFERENCES users(id), + manual_payment_date TIMESTAMP WITH TIME ZONE, + payment_method VARCHAR, + + -- Timestamps + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Donations table +CREATE TABLE IF NOT EXISTS donations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + -- Donation Details + amount_cents INTEGER NOT NULL, + donation_type donationtype NOT NULL DEFAULT 'public', + status donationstatus NOT NULL DEFAULT 'pending', + + -- Donor Information + user_id UUID REFERENCES users(id), -- NULL for public donations + donor_email VARCHAR, + donor_name VARCHAR, + + -- Payment Details + stripe_checkout_session_id VARCHAR, + stripe_payment_intent_id VARCHAR, + payment_method VARCHAR, + + -- Metadata + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE +); + +COMMIT; + +-- Display progress +SELECT 'Step 3/8 completed: Subscription and donation tables created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 4: Create RBAC Tables +-- ============================================================================ + +-- Permissions table +CREATE TABLE IF NOT EXISTS permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + code VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + description TEXT, + module VARCHAR NOT NULL, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Roles table (for dynamic RBAC) +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + code VARCHAR NOT NULL UNIQUE, + name VARCHAR NOT NULL, + description TEXT, + is_system_role BOOLEAN NOT NULL DEFAULT FALSE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +-- Role Permissions junction table +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + role userrole, -- Legacy enum-based role (for backward compatibility) + role_id UUID REFERENCES roles(id) ON DELETE CASCADE, -- Dynamic role + permission_id UUID NOT NULL REFERENCES permissions(id) ON DELETE CASCADE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + created_by UUID REFERENCES users(id) ON DELETE SET NULL +); + +COMMIT; + +-- Display progress +SELECT 'Step 4/8 completed: RBAC tables created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 5: Create Document Management Tables +-- ============================================================================ + +-- Newsletter Archive table +CREATE TABLE IF NOT EXISTS newsletter_archives ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + title VARCHAR NOT NULL, + file_url VARCHAR NOT NULL, + file_size_bytes INTEGER, + issue_date DATE NOT NULL, + description TEXT, + + uploaded_by UUID REFERENCES users(id), + uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Financial Reports table +CREATE TABLE IF NOT EXISTS financial_reports ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + title VARCHAR NOT NULL, + file_url VARCHAR NOT NULL, + file_size_bytes INTEGER, + fiscal_period VARCHAR NOT NULL, + report_type VARCHAR, + + uploaded_by UUID REFERENCES users(id), + uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Bylaws Documents table +CREATE TABLE IF NOT EXISTS bylaws_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + title VARCHAR NOT NULL, + file_url VARCHAR NOT NULL, + file_size_bytes INTEGER, + version VARCHAR NOT NULL, + effective_date DATE NOT NULL, + description TEXT, + is_current BOOLEAN DEFAULT TRUE, + + uploaded_by UUID REFERENCES users(id), + uploaded_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +COMMIT; + +-- Display progress +SELECT 'Step 5/8 completed: Document management tables created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 6: Create System Tables +-- ============================================================================ + +-- Storage Usage table +CREATE TABLE IF NOT EXISTS storage_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + total_bytes_used BIGINT NOT NULL DEFAULT 0, + max_bytes_allowed BIGINT NOT NULL DEFAULT 10737418240, -- 10GB + last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- User Invitations table +CREATE TABLE IF NOT EXISTS user_invitations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + email VARCHAR NOT NULL, + token VARCHAR NOT NULL UNIQUE, + role userrole NOT NULL, + status invitationstatus NOT NULL DEFAULT 'pending', + + invited_by UUID REFERENCES users(id) ON DELETE SET NULL, + expires_at TIMESTAMP WITH TIME ZONE NOT NULL, + accepted_at TIMESTAMP WITH TIME ZONE, + revoked_at TIMESTAMP WITH TIME ZONE, + + created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP +); + +-- Import Jobs table +CREATE TABLE IF NOT EXISTS import_jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + filename VARCHAR NOT NULL, + status importjobstatus NOT NULL DEFAULT 'processing', + total_rows INTEGER DEFAULT 0, + processed_rows INTEGER DEFAULT 0, + success_count INTEGER DEFAULT 0, + error_count INTEGER DEFAULT 0, + error_log JSONB DEFAULT '[]'::jsonb, + + started_by UUID REFERENCES users(id), + started_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP, + completed_at TIMESTAMP WITH TIME ZONE +); + +COMMIT; + +-- Display progress +SELECT 'Step 6/8 completed: System tables created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 7: Create Indexes +-- ============================================================================ + +-- Users table indexes +CREATE INDEX IF NOT EXISTS idx_users_email ON users(email); +CREATE INDEX IF NOT EXISTS idx_users_status ON users(status); +CREATE INDEX IF NOT EXISTS idx_users_role ON users(role); +CREATE INDEX IF NOT EXISTS idx_users_role_id ON users(role_id); +CREATE INDEX IF NOT EXISTS idx_users_email_verified ON users(email_verified); +CREATE INDEX IF NOT EXISTS idx_users_rejected_at ON users(rejected_at) WHERE rejected_at IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_users_created_at ON users(created_at); + +-- Events table indexes +CREATE INDEX IF NOT EXISTS idx_events_created_by ON events(created_by); +CREATE INDEX IF NOT EXISTS idx_events_start_at ON events(start_at); +CREATE INDEX IF NOT EXISTS idx_events_published ON events(published); + +-- Event RSVPs indexes +CREATE INDEX IF NOT EXISTS idx_event_rsvps_event_id ON event_rsvps(event_id); +CREATE INDEX IF NOT EXISTS idx_event_rsvps_user_id ON event_rsvps(user_id); +CREATE INDEX IF NOT EXISTS idx_event_rsvps_rsvp_status ON event_rsvps(rsvp_status); + +-- Event Gallery indexes +CREATE INDEX IF NOT EXISTS idx_event_galleries_event_id ON event_galleries(event_id); + +-- Subscriptions indexes +CREATE INDEX IF NOT EXISTS idx_subscriptions_user_id ON subscriptions(user_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_plan_id ON subscriptions(plan_id); +CREATE INDEX IF NOT EXISTS idx_subscriptions_status ON subscriptions(status); +CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe_subscription_id ON subscriptions(stripe_subscription_id); + +-- Donations indexes +CREATE INDEX IF NOT EXISTS idx_donation_user ON donations(user_id); +CREATE INDEX IF NOT EXISTS idx_donation_type ON donations(donation_type); +CREATE INDEX IF NOT EXISTS idx_donation_status ON donations(status); +CREATE INDEX IF NOT EXISTS idx_donation_created ON donations(created_at); + +-- Permissions indexes +CREATE INDEX IF NOT EXISTS idx_permissions_code ON permissions(code); +CREATE INDEX IF NOT EXISTS idx_permissions_module ON permissions(module); + +-- Roles indexes +CREATE INDEX IF NOT EXISTS idx_roles_code ON roles(code); +CREATE INDEX IF NOT EXISTS idx_roles_is_system_role ON roles(is_system_role); + +-- Role Permissions indexes +CREATE INDEX IF NOT EXISTS idx_role_permissions_role ON role_permissions(role); +CREATE INDEX IF NOT EXISTS idx_role_permissions_role_id ON role_permissions(role_id); +CREATE UNIQUE INDEX IF NOT EXISTS idx_role_permission ON role_permissions(role, permission_id) WHERE role IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_dynamic_role_permission ON role_permissions(role_id, permission_id) WHERE role_id IS NOT NULL; + +COMMIT; + +-- Display progress +SELECT 'Step 7/8 completed: Indexes created' AS progress; + +BEGIN; + +-- ============================================================================ +-- SECTION 8: Initialize Default Data +-- ============================================================================ + +-- Insert initial storage usage record +INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated) +SELECT + gen_random_uuid(), + 0, + 10737418240, -- 10GB + CURRENT_TIMESTAMP +WHERE NOT EXISTS (SELECT 1 FROM storage_usage); + +COMMIT; + +-- Display progress +SELECT 'Step 8/8 completed: Default data initialized' AS progress; + +-- ============================================================================ +-- Migration Complete +-- ============================================================================ + +SELECT ' +================================================================================ +✅ Migration 000 completed successfully! +================================================================================ + +Database schema initialized with: +- 10 ENUM types +- 17 tables (users, events, subscriptions, donations, RBAC, documents, system) +- 30+ indexes for performance +- 1 storage usage record + +Next steps: +1. Run: python seed_permissions_rbac.py (to populate permissions and roles) +2. Run: python create_admin.py (to create superadmin user) +3. Run remaining migrations in sequence (001-010) + +Database is ready for LOAF membership platform! 🎉 +================================================================================ +' AS migration_complete; diff --git a/migrations/009_create_donations.sql b/migrations/009_create_donations.sql new file mode 100644 index 0000000..7003414 --- /dev/null +++ b/migrations/009_create_donations.sql @@ -0,0 +1,44 @@ +-- Migration: Create Donations Table +-- Description: Adds donations table to track both member and public donations +-- Date: 2025-12-17 +-- CRITICAL: Fixes data loss issue where standalone donations weren't being saved + +BEGIN; + +-- Create donation type enum +CREATE TYPE donationtype AS ENUM ('member', 'public'); + +-- Create donation status enum +CREATE TYPE donationstatus AS ENUM ('pending', 'completed', 'failed'); + +-- Create donations table +CREATE TABLE donations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + amount_cents INTEGER NOT NULL, + donation_type donationtype NOT NULL DEFAULT 'public', + status donationstatus NOT NULL DEFAULT 'pending', + user_id UUID REFERENCES users(id), + donor_email VARCHAR, + donor_name VARCHAR, + stripe_checkout_session_id VARCHAR, + stripe_payment_intent_id VARCHAR, + payment_method VARCHAR, + notes TEXT, + created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE +); + +-- Create indexes for performance +CREATE INDEX idx_donation_user ON donations(user_id); +CREATE INDEX idx_donation_type ON donations(donation_type); +CREATE INDEX idx_donation_status ON donations(status); +CREATE INDEX idx_donation_created ON donations(created_at); + +-- Add comment +COMMENT ON TABLE donations IS 'Tracks both member and public one-time donations'; + +COMMIT; + +-- Verify migration +SELECT 'Donations table created successfully' as status; +SELECT COUNT(*) as initial_count FROM donations; diff --git a/migrations/010_add_rejection_fields.sql b/migrations/010_add_rejection_fields.sql new file mode 100644 index 0000000..84816c9 --- /dev/null +++ b/migrations/010_add_rejection_fields.sql @@ -0,0 +1,40 @@ +-- Migration: Add Rejection Fields to Users Table +-- Description: Adds rejection tracking fields and rejected status to UserStatus enum +-- Date: 2025-12-18 + +BEGIN; + +-- Add 'rejected' value to UserStatus enum if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM pg_enum + WHERE enumlabel = 'rejected' + AND enumtypid = (SELECT oid FROM pg_type WHERE typname = 'userstatus') + ) THEN + ALTER TYPE userstatus ADD VALUE 'rejected'; + RAISE NOTICE 'Added rejected to userstatus enum'; + ELSE + RAISE NOTICE 'rejected already exists in userstatus enum'; + END IF; +END$$; + +-- Add rejection tracking fields to users table +ALTER TABLE users +ADD COLUMN IF NOT EXISTS rejection_reason TEXT, +ADD COLUMN IF NOT EXISTS rejected_at TIMESTAMP WITH TIME ZONE, +ADD COLUMN IF NOT EXISTS rejected_by UUID REFERENCES users(id); + +-- Add comments for documentation +COMMENT ON COLUMN users.rejection_reason IS 'Reason provided when application was rejected'; +COMMENT ON COLUMN users.rejected_at IS 'Timestamp when application was rejected'; +COMMENT ON COLUMN users.rejected_by IS 'Admin who rejected the application'; + +-- Create index on rejected_at for filtering rejected users +CREATE INDEX IF NOT EXISTS idx_users_rejected_at ON users(rejected_at) WHERE rejected_at IS NOT NULL; + +COMMIT; + +-- Verify migration +SELECT 'Rejection fields added successfully' AS status; +SELECT COUNT(*) AS rejected_users_count FROM users WHERE status = 'rejected'; diff --git a/models.py b/models.py index ca4e842..ad4f06e 100644 --- a/models.py +++ b/models.py @@ -16,6 +16,7 @@ class UserStatus(enum.Enum): canceled = "canceled" # User or admin canceled membership expired = "expired" # Subscription ended without renewal abandoned = "abandoned" # Incomplete registration (no verification/event/payment) + rejected = "rejected" # Application rejected by admin class UserRole(enum.Enum): guest = "guest" @@ -34,6 +35,15 @@ class SubscriptionStatus(enum.Enum): expired = "expired" cancelled = "cancelled" +class DonationType(enum.Enum): + member = "member" + public = "public" + +class DonationStatus(enum.Enum): + pending = "pending" + completed = "completed" + failed = "failed" + class User(Base): __tablename__ = "users" @@ -115,6 +125,11 @@ class User(Base): renewal_reminders_sent = Column(Integer, default=0, nullable=False, comment="Count of renewal reminders sent") last_renewal_reminder_at = Column(DateTime, nullable=True, comment="Timestamp of last renewal reminder") + # Rejection Tracking + rejection_reason = Column(Text, nullable=True, comment="Reason provided when application was rejected") + rejected_at = Column(DateTime(timezone=True), nullable=True, comment="Timestamp when application was rejected") + rejected_by = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True, comment="Admin who rejected the application") + 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)) @@ -225,6 +240,41 @@ class Subscription(Base): user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id]) plan = relationship("SubscriptionPlan", back_populates="subscriptions") +class Donation(Base): + __tablename__ = "donations" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + + # Donation details + amount_cents = Column(Integer, nullable=False) + donation_type = Column(SQLEnum(DonationType), nullable=False, default=DonationType.public) + status = Column(SQLEnum(DonationStatus), nullable=False, default=DonationStatus.pending) + + # Donor information + user_id = Column(UUID(as_uuid=True), ForeignKey('users.id'), nullable=True) # NULL for public donations + donor_email = Column(String, nullable=True) # For non-members + donor_name = Column(String, nullable=True) # For non-members + + # Payment details + stripe_checkout_session_id = Column(String, nullable=True) + stripe_payment_intent_id = Column(String, nullable=True) + payment_method = Column(String, nullable=True) # card, bank_transfer, etc. + + # Metadata + notes = Column(Text, nullable=True) + created_at = Column(DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)) + updated_at = Column(DateTime(timezone=True), onupdate=lambda: datetime.now(timezone.utc)) + + # Relationship + user = relationship("User", backref="donations", foreign_keys=[user_id]) + + __table_args__ = ( + Index('idx_donation_user', 'user_id'), + Index('idx_donation_type', 'donation_type'), + Index('idx_donation_status', 'status'), + Index('idx_donation_created', 'created_at'), + ) + class EventGallery(Base): __tablename__ = "event_galleries" diff --git a/seed_permissions_rbac.py b/seed_permissions_rbac.py index 1ab34ff..6e0529b 100755 --- a/seed_permissions_rbac.py +++ b/seed_permissions_rbac.py @@ -2,7 +2,7 @@ """ Permission Seeding Script for Dynamic RBAC System -This script populates the database with 56 granular permissions and assigns them +This script populates the database with 59 granular permissions and assigns them to the appropriate dynamic roles (not the old enum roles). Usage: @@ -33,7 +33,7 @@ engine = create_engine(DATABASE_URL) SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) # ============================================================ -# Permission Definitions (56 permissions across 9 modules) +# Permission Definitions (59 permissions across 10 modules) # ============================================================ PERMISSIONS = [ @@ -60,13 +60,18 @@ PERMISSIONS = [ {"code": "events.rsvps", "name": "View Event RSVPs", "description": "View and manage event RSVPs", "module": "events"}, {"code": "events.calendar_export", "name": "Export Event Calendar", "description": "Export events to iCal format", "module": "events"}, - # ========== SUBSCRIPTIONS MODULE (6) ========== + # ========== SUBSCRIPTIONS MODULE (7) ========== {"code": "subscriptions.view", "name": "View Subscriptions", "description": "View subscription list and details", "module": "subscriptions"}, {"code": "subscriptions.create", "name": "Create Subscriptions", "description": "Create manual subscriptions for users", "module": "subscriptions"}, {"code": "subscriptions.edit", "name": "Edit Subscriptions", "description": "Edit subscription details", "module": "subscriptions"}, {"code": "subscriptions.cancel", "name": "Cancel Subscriptions", "description": "Cancel user subscriptions", "module": "subscriptions"}, {"code": "subscriptions.activate", "name": "Activate Subscriptions", "description": "Manually activate subscriptions", "module": "subscriptions"}, {"code": "subscriptions.plans", "name": "Manage Subscription Plans", "description": "Create and edit subscription plans", "module": "subscriptions"}, + {"code": "subscriptions.export", "name": "Export Subscriptions", "description": "Export subscription data to CSV", "module": "subscriptions"}, + + # ========== DONATIONS MODULE (2) ========== + {"code": "donations.view", "name": "View Donations", "description": "View donation list and details", "module": "donations"}, + {"code": "donations.export", "name": "Export Donations", "description": "Export donation data to CSV", "module": "donations"}, # ========== FINANCIALS MODULE (6) ========== {"code": "financials.view", "name": "View Financial Reports", "description": "View financial reports and dashboards", "module": "financials"}, @@ -129,6 +134,8 @@ DEFAULT_ROLE_PERMISSIONS = { "financials.delete", "financials.export", "financials.payments", "subscriptions.view", "subscriptions.create", "subscriptions.edit", "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", + "subscriptions.export", + "donations.view", "donations.export", ], "admin": [ @@ -140,6 +147,8 @@ DEFAULT_ROLE_PERMISSIONS = { "events.attendance", "events.rsvps", "events.calendar_export", "subscriptions.view", "subscriptions.create", "subscriptions.edit", "subscriptions.cancel", "subscriptions.activate", "subscriptions.plans", + "subscriptions.export", + "donations.view", "donations.export", "financials.view", "financials.create", "financials.edit", "financials.delete", "financials.export", "financials.payments", "newsletters.view", "newsletters.create", "newsletters.edit", "newsletters.delete", diff --git a/server.py b/server.py index 75d92c6..0c2978f 100644 --- a/server.py +++ b/server.py @@ -17,7 +17,7 @@ import csv import io from database import engine, get_db, Base -from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus +from models import User, Event, EventRSVP, UserStatus, UserRole, RSVPStatus, SubscriptionPlan, Subscription, SubscriptionStatus, StorageUsage, EventGallery, NewsletterArchive, FinancialReport, BylawsDocument, Permission, RolePermission, Role, UserInvitation, InvitationStatus, ImportJob, ImportJobStatus, Donation, DonationType, DonationStatus from auth import ( get_password_hash, verify_password, @@ -208,6 +208,8 @@ class UserResponse(BaseModel): role: str email_verified: bool created_at: datetime + # Profile + profile_photo_url: Optional[str] = None # Subscription info (optional) subscription_start_date: Optional[datetime] = None subscription_end_date: Optional[datetime] = None @@ -764,6 +766,20 @@ async def get_my_permissions( "role": get_user_role_code(current_user) } +@api_router.get("/config") +async def get_config(): + """ + Get public configuration values + Returns: max_file_size_bytes, max_file_size_mb + """ + max_file_size_bytes = int(os.getenv('MAX_FILE_SIZE_BYTES', 52428800)) # Default 50MB + max_file_size_mb = max_file_size_bytes / (1024 * 1024) + + return { + "max_file_size_bytes": max_file_size_bytes, + "max_file_size_mb": int(max_file_size_mb) + } + # User Profile Routes @api_router.get("/users/profile", response_model=UserResponse) async def get_profile(current_user: User = Depends(get_current_user)): @@ -2104,6 +2120,7 @@ async def get_user_by_id( "state": user.state, "zipcode": user.zipcode, "date_of_birth": user.date_of_birth.isoformat() if user.date_of_birth else None, + "profile_photo_url": user.profile_photo_url, "partner_first_name": user.partner_first_name, "partner_last_name": user.partner_last_name, "partner_is_member": user.partner_is_member, @@ -2194,6 +2211,52 @@ async def update_user_status( except ValueError: raise HTTPException(status_code=400, detail="Invalid status") +@api_router.post("/admin/users/{user_id}/reject") +async def reject_user( + user_id: str, + rejection_data: dict, + current_user: User = Depends(require_permission("users.approve")), + db: Session = Depends(get_db) +): + """Reject a user's membership application with mandatory reason""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + reason = rejection_data.get("reason", "").strip() + if not reason: + raise HTTPException(status_code=400, detail="Rejection reason is required") + + # Update user status to rejected + user.status = UserStatus.rejected + user.rejection_reason = reason + user.rejected_at = datetime.now(timezone.utc) + user.rejected_by = current_user.id + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + # Send rejection email + try: + from email_service import send_rejection_email + await send_rejection_email( + user.email, + user.first_name, + reason + ) + logger.info(f"Rejection email sent to {user.email}") + except Exception as e: + logger.error(f"Failed to send rejection email to {user.email}: {str(e)}") + # Don't fail the request if email fails + + logger.info(f"Admin {current_user.email} rejected user {user.email}") + + return { + "message": "User rejected successfully", + "user_id": str(user.id), + "status": user.status.value + } + @api_router.post("/admin/users/{user_id}/activate-payment") async def activate_payment_manually( user_id: str, @@ -2356,6 +2419,114 @@ async def admin_resend_verification( return {"message": f"Verification email resent to {user.email}"} +@api_router.post("/admin/users/{user_id}/upload-photo") +async def admin_upload_user_profile_photo( + user_id: str, + file: UploadFile = File(...), + current_user: User = Depends(require_permission("users.edit")), + db: Session = Depends(get_db) +): + """Admin uploads profile photo for a specific user""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User 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)) + + # Delete old profile photo if exists + if user.profile_photo_url: + old_key = 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) + storage.total_bytes_used -= old_size + except: + pass + + # Upload new photo + try: + public_url, object_key, file_size = await r2.upload_file( + file=file, + folder="profiles", + max_size_bytes=max_file_size + ) + + user.profile_photo_url = public_url + storage.total_bytes_used += file_size + storage.last_updated = datetime.now(timezone.utc) + + db.commit() + + logger.info(f"Admin {current_user.email} uploaded profile photo for user {user.email}: {file_size} bytes") + + return { + "message": "Profile photo uploaded successfully", + "profile_photo_url": public_url + } + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Upload failed: {str(e)}") + +@api_router.delete("/admin/users/{user_id}/delete-photo") +async def admin_delete_user_profile_photo( + user_id: str, + current_user: User = Depends(require_permission("users.edit")), + db: Session = Depends(get_db) +): + """Admin deletes profile photo for a specific user""" + user = db.query(User).filter(User.id == user_id).first() + if not user: + raise HTTPException(status_code=404, detail="User not found") + + if not user.profile_photo_url: + raise HTTPException(status_code=404, detail="User has no profile photo") + + r2 = get_r2_storage() + + # Extract object key from URL + object_key = user.profile_photo_url.split('/')[-1] + object_key = f"profiles/{object_key}" + + try: + # Get file size before deletion for storage tracking + storage = db.query(StorageUsage).first() + if storage: + try: + file_size = await r2.get_file_size(object_key) + storage.total_bytes_used -= file_size + storage.last_updated = datetime.now(timezone.utc) + except: + pass + + # Delete from R2 + await r2.delete_file(object_key) + + # Remove URL from user record + user.profile_photo_url = None + db.commit() + + logger.info(f"Admin {current_user.email} deleted profile photo for user {user.email}") + + return {"message": "Profile photo deleted successfully"} + except Exception as e: + db.rollback() + raise HTTPException(status_code=500, detail=f"Delete failed: {str(e)}") + # ============================================================ # User Creation & Invitation Endpoints # ============================================================ @@ -3648,6 +3819,287 @@ async def cancel_subscription( return {"message": "Subscription cancelled successfully"} +@api_router.get("/admin/subscriptions/export") +async def export_subscriptions( + status: Optional[str] = None, + plan_id: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("subscriptions.export")), + db: Session = Depends(get_db) +): + """Export subscriptions to CSV for financial records""" + + # Build query with same logic as get_all_subscriptions + query = db.query(Subscription).join(Subscription.user).join(Subscription.plan) + + # Apply filters + if status: + query = query.filter(Subscription.status == status) + if plan_id: + query = query.filter(Subscription.plan_id == plan_id) + if search: + search_term = f"%{search}%" + query = query.filter( + (User.first_name.ilike(search_term)) | + (User.last_name.ilike(search_term)) | + (User.email.ilike(search_term)) + ) + + subscriptions = query.order_by(Subscription.created_at.desc()).all() + + # Create CSV + output = io.StringIO() + writer = csv.writer(output) + + # Header row + writer.writerow([ + 'Subscription ID', 'Member Name', 'Email', 'Plan Name', 'Billing Cycle', + 'Status', 'Base Amount', 'Donation Amount', 'Total Amount', 'Payment Method', + 'Start Date', 'End Date', 'Stripe Subscription ID', 'Created At', 'Updated At' + ]) + + # Data rows + for sub in subscriptions: + user = sub.user + plan = sub.plan + writer.writerow([ + str(sub.id), + f"{user.first_name} {user.last_name}", + user.email, + plan.name, + plan.billing_cycle, + sub.status.value, + f"${sub.base_subscription_cents / 100:.2f}", + f"${sub.donation_cents / 100:.2f}" if sub.donation_cents else "$0.00", + f"${sub.amount_paid_cents / 100:.2f}" if sub.amount_paid_cents else "$0.00", + sub.payment_method or 'Stripe', + sub.start_date.isoformat() if sub.start_date else '', + sub.end_date.isoformat() if sub.end_date else '', + sub.stripe_subscription_id or '', + sub.created_at.isoformat() if sub.created_at else '', + sub.updated_at.isoformat() if sub.updated_at else '' + ]) + + # Return CSV + filename = f"subscriptions_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + +# ============================================================================ +# Admin Donation Management Routes +# ============================================================================ + +@api_router.get("/admin/donations") +async def get_donations( + donation_type: Optional[str] = None, + status: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("donations.view")), + db: Session = Depends(get_db) +): + """Get all donations with optional filters.""" + + query = db.query(Donation).outerjoin(User, Donation.user_id == User.id) + + # Apply filters + if donation_type: + try: + query = query.filter(Donation.donation_type == DonationType[donation_type]) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}") + + if status: + try: + query = query.filter(Donation.status == DonationStatus[status]) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if start_date: + try: + start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + query = query.filter(Donation.created_at >= start_dt) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid start_date format") + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + query = query.filter(Donation.created_at <= end_dt) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid end_date format") + + if search: + search_term = f"%{search}%" + query = query.filter( + (Donation.donor_email.ilike(search_term)) | + (Donation.donor_name.ilike(search_term)) | + (User.first_name.ilike(search_term)) | + (User.last_name.ilike(search_term)) + ) + + donations = query.order_by(Donation.created_at.desc()).all() + + return [{ + "id": str(d.id), + "amount_cents": d.amount_cents, + "amount": f"${d.amount_cents / 100:.2f}", + "donation_type": d.donation_type.value, + "status": d.status.value, + "donor_name": d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name), + "donor_email": d.donor_email or (d.user.email if d.user else None), + "payment_method": d.payment_method, + "notes": d.notes, + "created_at": d.created_at.isoformat() + } for d in donations] + +@api_router.get("/admin/donations/stats") +async def get_donation_stats( + current_user: User = Depends(require_permission("donations.view")), + db: Session = Depends(get_db) +): + """Get donation statistics.""" + from sqlalchemy import func + + # Total donations + total_donations = db.query(Donation).filter( + Donation.status == DonationStatus.completed + ).count() + + # Member donations + member_donations = db.query(Donation).filter( + Donation.status == DonationStatus.completed, + Donation.donation_type == DonationType.member + ).count() + + # Public donations + public_donations = db.query(Donation).filter( + Donation.status == DonationStatus.completed, + Donation.donation_type == DonationType.public + ).count() + + # Total amount + total_amount = db.query(func.sum(Donation.amount_cents)).filter( + Donation.status == DonationStatus.completed + ).scalar() or 0 + + # This month + now = datetime.now(timezone.utc) + this_month_start = now.replace(day=1, hour=0, minute=0, second=0, microsecond=0) + this_month_amount = db.query(func.sum(Donation.amount_cents)).filter( + Donation.status == DonationStatus.completed, + Donation.created_at >= this_month_start + ).scalar() or 0 + + this_month_count = db.query(Donation).filter( + Donation.status == DonationStatus.completed, + Donation.created_at >= this_month_start + ).count() + + return { + "total_donations": total_donations, + "member_donations": member_donations, + "public_donations": public_donations, + "total_amount_cents": total_amount, + "total_amount": f"${total_amount / 100:.2f}", + "this_month_amount_cents": this_month_amount, + "this_month_amount": f"${this_month_amount / 100:.2f}", + "this_month_count": this_month_count + } + +@api_router.get("/admin/donations/export") +async def export_donations( + donation_type: Optional[str] = None, + status: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + search: Optional[str] = None, + current_user: User = Depends(require_permission("donations.export")), + db: Session = Depends(get_db) +): + """Export donations to CSV.""" + import io + import csv + from fastapi.responses import StreamingResponse + + # Build query (same as get_donations) + query = db.query(Donation).outerjoin(User, Donation.user_id == User.id) + + if donation_type: + try: + query = query.filter(Donation.donation_type == DonationType[donation_type]) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid donation type: {donation_type}") + + if status: + try: + query = query.filter(Donation.status == DonationStatus[status]) + except KeyError: + raise HTTPException(status_code=400, detail=f"Invalid status: {status}") + + if start_date: + try: + start_dt = datetime.fromisoformat(start_date.replace('Z', '+00:00')) + query = query.filter(Donation.created_at >= start_dt) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid start_date format") + + if end_date: + try: + end_dt = datetime.fromisoformat(end_date.replace('Z', '+00:00')) + query = query.filter(Donation.created_at <= end_dt) + except ValueError: + raise HTTPException(status_code=400, detail="Invalid end_date format") + + if search: + search_term = f"%{search}%" + query = query.filter( + (Donation.donor_email.ilike(search_term)) | + (Donation.donor_name.ilike(search_term)) | + (User.first_name.ilike(search_term)) | + (User.last_name.ilike(search_term)) + ) + + donations = query.order_by(Donation.created_at.desc()).all() + + # Create CSV + output = io.StringIO() + writer = csv.writer(output) + + writer.writerow([ + 'Donation ID', 'Date', 'Donor Name', 'Donor Email', 'Type', + 'Amount', 'Status', 'Payment Method', 'Stripe Payment Intent', + 'Notes' + ]) + + for d in donations: + donor_name = d.donor_name if d.donation_type == DonationType.public else (f"{d.user.first_name} {d.user.last_name}" if d.user else d.donor_name) + donor_email = d.donor_email or (d.user.email if d.user else '') + + writer.writerow([ + str(d.id), + d.created_at.strftime('%Y-%m-%d %H:%M:%S'), + donor_name or '', + donor_email, + d.donation_type.value, + f"${d.amount_cents / 100:.2f}", + d.status.value, + d.payment_method or '', + d.stripe_payment_intent_id or '', + d.notes or '' + ]) + + filename = f"donations_export_{datetime.now().strftime('%Y%m%d')}.csv" + return StreamingResponse( + iter([output.getvalue()]), + media_type="text/csv", + headers={"Content-Disposition": f"attachment; filename={filename}"} + ) + # ============================================================================ # Admin Document Management Routes # ============================================================================ @@ -4761,14 +5213,39 @@ async def create_checkout( @api_router.post("/donations/checkout") async def create_donation_checkout( request: DonationCheckoutRequest, - db: Session = Depends(get_db) + db: Session = Depends(get_db), + current_user: Optional[User] = Depends(lambda: None) # Optional authentication ): """Create Stripe Checkout session for one-time donation.""" # Get frontend URL from env frontend_url = os.getenv("FRONTEND_URL", "http://localhost:3000") + # Check if user is authenticated (from header if present) try: + from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials + from jose import jwt, JWTError + + # Try to get token from request if available + # For now, we'll make this work for both authenticated and anonymous + pass + except: + pass + + try: + # Create donation record first + donation = Donation( + amount_cents=request.amount_cents, + donation_type=DonationType.member if current_user else DonationType.public, + user_id=current_user.id if current_user else None, + donor_email=current_user.email if current_user else None, + donor_name=f"{current_user.first_name} {current_user.last_name}" if current_user else None, + status=DonationStatus.pending + ) + db.add(donation) + db.commit() + db.refresh(donation) + # Create Stripe Checkout Session for one-time payment import stripe stripe.api_key = os.getenv("STRIPE_SECRET_KEY") @@ -4788,17 +5265,28 @@ async def create_donation_checkout( }], mode='payment', # One-time payment (not subscription) success_url=f"{frontend_url}/membership/donation-success?session_id={{CHECKOUT_SESSION_ID}}", - cancel_url=f"{frontend_url}/membership/donate" + cancel_url=f"{frontend_url}/membership/donate", + metadata={ + 'donation_id': str(donation.id), + 'donation_type': donation.donation_type.value, + 'user_id': str(current_user.id) if current_user else None + } ) - logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f}") + # Update donation with session ID + donation.stripe_checkout_session_id = checkout_session.id + db.commit() + + logger.info(f"Donation checkout created: ${request.amount_cents/100:.2f} (ID: {donation.id})") return {"checkout_url": checkout_session.url} except stripe.error.StripeError as e: + db.rollback() logger.error(f"Stripe error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail=f"Payment processing error: {str(e)}") except Exception as e: + db.rollback() logger.error(f"Error creating donation checkout: {str(e)}") raise HTTPException(status_code=500, detail="Failed to create donation checkout") @@ -4911,64 +5399,95 @@ async def stripe_webhook(request: Request, db: Session = Depends(get_db)): # Handle checkout.session.completed event if event["type"] == "checkout.session.completed": session = event["data"]["object"] + metadata = session.get("metadata", {}) - # Get metadata - user_id = session["metadata"].get("user_id") - plan_id = session["metadata"].get("plan_id") - base_amount = int(session["metadata"].get("base_amount", 0)) - donation_amount = int(session["metadata"].get("donation_amount", 0)) - total_amount = int(session["metadata"].get("total_amount", session.get("amount_total", 0))) - - if not user_id or not plan_id: - logger.error("Missing user_id or plan_id in webhook metadata") - return {"status": "error", "message": "Missing metadata"} - - # Get user and plan - user = db.query(User).filter(User.id == user_id).first() - plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() - - if user and plan: - # Check if subscription already exists (idempotency) - existing_subscription = db.query(Subscription).filter( - Subscription.stripe_subscription_id == session.get("subscription") - ).first() - - if not existing_subscription: - # Calculate subscription period using custom billing cycle if enabled - from payment_service import calculate_subscription_period - start_date, end_date = calculate_subscription_period(plan) - - # Create subscription record with donation tracking - subscription = Subscription( - user_id=user.id, - plan_id=plan.id, - stripe_subscription_id=session.get("subscription"), - stripe_customer_id=session.get("customer"), - status=SubscriptionStatus.active, - start_date=start_date, - end_date=end_date, - amount_paid_cents=total_amount, - base_subscription_cents=base_amount or plan.minimum_price_cents, - donation_cents=donation_amount, - payment_method="stripe" - ) - db.add(subscription) - - # Update user status and role - user.status = UserStatus.active - set_user_role(user, UserRole.member, db) - user.updated_at = datetime.now(timezone.utc) + # Check if this is a donation (has donation_id in metadata) + if "donation_id" in metadata: + donation_id = uuid.UUID(metadata["donation_id"]) + donation = db.query(Donation).filter(Donation.id == donation_id).first() + if donation: + donation.status = DonationStatus.completed + donation.stripe_payment_intent_id = session.get('payment_intent') + donation.payment_method = 'card' + donation.updated_at = datetime.now(timezone.utc) db.commit() - logger.info( - f"Subscription created for user {user.email}: " - f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}" - ) + # Send thank you email + try: + from email_service import send_donation_thank_you_email + donor_first_name = donation.donor_name.split()[0] if donation.donor_name else "Friend" + await send_donation_thank_you_email( + donation.donor_email, + donor_first_name, + donation.amount_cents + ) + except Exception as e: + logger.error(f"Failed to send donation thank you email: {str(e)}") + + logger.info(f"Donation completed: ${donation.amount_cents/100:.2f} (ID: {donation.id})") else: - logger.info(f"Subscription already exists for session {session.get('id')}") + logger.error(f"Donation not found: {donation_id}") + + # Otherwise handle subscription payment (existing logic) else: - logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}") + # Get metadata + user_id = metadata.get("user_id") + plan_id = metadata.get("plan_id") + base_amount = int(metadata.get("base_amount", 0)) + donation_amount = int(metadata.get("donation_amount", 0)) + total_amount = int(metadata.get("total_amount", session.get("amount_total", 0))) + + if not user_id or not plan_id: + logger.error("Missing user_id or plan_id in webhook metadata") + return {"status": "error", "message": "Missing metadata"} + + # Get user and plan + user = db.query(User).filter(User.id == user_id).first() + plan = db.query(SubscriptionPlan).filter(SubscriptionPlan.id == plan_id).first() + + if user and plan: + # Check if subscription already exists (idempotency) + existing_subscription = db.query(Subscription).filter( + Subscription.stripe_subscription_id == session.get("subscription") + ).first() + + if not existing_subscription: + # Calculate subscription period using custom billing cycle if enabled + from payment_service import calculate_subscription_period + start_date, end_date = calculate_subscription_period(plan) + + # Create subscription record with donation tracking + subscription = Subscription( + user_id=user.id, + plan_id=plan.id, + stripe_subscription_id=session.get("subscription"), + stripe_customer_id=session.get("customer"), + status=SubscriptionStatus.active, + start_date=start_date, + end_date=end_date, + amount_paid_cents=total_amount, + base_subscription_cents=base_amount or plan.minimum_price_cents, + donation_cents=donation_amount, + payment_method="stripe" + ) + db.add(subscription) + + # Update user status and role + user.status = UserStatus.active + set_user_role(user, UserRole.member, db) + user.updated_at = datetime.now(timezone.utc) + + db.commit() + + logger.info( + f"Subscription created for user {user.email}: " + f"${base_amount/100:.2f} base + ${donation_amount/100:.2f} donation = ${total_amount/100:.2f}" + ) + else: + logger.info(f"Subscription already exists for session {session.get('id')}") + else: + logger.error(f"User or plan not found: user_id={user_id}, plan_id={plan_id}") return {"status": "success"}