forked from andika/membership-be
Update New Features
This commit is contained in:
17
.env.example
17
.env.example
@@ -31,3 +31,20 @@ FRONTEND_URL=http://localhost:3000
|
|||||||
# Stripe Configuration (for future payment integration)
|
# Stripe Configuration (for future payment integration)
|
||||||
# STRIPE_SECRET_KEY=sk_test_...
|
# STRIPE_SECRET_KEY=sk_test_...
|
||||||
# STRIPE_WEBHOOK_SECRET=whsec_...
|
# 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
|
||||||
|
|||||||
BIN
__pycache__/calendar_service.cpython-312.pyc
Normal file
BIN
__pycache__/calendar_service.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
__pycache__/ms_calendar_service.cpython-312.pyc
Normal file
BIN
__pycache__/ms_calendar_service.cpython-312.pyc
Normal file
Binary file not shown.
BIN
__pycache__/r2_storage.cpython-312.pyc
Normal file
BIN
__pycache__/r2_storage.cpython-312.pyc
Normal file
Binary file not shown.
Binary file not shown.
127
calendar_service.py
Normal file
127
calendar_service.py
Normal 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()
|
||||||
@@ -135,13 +135,13 @@ async def send_verification_email(to_email: str, token: str):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<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; }}
|
.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 {{ 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: 'Fraunces', serif; }}
|
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
|
||||||
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
.content {{ background: #FFFFFF; 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 {{ 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: #D0694E; }}
|
.button:hover {{ background: #FFFFFF; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -156,7 +156,7 @@ async def send_verification_email(to_email: str, token: str):
|
|||||||
<a href="{verification_url}" class="button">Verify Email</a>
|
<a href="{verification_url}" class="button">Verify Email</a>
|
||||||
</p>
|
</p>
|
||||||
<p>Or copy and paste this link into your browser:</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>This link will expire in 24 hours.</p>
|
||||||
<p>If you didn't create an account, please ignore this email.</p>
|
<p>If you didn't create an account, please ignore this email.</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -175,12 +175,13 @@ async def send_approval_notification(to_email: str, first_name: str):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<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; }}
|
.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 {{ 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: 'Fraunces', serif; }}
|
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
|
||||||
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
.content {{ background: #FFFFFF; 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 {{ 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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -211,17 +212,17 @@ async def send_payment_prompt_email(to_email: str, first_name: str):
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<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; }}
|
.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 {{ 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: 'Fraunces', serif; }}
|
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
|
||||||
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
.content {{ background: #FFFFFF; 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 {{ 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: #D0694E; }}
|
.button:hover {{ background: #FFFFFF; }}
|
||||||
.benefits {{ background: white; padding: 20px; border-radius: 8px; margin: 20px 0; }}
|
.benefits {{ background: #f1eef9; padding: 20px; border-radius: 8px; margin: 20px 0; border: 2px solid #ddd8eb; }}
|
||||||
.benefits ul {{ list-style: none; padding: 0; }}
|
.benefits ul {{ list-style: none; padding: 0; }}
|
||||||
.benefits li {{ padding: 8px 0; padding-left: 25px; position: relative; }}
|
.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>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>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
|
Questions? Contact us at support@loaf.org
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -269,14 +270,14 @@ async def send_password_reset_email(to_email: str, first_name: str, reset_url: s
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<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; }}
|
.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 {{ 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: 'Fraunces', serif; }}
|
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
|
||||||
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
.content {{ background: #FFFFFF; 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 {{ 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: #D0694E; }}
|
.button:hover {{ background: #FFFFFF; }}
|
||||||
.note {{ background: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
|
.note {{ background: #f1eef9; border-left: 4px solid #ff9e77; padding: 15px; margin: 20px 0; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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>
|
<p style="margin: 5px 0 0 0; font-size: 14px;">If you didn't request this, please ignore this email.</p>
|
||||||
</div>
|
</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>
|
Or copy and paste this link into your browser:<br>
|
||||||
<span style="word-break: break-all;">{reset_url}</span>
|
<span style="word-break: break-all;">{reset_url}</span>
|
||||||
</p>
|
</p>
|
||||||
@@ -320,8 +321,8 @@ async def send_admin_password_reset_email(
|
|||||||
|
|
||||||
force_change_text = (
|
force_change_text = (
|
||||||
"""
|
"""
|
||||||
<div class="note" style="background: #FFEBEE; border-left: 4px solid #E07A5F;">
|
<div class="note" style="background: #FFEBEE; border-left: 4px solid #ff9e77;">
|
||||||
<p style="margin: 0; font-weight: bold; color: #E07A5F;">⚠️ You will be required to change this password when you log in.</p>
|
<p style="margin: 0; font-weight: bold; color: #ff9e77;">⚠️ You will be required to change this password when you log in.</p>
|
||||||
</div>
|
</div>
|
||||||
"""
|
"""
|
||||||
) if force_change else ""
|
) if force_change else ""
|
||||||
@@ -333,15 +334,15 @@ async def send_admin_password_reset_email(
|
|||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<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; }}
|
.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 {{ 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: 'Fraunces', serif; }}
|
.header h1 {{ color: white; margin: 0; font-family: 'Inter', sans-serif; }}
|
||||||
.content {{ background: #FDFCF8; padding: 30px; border-radius: 0 0 10px 10px; }}
|
.content {{ background: #FFFFFF; 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 {{ 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: #D0694E; }}
|
.button:hover {{ background: #FFFFFF; }}
|
||||||
.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; }}
|
.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: #FFF3E0; border-left: 4px solid #E07A5F; padding: 15px; margin: 20px 0; }}
|
.note {{ background: #f1eef9; border-left: 4px solid #ff9e77; padding: 15px; margin: 20px 0; }}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
@@ -365,7 +366,7 @@ async def send_admin_password_reset_email(
|
|||||||
<a href="{login_url}" class="button">Go to Login</a>
|
<a href="{login_url}" class="button">Go to Login</a>
|
||||||
</p>
|
</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
|
Questions? Contact us at support@loaf.org
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
138
migrations/README.md
Normal file
138
migrations/README.md
Normal 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;
|
||||||
|
```
|
||||||
38
migrations/add_calendar_uid.sql
Normal file
38
migrations/add_calendar_uid.sql
Normal 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
161
migrations/complete_fix.sql
Normal 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 $$;
|
||||||
17
migrations/fix_storage_usage.sql
Normal file
17
migrations/fix_storage_usage.sql
Normal 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;
|
||||||
117
migrations/sprint_1_2_3_migration.sql
Normal file
117
migrations/sprint_1_2_3_migration.sql
Normal 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 $$;
|
||||||
54
migrations/verify_columns.sql
Normal file
54
migrations/verify_columns.sql
Normal 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;
|
||||||
92
models.py
92
models.py
@@ -1,4 +1,4 @@
|
|||||||
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, ForeignKey, JSON
|
from sqlalchemy import Column, String, Boolean, DateTime, Enum as SQLEnum, Text, Integer, BigInteger, ForeignKey, JSON
|
||||||
from sqlalchemy.dialects.postgresql import UUID
|
from sqlalchemy.dialects.postgresql import UUID
|
||||||
from sqlalchemy.orm import relationship
|
from sqlalchemy.orm import relationship
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
@@ -82,6 +82,13 @@ class User(Base):
|
|||||||
password_reset_expires = Column(DateTime, nullable=True)
|
password_reset_expires = Column(DateTime, nullable=True)
|
||||||
force_password_change = Column(Boolean, default=False, nullable=False)
|
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))
|
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))
|
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
@@ -92,7 +99,7 @@ class User(Base):
|
|||||||
|
|
||||||
class Event(Base):
|
class Event(Base):
|
||||||
__tablename__ = "events"
|
__tablename__ = "events"
|
||||||
|
|
||||||
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||||
title = Column(String, nullable=False)
|
title = Column(String, nullable=False)
|
||||||
description = Column(Text, nullable=True)
|
description = Column(Text, nullable=True)
|
||||||
@@ -102,12 +109,17 @@ class Event(Base):
|
|||||||
capacity = Column(Integer, nullable=True)
|
capacity = Column(Integer, nullable=True)
|
||||||
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
created_by = Column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=False)
|
||||||
published = Column(Boolean, default=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))
|
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))
|
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc))
|
||||||
|
|
||||||
# Relationships
|
# Relationships
|
||||||
creator = relationship("User", back_populates="events_created")
|
creator = relationship("User", back_populates="events_created")
|
||||||
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
|
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
|
||||||
|
gallery_images = relationship("EventGallery", back_populates="event", cascade="all, delete-orphan")
|
||||||
|
|
||||||
class EventRSVP(Base):
|
class EventRSVP(Base):
|
||||||
__tablename__ = "event_rsvps"
|
__tablename__ = "event_rsvps"
|
||||||
@@ -167,3 +179,77 @@ class Subscription(Base):
|
|||||||
# Relationships
|
# Relationships
|
||||||
user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
|
user = relationship("User", back_populates="subscriptions", foreign_keys=[user_id])
|
||||||
plan = relationship("SubscriptionPlan", back_populates="subscriptions")
|
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
320
ms_calendar_service.py
Normal 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
243
r2_storage.py
Normal 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
|
||||||
@@ -9,7 +9,7 @@ certifi==2025.11.12
|
|||||||
cffi==2.0.0
|
cffi==2.0.0
|
||||||
charset-normalizer==3.4.4
|
charset-normalizer==3.4.4
|
||||||
click==8.3.1
|
click==8.3.1
|
||||||
cryptography==46.0.3
|
cryptography==44.0.0
|
||||||
dnspython==2.8.0
|
dnspython==2.8.0
|
||||||
ecdsa==0.19.1
|
ecdsa==0.19.1
|
||||||
email-validator==2.3.0
|
email-validator==2.3.0
|
||||||
@@ -17,6 +17,7 @@ fastapi==0.110.1
|
|||||||
flake8==7.3.0
|
flake8==7.3.0
|
||||||
greenlet==3.2.4
|
greenlet==3.2.4
|
||||||
h11==0.16.0
|
h11==0.16.0
|
||||||
|
icalendar==6.0.1
|
||||||
idna==3.11
|
idna==3.11
|
||||||
iniconfig==2.3.0
|
iniconfig==2.3.0
|
||||||
isort==7.0.0
|
isort==7.0.0
|
||||||
@@ -26,6 +27,7 @@ markdown-it-py==4.0.0
|
|||||||
mccabe==0.7.0
|
mccabe==0.7.0
|
||||||
mdurl==0.1.2
|
mdurl==0.1.2
|
||||||
motor==3.3.1
|
motor==3.3.1
|
||||||
|
msal==1.27.0
|
||||||
mypy==1.18.2
|
mypy==1.18.2
|
||||||
mypy_extensions==1.1.0
|
mypy_extensions==1.1.0
|
||||||
numpy==2.3.5
|
numpy==2.3.5
|
||||||
@@ -34,6 +36,7 @@ packaging==25.0
|
|||||||
pandas==2.3.3
|
pandas==2.3.3
|
||||||
passlib==1.7.4
|
passlib==1.7.4
|
||||||
pathspec==0.12.1
|
pathspec==0.12.1
|
||||||
|
pillow==10.2.0
|
||||||
platformdirs==4.5.0
|
platformdirs==4.5.0
|
||||||
pluggy==1.6.0
|
pluggy==1.6.0
|
||||||
psycopg2-binary==2.9.11
|
psycopg2-binary==2.9.11
|
||||||
@@ -50,6 +53,7 @@ pytest==9.0.1
|
|||||||
python-dateutil==2.9.0.post0
|
python-dateutil==2.9.0.post0
|
||||||
python-dotenv==1.2.1
|
python-dotenv==1.2.1
|
||||||
python-jose==3.5.0
|
python-jose==3.5.0
|
||||||
|
python-magic==0.4.27
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
pytokens==0.3.0
|
pytokens==0.3.0
|
||||||
pytz==2025.2
|
pytz==2025.2
|
||||||
|
|||||||
Reference in New Issue
Block a user