Update New Features

This commit is contained in:
Koncept Kit
2025-12-10 17:52:32 +07:00
parent 005c56b43d
commit f051976881
20 changed files with 2776 additions and 57 deletions

View File

@@ -31,3 +31,20 @@ FRONTEND_URL=http://localhost:3000
# Stripe Configuration (for future payment integration)
# STRIPE_SECRET_KEY=sk_test_...
# STRIPE_WEBHOOK_SECRET=whsec_...
# Cloudflare R2 Storage Configuration
R2_ACCOUNT_ID=your_r2_account_id
R2_ACCESS_KEY_ID=your_r2_access_key_id
R2_SECRET_ACCESS_KEY=your_r2_secret_access_key
R2_BUCKET_NAME=loaf-membership-storage
R2_PUBLIC_URL=https://your-r2-public-url.com
# Storage Limits (in bytes)
MAX_STORAGE_BYTES=10737418240 # 10GB default
MAX_FILE_SIZE_BYTES=52428800 # 50MB per file default
# Microsoft Calendar API Configuration
MS_CALENDAR_CLIENT_ID=your_microsoft_client_id
MS_CALENDAR_CLIENT_SECRET=your_microsoft_client_secret
MS_CALENDAR_TENANT_ID=your_microsoft_tenant_id
MS_CALENDAR_REDIRECT_URI=http://localhost:8000/membership/api/calendar/callback

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

127
calendar_service.py Normal file
View File

@@ -0,0 +1,127 @@
"""
Calendar Service for generating iCalendar (.ics) data
Implements RFC 5545 iCalendar format for universal calendar compatibility
"""
from icalendar import Calendar, Event as iCalEvent, Alarm
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import uuid
import os
class CalendarService:
"""Service for generating iCalendar (.ics) data compatible with all calendar apps"""
def __init__(self):
self.domain = os.getenv('CALENDAR_DOMAIN', 'loaf.community')
self.timezone = ZoneInfo(os.getenv('CALENDAR_TIMEZONE', 'America/New_York'))
def generate_event_uid(self) -> str:
"""
Generate unique event identifier (UUID4 hex-encoded per RFC 7986)
Returns:
str: Unique identifier in format {uuid}@{domain}
"""
return f"{uuid.uuid4().hex}@{self.domain}"
def event_to_ical_event(self, event, include_reminder: bool = True):
"""
Convert database Event model to iCalendar Event component
Args:
event: Event model instance from database
include_reminder: Whether to add 1-hour reminder alarm
Returns:
icalendar.Event: iCalendar event component
"""
ical_event = iCalEvent()
# Required properties
ical_event.add('uid', event.calendar_uid or self.generate_event_uid())
ical_event.add('dtstamp', datetime.now(self.timezone))
ical_event.add('dtstart', event.start_at)
ical_event.add('dtend', event.end_at)
ical_event.add('summary', event.title)
# Optional properties
if event.description:
ical_event.add('description', event.description)
if event.location:
ical_event.add('location', event.location)
# Metadata
ical_event.add('url', f"https://{self.domain}/events/{event.id}")
ical_event.add('status', 'CONFIRMED')
ical_event.add('sequence', 0)
# Add 1-hour reminder (VALARM component)
if include_reminder:
alarm = Alarm()
alarm.add('action', 'DISPLAY')
alarm.add('description', f"Reminder: {event.title}")
alarm.add('trigger', timedelta(hours=-1))
ical_event.add_component(alarm)
return ical_event
def create_calendar(self, name: str, description: str = None):
"""
Create base calendar with metadata
Args:
name: Calendar name (X-WR-CALNAME)
description: Optional calendar description
Returns:
icalendar.Calendar: Base calendar object
"""
cal = Calendar()
cal.add('prodid', '-//LOAF Membership Platform//EN')
cal.add('version', '2.0')
cal.add('x-wr-calname', name)
cal.add('x-wr-timezone', str(self.timezone))
if description:
cal.add('x-wr-caldesc', description)
cal.add('method', 'PUBLISH')
cal.add('calscale', 'GREGORIAN')
return cal
def create_single_event_calendar(self, event) -> bytes:
"""
Create calendar with single event for download
Args:
event: Event model instance
Returns:
bytes: iCalendar data as bytes
"""
cal = self.create_calendar(event.title)
ical_event = self.event_to_ical_event(event)
cal.add_component(ical_event)
return cal.to_ics()
def create_subscription_feed(self, events: list, feed_name: str) -> bytes:
"""
Create calendar subscription feed with multiple events
Args:
events: List of Event model instances
feed_name: Name for the calendar feed
Returns:
bytes: iCalendar data as bytes
"""
cal = self.create_calendar(
feed_name,
description="LOAF Community Events - Auto-syncing calendar feed"
)
for event in events:
ical_event = self.event_to_ical_event(event)
cal.add_component(ical_event)
return cal.to_ics()

View File

@@ -135,13 +135,13 @@ async def send_verification_email(to_email: str, token: str):
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #DDD8EB; color: #422268; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #FFFFFF; }}
</style>
</head>
<body>
@@ -156,7 +156,7 @@ async def send_verification_email(to_email: str, token: str):
<a href="{verification_url}" class="button">Verify Email</a>
</p>
<p>Or copy and paste this link into your browser:</p>
<p style="word-break: break-all; color: #6B708D;">{verification_url}</p>
<p style="word-break: break-all; color: #664fa3;">{verification_url}</p>
<p>This link will expire in 24 hours.</p>
<p>If you didn't create an account, please ignore this email.</p>
</div>
@@ -175,12 +175,13 @@ async def send_approval_notification(to_email: str, first_name: str):
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #DDD8EB; color: #422268; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #FFFFFF; }}
</style>
</head>
<body>
@@ -211,17 +212,17 @@ async def send_payment_prompt_email(to_email: str, first_name: str):
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.benefits {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #DDD8EB; color: #422268; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #FFFFFF; }}
.benefits {{ background: #f1eef9; padding: 20px; border-radius: 8px; margin: 20px 0; border: 2px solid #ddd8eb; }}
.benefits ul {{ list-style: none; padding: 0; }}
.benefits li {{ padding: 8px 0; padding-left: 25px; position: relative; }}
.benefits li:before {{ content: ""; position: absolute; left: 0; color: #E07A5F; font-weight: bold; }}
.benefits li:before {{ content: ""; position: absolute; left: 0; color: #ff9e77; font-weight: bold; }}
</style>
</head>
<body>
@@ -250,7 +251,7 @@ async def send_payment_prompt_email(to_email: str, first_name: str):
<p>We're excited to have you join the LOAF community!</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #E8E4DB; color: #6B708D; font-size: 14px;">
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd8eb; color: #664fa3; font-size: 14px;">
Questions? Contact us at support@loaf.org
</p>
</div>
@@ -269,14 +270,14 @@ async def send_password_reset_email(to_email: str, first_name: str, reset_url: s
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #DDD8EB; color: #422268; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #FFFFFF; }}
.note {{ background: #f1eef9; border-left: 4px solid #ff9e77; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
@@ -297,7 +298,7 @@ async def send_password_reset_email(to_email: str, first_name: str, reset_url: s
<p style="margin: 5px 0 0 0; font-size: 14px;">If you didn't request this, please ignore this email.</p>
</div>
<p style="margin-top: 20px; color: #6B708D; font-size: 14px;">
<p style="margin-top: 20px; color: #664fa3; font-size: 14px;">
Or copy and paste this link into your browser:<br>
<span style="word-break: break-all;">{reset_url}</span>
</p>
@@ -320,8 +321,8 @@ async def send_admin_password_reset_email(
force_change_text = (
"""
<div class="note" style="background: #FFEBEE; border-left: 4px solid #E07A5F;">
<p style="margin: 0; font-weight: bold; color: #E07A5F;">⚠️ You will be required to change this password when you log in.</p>
<div class="note" style="background: #FFEBEE; border-left: 4px solid #ff9e77;">
<p style="margin: 0; font-weight: bold; color: #ff9e77;">⚠️ You will be required to change this password when you log in.</p>
</div>
"""
) if force_change else ""
@@ -333,15 +334,15 @@ async def send_admin_password_reset_email(
<html>
<head>
<style>
body {{ font-family: 'DM Sans', Arial, sans-serif; line-height: 1.6; color: #3D405B; }}
body {{ font-family: 'Nunito Sans', Arial, sans-serif; line-height: 1.6; color: #422268; }}
.container {{ max-width: 600px; margin: 0 auto; padding: 20px; }}
.header {{ background: linear-gradient(135deg, #F2CC8F 0%, #E07A5F 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Fraunces', serif; }}
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #E07A5F; color: white; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #D0694E; }}
.password-box {{ background: #F5F5F5; padding: 20px; margin: 20px 0; border-left: 4px solid #E07A5F; font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; word-break: break-all; }}
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
.header {{ background: linear-gradient(135deg, #644c9f 0%, #48286e 100%); padding: 30px; text-align: center; border-radius: 10px 10px 0 0; }}
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
.content {{ background: #FFFFFF; padding: 30px; border-radius: 0 0 10px 10px; }}
.button {{ display: inline-block; background: #DDD8EB; color: #422268; padding: 15px 40px; text-decoration: none; border-radius: 50px; font-weight: 600; margin: 20px 0; }}
.button:hover {{ background: #FFFFFF; }}
.password-box {{ background: #f1eef9; padding: 20px; margin: 20px 0; border-left: 4px solid #ff9e77; font-family: 'Courier New', monospace; font-size: 18px; font-weight: bold; word-break: break-all; }}
.note {{ background: #f1eef9; border-left: 4px solid #ff9e77; padding: 15px; margin: 20px 0; }}
</style>
</head>
<body>
@@ -365,7 +366,7 @@ async def send_admin_password_reset_email(
<a href="{login_url}" class="button">Go to Login</a>
</p>
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #E8E4DB; color: #6B708D; font-size: 14px;">
<p style="margin-top: 30px; padding-top: 20px; border-top: 1px solid #ddd8eb; color: #664fa3; font-size: 14px;">
Questions? Contact us at support@loaf.org
</p>
</div>

138
migrations/README.md Normal file
View File

@@ -0,0 +1,138 @@
# Database Migrations
This folder contains SQL migration scripts for the membership platform.
## Running the Sprint 1-3 Migration
The `sprint_1_2_3_migration.sql` file adds all necessary columns and tables for the Members Only features (Sprints 1, 2, and 3).
### Prerequisites
- PostgreSQL installed and running
- Database created (e.g., `membership_db`)
- Database connection credentials from your `.env` file
### Option 1: Using psql command line
```bash
# Navigate to the migrations directory
cd /Users/andika/Documents/Works/Koncept\ Kit/KKN/membership-website/backend/migrations
# Run the migration (replace with your database credentials)
psql -U your_username -d membership_db -f sprint_1_2_3_migration.sql
# Or if you have a connection string
psql "postgresql://user:password@localhost:5432/membership_db" -f sprint_1_2_3_migration.sql
```
### Option 2: Using pgAdmin or another GUI tool
1. Open pgAdmin and connect to your database
2. Open the Query Tool
3. Load the `sprint_1_2_3_migration.sql` file
4. Execute the script
### Option 3: Using Python script
```bash
cd /Users/andika/Documents/Works/Koncept\ Kit/KKN/membership-website/backend
# Run the migration using Python
python3 -c "
import psycopg2
import os
from dotenv import load_dotenv
load_dotenv()
DATABASE_URL = os.getenv('DATABASE_URL')
conn = psycopg2.connect(DATABASE_URL)
cur = conn.cursor()
with open('migrations/sprint_1_2_3_migration.sql', 'r') as f:
sql = f.read()
cur.execute(sql)
conn.commit()
cur.close()
conn.close()
print('Migration completed successfully!')
"
```
### What Gets Added
**Users Table:**
- `profile_photo_url` - Stores Cloudflare R2 URL for profile photos
- `social_media_facebook` - Facebook profile/page URL
- `social_media_instagram` - Instagram handle or URL
- `social_media_twitter` - Twitter/X handle or URL
- `social_media_linkedin` - LinkedIn profile URL
**Events Table:**
- `microsoft_calendar_id` - Microsoft Calendar event ID for syncing
- `microsoft_calendar_sync_enabled` - Boolean flag for sync status
**New Tables:**
- `event_galleries` - Stores event photos with captions
- `newsletter_archives` - Stores newsletter documents
- `financial_reports` - Stores annual financial reports
- `bylaws_documents` - Stores organization bylaws
- `storage_usage` - Tracks Cloudflare R2 storage usage
### Verification
After running the migration, verify it worked:
```sql
-- Check users table columns
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name IN ('profile_photo_url', 'social_media_facebook');
-- Check new tables exist
SELECT table_name
FROM information_schema.tables
WHERE table_name IN ('event_galleries', 'storage_usage');
-- Check storage_usage has initial record
SELECT * FROM storage_usage;
```
### Troubleshooting
**Error: "relation does not exist"**
- Make sure you're connected to the correct database
- Verify the `users` and `events` tables exist first
**Error: "column already exists"**
- This is safe to ignore - the script uses `IF NOT EXISTS` clauses
**Error: "permission denied"**
- Make sure your database user has ALTER TABLE privileges
- You may need to run as a superuser or database owner
### Rollback (if needed)
If you need to undo the migration:
```sql
-- Remove new columns from users
ALTER TABLE users DROP COLUMN IF EXISTS profile_photo_url;
ALTER TABLE users DROP COLUMN IF EXISTS social_media_facebook;
ALTER TABLE users DROP COLUMN IF EXISTS social_media_instagram;
ALTER TABLE users DROP COLUMN IF EXISTS social_media_twitter;
ALTER TABLE users DROP COLUMN IF EXISTS social_media_linkedin;
-- Remove new columns from events
ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_id;
ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_sync_enabled;
-- Remove new tables
DROP TABLE IF EXISTS event_galleries;
DROP TABLE IF EXISTS newsletter_archives;
DROP TABLE IF EXISTS financial_reports;
DROP TABLE IF EXISTS bylaws_documents;
DROP TABLE IF EXISTS storage_usage;
```

View File

@@ -0,0 +1,38 @@
-- Migration: Add calendar_uid to events table and remove Microsoft Calendar columns
-- Sprint 2: Universal Calendar Export
-- Add new calendar_uid column
ALTER TABLE events ADD COLUMN IF NOT EXISTS calendar_uid VARCHAR;
-- Remove old Microsoft Calendar columns (if they exist)
ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_id;
ALTER TABLE events DROP COLUMN IF EXISTS microsoft_calendar_sync_enabled;
-- Verify migration
DO $$
BEGIN
-- Check if calendar_uid exists
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'calendar_uid'
) THEN
RAISE NOTICE '✅ calendar_uid column added successfully';
ELSE
RAISE NOTICE '⚠️ calendar_uid column not found';
END IF;
-- Check if old columns are removed
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'microsoft_calendar_id'
) THEN
RAISE NOTICE '✅ microsoft_calendar_id column removed';
END IF;
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'microsoft_calendar_sync_enabled'
) THEN
RAISE NOTICE '✅ microsoft_calendar_sync_enabled column removed';
END IF;
END $$;

161
migrations/complete_fix.sql Normal file
View File

@@ -0,0 +1,161 @@
-- Complete Fix for Sprint 1-3 Migration
-- Safe to run multiple times
-- ==============================================
-- Step 1: Add columns to users table
-- ==============================================
DO $$
BEGIN
-- Add profile_photo_url
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'profile_photo_url'
) THEN
ALTER TABLE users ADD COLUMN profile_photo_url VARCHAR;
RAISE NOTICE 'Added profile_photo_url to users table';
ELSE
RAISE NOTICE 'profile_photo_url already exists in users table';
END IF;
-- Add social_media_facebook
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'social_media_facebook'
) THEN
ALTER TABLE users ADD COLUMN social_media_facebook VARCHAR;
RAISE NOTICE 'Added social_media_facebook to users table';
ELSE
RAISE NOTICE 'social_media_facebook already exists in users table';
END IF;
-- Add social_media_instagram
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'social_media_instagram'
) THEN
ALTER TABLE users ADD COLUMN social_media_instagram VARCHAR;
RAISE NOTICE 'Added social_media_instagram to users table';
ELSE
RAISE NOTICE 'social_media_instagram already exists in users table';
END IF;
-- Add social_media_twitter
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'social_media_twitter'
) THEN
ALTER TABLE users ADD COLUMN social_media_twitter VARCHAR;
RAISE NOTICE 'Added social_media_twitter to users table';
ELSE
RAISE NOTICE 'social_media_twitter already exists in users table';
END IF;
-- Add social_media_linkedin
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'users' AND column_name = 'social_media_linkedin'
) THEN
ALTER TABLE users ADD COLUMN social_media_linkedin VARCHAR;
RAISE NOTICE 'Added social_media_linkedin to users table';
ELSE
RAISE NOTICE 'social_media_linkedin already exists in users table';
END IF;
END $$;
-- ==============================================
-- Step 2: Add columns to events table
-- ==============================================
DO $$
BEGIN
-- Add microsoft_calendar_id
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'microsoft_calendar_id'
) THEN
ALTER TABLE events ADD COLUMN microsoft_calendar_id VARCHAR;
RAISE NOTICE 'Added microsoft_calendar_id to events table';
ELSE
RAISE NOTICE 'microsoft_calendar_id already exists in events table';
END IF;
-- Add microsoft_calendar_sync_enabled
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'events' AND column_name = 'microsoft_calendar_sync_enabled'
) THEN
ALTER TABLE events ADD COLUMN microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE;
RAISE NOTICE 'Added microsoft_calendar_sync_enabled to events table';
ELSE
RAISE NOTICE 'microsoft_calendar_sync_enabled already exists in events table';
END IF;
END $$;
-- ==============================================
-- Step 3: Fix storage_usage initialization
-- ==============================================
-- Delete any incomplete records
DELETE FROM storage_usage WHERE id IS NULL;
-- Insert initial record if table is empty
INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated)
SELECT
gen_random_uuid(),
0,
10737418240, -- 10GB default
CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
-- ==============================================
-- Step 4: Verify everything
-- ==============================================
DO $$
DECLARE
user_col_count INT;
event_col_count INT;
storage_count INT;
BEGIN
-- Count users columns
SELECT COUNT(*) INTO user_col_count
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name IN (
'profile_photo_url',
'social_media_facebook',
'social_media_instagram',
'social_media_twitter',
'social_media_linkedin'
);
-- Count events columns
SELECT COUNT(*) INTO event_col_count
FROM information_schema.columns
WHERE table_name = 'events'
AND column_name IN (
'microsoft_calendar_id',
'microsoft_calendar_sync_enabled'
);
-- Count storage_usage records
SELECT COUNT(*) INTO storage_count FROM storage_usage;
-- Report results
RAISE NOTICE '';
RAISE NOTICE '========================================';
RAISE NOTICE 'Migration Verification Results:';
RAISE NOTICE '========================================';
RAISE NOTICE 'Users table: %/5 columns added', user_col_count;
RAISE NOTICE 'Events table: %/2 columns added', event_col_count;
RAISE NOTICE 'Storage usage: % record(s)', storage_count;
RAISE NOTICE '';
IF user_col_count = 5 AND event_col_count = 2 AND storage_count > 0 THEN
RAISE NOTICE '✅ Migration completed successfully!';
ELSE
RAISE NOTICE '⚠️ Migration incomplete. Please check the logs above.';
END IF;
RAISE NOTICE '========================================';
END $$;

View File

@@ -0,0 +1,17 @@
-- Fix storage_usage table initialization
-- This script safely initializes storage_usage if empty
-- Delete any incomplete records first
DELETE FROM storage_usage WHERE id IS NULL;
-- Insert with explicit UUID generation if table is empty
INSERT INTO storage_usage (id, total_bytes_used, max_bytes_allowed, last_updated)
SELECT
gen_random_uuid(),
0,
10737418240, -- 10GB default
CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
-- Verify the record was created
SELECT * FROM storage_usage;

View File

@@ -0,0 +1,117 @@
-- Sprint 1, 2, 3 Database Migration
-- This script adds all new columns and tables for Members Only features
-- ==============================================
-- Step 1: Add new columns to users table
-- ==============================================
-- Add profile photo and social media columns (Sprint 1)
ALTER TABLE users ADD COLUMN IF NOT EXISTS profile_photo_url VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_facebook VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_instagram VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_twitter VARCHAR;
ALTER TABLE users ADD COLUMN IF NOT EXISTS social_media_linkedin VARCHAR;
-- ==============================================
-- Step 2: Add new columns to events table
-- ==============================================
-- Add Microsoft Calendar integration columns (Sprint 2)
ALTER TABLE events ADD COLUMN IF NOT EXISTS microsoft_calendar_id VARCHAR;
ALTER TABLE events ADD COLUMN IF NOT EXISTS microsoft_calendar_sync_enabled BOOLEAN DEFAULT FALSE;
-- ==============================================
-- Step 3: Create new tables
-- ==============================================
-- EventGallery table (Sprint 3)
CREATE TABLE IF NOT EXISTS event_galleries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id UUID NOT NULL REFERENCES events(id) ON DELETE CASCADE,
image_url VARCHAR NOT NULL,
image_key VARCHAR NOT NULL,
caption TEXT,
uploaded_by UUID NOT NULL REFERENCES users(id),
file_size_bytes INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Create index for faster queries
CREATE INDEX IF NOT EXISTS idx_event_galleries_event_id ON event_galleries(event_id);
CREATE INDEX IF NOT EXISTS idx_event_galleries_uploaded_by ON event_galleries(uploaded_by);
-- NewsletterArchive table (Sprint 4 - preparing ahead)
CREATE TABLE IF NOT EXISTS newsletter_archives (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR NOT NULL,
description TEXT,
published_date TIMESTAMP WITH TIME ZONE NOT NULL,
document_url VARCHAR NOT NULL,
document_type VARCHAR DEFAULT 'google_docs',
file_size_bytes INTEGER,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_newsletter_archives_published_date ON newsletter_archives(published_date DESC);
CREATE INDEX IF NOT EXISTS idx_newsletter_archives_created_by ON newsletter_archives(created_by);
-- FinancialReport table (Sprint 4 - preparing ahead)
CREATE TABLE IF NOT EXISTS financial_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
year INTEGER NOT NULL,
title VARCHAR NOT NULL,
document_url VARCHAR NOT NULL,
document_type VARCHAR DEFAULT 'google_drive',
file_size_bytes INTEGER,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_financial_reports_year ON financial_reports(year DESC);
CREATE INDEX IF NOT EXISTS idx_financial_reports_created_by ON financial_reports(created_by);
-- BylawsDocument table (Sprint 4 - preparing ahead)
CREATE TABLE IF NOT EXISTS bylaws_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title VARCHAR NOT NULL,
version VARCHAR NOT NULL,
effective_date TIMESTAMP WITH TIME ZONE NOT NULL,
document_url VARCHAR NOT NULL,
document_type VARCHAR DEFAULT 'google_drive',
file_size_bytes INTEGER,
is_current BOOLEAN DEFAULT TRUE,
created_by UUID NOT NULL REFERENCES users(id),
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_bylaws_documents_is_current ON bylaws_documents(is_current);
CREATE INDEX IF NOT EXISTS idx_bylaws_documents_created_by ON bylaws_documents(created_by);
-- StorageUsage table (Sprint 1)
CREATE TABLE IF NOT EXISTS storage_usage (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
total_bytes_used BIGINT DEFAULT 0,
max_bytes_allowed BIGINT NOT NULL,
last_updated TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
-- Insert initial storage usage record
INSERT INTO storage_usage (total_bytes_used, max_bytes_allowed)
SELECT 0, 10737418240
WHERE NOT EXISTS (SELECT 1 FROM storage_usage);
-- ==============================================
-- Migration Complete
-- ==============================================
-- Verify migrations
DO $$
BEGIN
RAISE NOTICE 'Migration completed successfully!';
RAISE NOTICE 'New columns added to users table: profile_photo_url, social_media_*';
RAISE NOTICE 'New columns added to events table: microsoft_calendar_*';
RAISE NOTICE 'New tables created: event_galleries, newsletter_archives, financial_reports, bylaws_documents, storage_usage';
END $$;

View File

@@ -0,0 +1,54 @@
-- Verification script to check which columns exist
-- Run this to see what's missing
-- Check users table columns
SELECT
'users' as table_name,
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_name = 'users'
AND column_name IN (
'profile_photo_url',
'social_media_facebook',
'social_media_instagram',
'social_media_twitter',
'social_media_linkedin'
)
ORDER BY column_name;
-- Check events table columns
SELECT
'events' as table_name,
column_name,
data_type,
is_nullable
FROM information_schema.columns
WHERE table_name = 'events'
AND column_name IN (
'microsoft_calendar_id',
'microsoft_calendar_sync_enabled'
)
ORDER BY column_name;
-- Check which tables exist
SELECT
table_name,
'EXISTS' as status
FROM information_schema.tables
WHERE table_name IN (
'event_galleries',
'newsletter_archives',
'financial_reports',
'bylaws_documents',
'storage_usage'
)
ORDER BY table_name;
-- Check storage_usage contents
SELECT
COUNT(*) as record_count,
SUM(total_bytes_used) as total_bytes,
MAX(max_bytes_allowed) as max_bytes
FROM storage_usage;

View File

@@ -1,4 +1,4 @@
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, ForeignKey, JSON
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
@@ -82,6 +82,13 @@ class User(Base):
password_reset_expires = Column(DateTime, nullable=True)
force_password_change = Column(Boolean, default=False, nullable=False)
# Members Only - Profile Photo & Social Media
profile_photo_url = Column(String, nullable=True) # Cloudflare R2 URL
social_media_facebook = Column(String, nullable=True)
social_media_instagram = Column(String, nullable=True)
social_media_twitter = Column(String, nullable=True)
social_media_linkedin = Column(String, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
@@ -102,12 +109,17 @@ class Event(Base):
capacity = Column(Integer, nullable=True)
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
published = Column(Boolean, default=False)
# Members Only - Universal Calendar Export
calendar_uid = Column(String, nullable=True) # Unique iCalendar UID (UUID4-based)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User", back_populates="events_created")
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
gallery_images = relationship("EventGallery", back_populates="event", cascade="all, delete-orphan")
class EventRSVP(Base):
__tablename__ = "event_rsvps"
@@ -167,3 +179,77 @@ class Subscription(Base):
# Relationships
user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
plan = relationship("SubscriptionPlan", back_populates="subscriptions")
class EventGallery(Base):
__tablename__ = "event_galleries"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
event_id = Column(UUID(as_uuid=True), ForeignKey("events.id"), nullable=False)
image_url = Column(String, nullable=False) # Cloudflare R2 URL
image_key = Column(String, nullable=False) # R2 object key for deletion
caption = Column(Text, nullable=True)
uploaded_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
file_size_bytes = Column(Integer, nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
event = relationship("Event", back_populates="gallery_images")
uploader = relationship("User")
class NewsletterArchive(Base):
__tablename__ = "newsletter_archives"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String, nullable=False)
description = Column(Text, nullable=True)
published_date = Column(DateTime, nullable=False)
document_url = Column(String, nullable=False) # Google Docs URL or R2 URL
document_type = Column(String, default="google_docs") # google_docs, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class FinancialReport(Base):
__tablename__ = "financial_reports"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
year = Column(Integer, nullable=False)
title = Column(String, nullable=False) # e.g., "2024 Annual Report"
document_url = Column(String, nullable=False) # Google Drive URL or R2 URL
document_type = Column(String, default="google_drive") # google_drive, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class BylawsDocument(Base):
__tablename__ = "bylaws_documents"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
title = Column(String, nullable=False)
version = Column(String, nullable=False) # e.g., "v1.0", "v2.0"
effective_date = Column(DateTime, nullable=False)
document_url = Column(String, nullable=False) # Google Drive URL or R2 URL
document_type = Column(String, default="google_drive") # google_drive, pdf, upload
file_size_bytes = Column(Integer, nullable=True) # For uploaded files
is_current = Column(Boolean, default=True) # Only one should be current
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
# Relationships
creator = relationship("User")
class StorageUsage(Base):
__tablename__ = "storage_usage"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
total_bytes_used = Column(BigInteger, default=0)
max_bytes_allowed = Column(BigInteger, nullable=False) # From .env
last_updated = Column(DateTime, default=lambda: datetime.now(timezone.utc))

320
ms_calendar_service.py Normal file
View File

@@ -0,0 +1,320 @@
"""
Microsoft Calendar Service
Handles OAuth2 authentication and event synchronization with Microsoft Graph API
"""
from msal import ConfidentialClientApplication
import requests
import os
from datetime import datetime, timezone
from typing import Optional, Dict, Any
from fastapi import HTTPException
class MSCalendarService:
"""
Microsoft Calendar Service using MSAL and Microsoft Graph API
"""
def __init__(self):
"""Initialize MSAL client with credentials from environment"""
self.client_id = os.getenv('MS_CALENDAR_CLIENT_ID')
self.client_secret = os.getenv('MS_CALENDAR_CLIENT_SECRET')
self.tenant_id = os.getenv('MS_CALENDAR_TENANT_ID')
self.redirect_uri = os.getenv('MS_CALENDAR_REDIRECT_URI')
if not all([self.client_id, self.client_secret, self.tenant_id]):
raise ValueError("Microsoft Calendar credentials not properly configured in environment variables")
# Initialize MSAL Confidential Client
self.app = ConfidentialClientApplication(
client_id=self.client_id,
client_credential=self.client_secret,
authority=f"https://login.microsoftonline.com/{self.tenant_id}"
)
# Microsoft Graph API endpoints
self.graph_url = "https://graph.microsoft.com/v1.0"
self.scopes = ["https://graph.microsoft.com/.default"]
def get_access_token(self) -> str:
"""
Get access token using client credentials flow
Returns:
str: Access token for Microsoft Graph API
Raises:
HTTPException: If token acquisition fails
"""
try:
result = self.app.acquire_token_for_client(scopes=self.scopes)
if "access_token" in result:
return result["access_token"]
else:
error = result.get("error_description", "Unknown error")
raise HTTPException(
status_code=500,
detail=f"Failed to acquire access token: {error}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Microsoft authentication error: {str(e)}"
)
def _make_graph_request(
self,
method: str,
endpoint: str,
data: Optional[Dict[Any, Any]] = None
) -> Dict[Any, Any]:
"""
Make an authenticated request to Microsoft Graph API
Args:
method: HTTP method (GET, POST, PATCH, DELETE)
endpoint: API endpoint path (e.g., "/me/events")
data: Request body data
Returns:
Dict: Response JSON
Raises:
HTTPException: If request fails
"""
token = self.get_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json"
}
url = f"{self.graph_url}{endpoint}"
try:
if method.upper() == "GET":
response = requests.get(url, headers=headers)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, json=data)
elif method.upper() == "PATCH":
response = requests.patch(url, headers=headers, json=data)
elif method.upper() == "DELETE":
response = requests.delete(url, headers=headers)
else:
raise ValueError(f"Unsupported HTTP method: {method}")
response.raise_for_status()
# DELETE requests may return 204 No Content
if response.status_code == 204:
return {}
return response.json()
except requests.exceptions.HTTPError as e:
raise HTTPException(
status_code=e.response.status_code,
detail=f"Microsoft Graph API error: {e.response.text}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Request to Microsoft Graph failed: {str(e)}"
)
async def create_event(
self,
title: str,
description: str,
location: str,
start_at: datetime,
end_at: datetime,
calendar_id: str = "primary"
) -> str:
"""
Create an event in Microsoft Calendar
Args:
title: Event title
description: Event description
location: Event location
start_at: Event start datetime (timezone-aware)
end_at: Event end datetime (timezone-aware)
calendar_id: Calendar ID (default: "primary")
Returns:
str: Microsoft Calendar Event ID
Raises:
HTTPException: If event creation fails
"""
event_data = {
"subject": title,
"body": {
"contentType": "HTML",
"content": description or ""
},
"start": {
"dateTime": start_at.isoformat(),
"timeZone": "UTC"
},
"end": {
"dateTime": end_at.isoformat(),
"timeZone": "UTC"
},
"location": {
"displayName": location
}
}
# Use /me/events for primary calendar or /me/calendars/{id}/events for specific calendar
endpoint = "/me/events" if calendar_id == "primary" else f"/me/calendars/{calendar_id}/events"
result = self._make_graph_request("POST", endpoint, event_data)
return result.get("id")
async def update_event(
self,
event_id: str,
title: Optional[str] = None,
description: Optional[str] = None,
location: Optional[str] = None,
start_at: Optional[datetime] = None,
end_at: Optional[datetime] = None
) -> bool:
"""
Update an existing event in Microsoft Calendar
Args:
event_id: Microsoft Calendar Event ID
title: Updated event title (optional)
description: Updated description (optional)
location: Updated location (optional)
start_at: Updated start datetime (optional)
end_at: Updated end datetime (optional)
Returns:
bool: True if successful
Raises:
HTTPException: If update fails
"""
event_data = {}
if title:
event_data["subject"] = title
if description is not None:
event_data["body"] = {
"contentType": "HTML",
"content": description
}
if location:
event_data["location"] = {"displayName": location}
if start_at:
event_data["start"] = {
"dateTime": start_at.isoformat(),
"timeZone": "UTC"
}
if end_at:
event_data["end"] = {
"dateTime": end_at.isoformat(),
"timeZone": "UTC"
}
if not event_data:
return True # Nothing to update
endpoint = f"/me/events/{event_id}"
self._make_graph_request("PATCH", endpoint, event_data)
return True
async def delete_event(self, event_id: str) -> bool:
"""
Delete an event from Microsoft Calendar
Args:
event_id: Microsoft Calendar Event ID
Returns:
bool: True if successful
Raises:
HTTPException: If deletion fails
"""
endpoint = f"/me/events/{event_id}"
self._make_graph_request("DELETE", endpoint)
return True
async def get_event(self, event_id: str) -> Dict[Any, Any]:
"""
Get event details from Microsoft Calendar
Args:
event_id: Microsoft Calendar Event ID
Returns:
Dict: Event details
Raises:
HTTPException: If retrieval fails
"""
endpoint = f"/me/events/{event_id}"
return self._make_graph_request("GET", endpoint)
async def sync_event(
self,
loaf_event,
existing_ms_event_id: Optional[str] = None
) -> str:
"""
Sync a LOAF event to Microsoft Calendar
Creates new event if existing_ms_event_id is None, otherwise updates
Args:
loaf_event: SQLAlchemy Event model instance
existing_ms_event_id: Existing Microsoft Calendar Event ID (optional)
Returns:
str: Microsoft Calendar Event ID
Raises:
HTTPException: If sync fails
"""
if existing_ms_event_id:
# Update existing event
await self.update_event(
event_id=existing_ms_event_id,
title=loaf_event.title,
description=loaf_event.description,
location=loaf_event.location,
start_at=loaf_event.start_at,
end_at=loaf_event.end_at
)
return existing_ms_event_id
else:
# Create new event
return await self.create_event(
title=loaf_event.title,
description=loaf_event.description or "",
location=loaf_event.location,
start_at=loaf_event.start_at,
end_at=loaf_event.end_at
)
# Singleton instance
_ms_calendar = None
def get_ms_calendar_service() -> MSCalendarService:
"""
Get singleton instance of MSCalendarService
Returns:
MSCalendarService: Initialized Microsoft Calendar service
"""
global _ms_calendar
if _ms_calendar is None:
_ms_calendar = MSCalendarService()
return _ms_calendar

243
r2_storage.py Normal file
View File

@@ -0,0 +1,243 @@
"""
Cloudflare R2 Storage Service
Handles file uploads, downloads, and deletions using S3-compatible API
"""
import boto3
from botocore.client import Config
from botocore.exceptions import ClientError
import os
import uuid
import magic
from typing import Optional, BinaryIO
from fastapi import UploadFile, HTTPException
from pathlib import Path
class R2Storage:
"""
Cloudflare R2 Storage Service using S3-compatible API
"""
# Allowed MIME types for uploads
ALLOWED_IMAGE_TYPES = {
'image/jpeg': ['.jpg', '.jpeg'],
'image/png': ['.png'],
'image/webp': ['.webp'],
'image/gif': ['.gif']
}
ALLOWED_DOCUMENT_TYPES = {
'application/pdf': ['.pdf'],
'application/msword': ['.doc'],
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'],
'application/vnd.ms-excel': ['.xls'],
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx']
}
def __init__(self):
"""Initialize R2 client with credentials from environment"""
self.account_id = os.getenv('R2_ACCOUNT_ID')
self.access_key = os.getenv('R2_ACCESS_KEY_ID')
self.secret_key = os.getenv('R2_SECRET_ACCESS_KEY')
self.bucket_name = os.getenv('R2_BUCKET_NAME')
self.public_url = os.getenv('R2_PUBLIC_URL')
if not all([self.account_id, self.access_key, self.secret_key, self.bucket_name]):
raise ValueError("R2 credentials not properly configured in environment variables")
# Initialize S3 client for R2
self.client = boto3.client(
's3',
endpoint_url=f'https://{self.account_id}.r2.cloudflarestorage.com',
aws_access_key_id=self.access_key,
aws_secret_access_key=self.secret_key,
config=Config(signature_version='s3v4'),
)
async def upload_file(
self,
file: UploadFile,
folder: str,
allowed_types: Optional[dict] = None,
max_size_bytes: Optional[int] = None
) -> tuple[str, str, int]:
"""
Upload a file to R2 storage
Args:
file: FastAPI UploadFile object
folder: Folder path in R2 (e.g., 'profiles', 'gallery/event-id')
allowed_types: Dict of allowed MIME types and extensions
max_size_bytes: Maximum file size in bytes
Returns:
tuple: (public_url, object_key, file_size_bytes)
Raises:
HTTPException: If upload fails or file is invalid
"""
try:
# Read file content
content = await file.read()
file_size = len(content)
# Check file size
if max_size_bytes and file_size > max_size_bytes:
max_mb = max_size_bytes / (1024 * 1024)
actual_mb = file_size / (1024 * 1024)
raise HTTPException(
status_code=413,
detail=f"File too large: {actual_mb:.2f}MB exceeds limit of {max_mb:.2f}MB"
)
# Detect MIME type
mime = magic.from_buffer(content, mime=True)
# Validate MIME type
if allowed_types and mime not in allowed_types:
allowed_list = ', '.join(allowed_types.keys())
raise HTTPException(
status_code=400,
detail=f"Invalid file type: {mime}. Allowed types: {allowed_list}"
)
# Generate unique filename
file_extension = Path(file.filename).suffix.lower()
if not file_extension and allowed_types and mime in allowed_types:
file_extension = allowed_types[mime][0]
unique_filename = f"{uuid.uuid4()}{file_extension}"
object_key = f"{folder}/{unique_filename}"
# Upload to R2
self.client.put_object(
Bucket=self.bucket_name,
Key=object_key,
Body=content,
ContentType=mime,
ContentLength=file_size
)
# Generate public URL
public_url = self.get_public_url(object_key)
return public_url, object_key, file_size
except ClientError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to upload file to R2: {str(e)}"
)
except Exception as e:
raise HTTPException(
status_code=500,
detail=f"Upload error: {str(e)}"
)
async def delete_file(self, object_key: str) -> bool:
"""
Delete a file from R2 storage
Args:
object_key: The S3 object key (path) of the file
Returns:
bool: True if successful
Raises:
HTTPException: If deletion fails
"""
try:
self.client.delete_object(
Bucket=self.bucket_name,
Key=object_key
)
return True
except ClientError as e:
raise HTTPException(
status_code=500,
detail=f"Failed to delete file from R2: {str(e)}"
)
def get_public_url(self, object_key: str) -> str:
"""
Generate public URL for an R2 object
Args:
object_key: The S3 object key (path) of the file
Returns:
str: Public URL
"""
if self.public_url:
# Use custom domain if configured
return f"{self.public_url}/{object_key}"
else:
# Use default R2 public URL
return f"https://{self.bucket_name}.{self.account_id}.r2.cloudflarestorage.com/{object_key}"
async def get_file_size(self, object_key: str) -> int:
"""
Get the size of a file in R2
Args:
object_key: The S3 object key (path) of the file
Returns:
int: File size in bytes
Raises:
HTTPException: If file not found
"""
try:
response = self.client.head_object(
Bucket=self.bucket_name,
Key=object_key
)
return response['ContentLength']
except ClientError as e:
if e.response['Error']['Code'] == '404':
raise HTTPException(status_code=404, detail="File not found")
raise HTTPException(
status_code=500,
detail=f"Failed to get file info: {str(e)}"
)
async def file_exists(self, object_key: str) -> bool:
"""
Check if a file exists in R2
Args:
object_key: The S3 object key (path) of the file
Returns:
bool: True if file exists, False otherwise
"""
try:
self.client.head_object(
Bucket=self.bucket_name,
Key=object_key
)
return True
except ClientError:
return False
# Singleton instance
_r2_storage = None
def get_r2_storage() -> R2Storage:
"""
Get singleton instance of R2Storage
Returns:
R2Storage: Initialized R2 storage service
"""
global _r2_storage
if _r2_storage is None:
_r2_storage = R2Storage()
return _r2_storage

View File

@@ -9,7 +9,7 @@ certifi==2025.11.12
cffi==2.0.0
charset-normalizer==3.4.4
click==8.3.1
cryptography==46.0.3
cryptography==44.0.0
dnspython==2.8.0
ecdsa==0.19.1
email-validator==2.3.0
@@ -17,6 +17,7 @@ fastapi==0.110.1
flake8==7.3.0
greenlet==3.2.4
h11==0.16.0
icalendar==6.0.1
idna==3.11
iniconfig==2.3.0
isort==7.0.0
@@ -26,6 +27,7 @@ markdown-it-py==4.0.0
mccabe==0.7.0
mdurl==0.1.2
motor==3.3.1
msal==1.27.0
mypy==1.18.2
mypy_extensions==1.1.0
numpy==2.3.5
@@ -34,6 +36,7 @@ packaging==25.0
pandas==2.3.3
passlib==1.7.4
pathspec==0.12.1
pillow==10.2.0
platformdirs==4.5.0
pluggy==1.6.0
psycopg2-binary==2.9.11
@@ -50,6 +53,7 @@ pytest==9.0.1
python-dateutil==2.9.0.post0
python-dotenv==1.2.1
python-jose==3.5.0
python-magic==0.4.27
python-multipart==0.0.20
pytokens==0.3.0
pytz==2025.2

1404
server.py

File diff suppressed because it is too large Load Diff