First commit

This commit is contained in:
James Pattinson
2025-11-10 13:57:46 +00:00
parent cffb5e8b8e
commit 3751ee0076
31 changed files with 2356 additions and 0 deletions

1
backend/app/__init__.py Normal file
View File

@@ -0,0 +1 @@
# App package initialization

View File

@@ -0,0 +1 @@
# API package initialization

View File

@@ -0,0 +1,69 @@
from fastapi import Depends, HTTPException, status
from fastapi.security import OAuth2PasswordBearer
from sqlalchemy.orm import Session
from typing import Optional
from ..core.database import get_db
from ..core.security import decode_token
from ..models.models import User, UserRole
from ..schemas import TokenData
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/api/v1/auth/login")
async def get_current_user(
token: str = Depends(oauth2_scheme),
db: Session = Depends(get_db)
) -> User:
"""Get current authenticated user"""
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
)
user_id = decode_token(token)
if user_id is None:
raise credentials_exception
user = db.query(User).filter(User.id == int(user_id)).first()
if user is None:
raise credentials_exception
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user"
)
return user
async def get_current_active_user(
current_user: User = Depends(get_current_user)
) -> User:
"""Get current active user"""
return current_user
async def get_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""Verify user has admin privileges"""
if current_user.role not in [UserRole.ADMIN, UserRole.SUPER_ADMIN]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not enough permissions"
)
return current_user
async def get_super_admin_user(
current_user: User = Depends(get_current_user)
) -> User:
"""Verify user has super admin privileges"""
if current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Super admin access required"
)
return current_user

View File

@@ -0,0 +1,11 @@
from fastapi import APIRouter
from . import auth, users, tiers, memberships, payments, email
api_router = APIRouter()
api_router.include_router(auth.router, prefix="/auth", tags=["authentication"])
api_router.include_router(users.router, prefix="/users", tags=["users"])
api_router.include_router(tiers.router, prefix="/tiers", tags=["membership-tiers"])
api_router.include_router(memberships.router, prefix="/memberships", tags=["memberships"])
api_router.include_router(payments.router, prefix="/payments", tags=["payments"])
api_router.include_router(email.router, prefix="/email", tags=["email"])

128
backend/app/api/v1/auth.py Normal file
View File

@@ -0,0 +1,128 @@
from fastapi import APIRouter, Depends, HTTPException, status
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.orm import Session
from datetime import datetime, timedelta
from typing import List
from ...core.database import get_db
from ...core.security import verify_password, get_password_hash, create_access_token
from ...models.models import User, UserRole
from ...schemas import (
UserCreate, UserResponse, Token, LoginRequest, MessageResponse
)
from ...services.email_service import email_service
router = APIRouter()
@router.post("/register", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
async def register(
user_data: UserCreate,
db: Session = Depends(get_db)
):
"""Register a new user"""
# Check if user already exists
existing_user = db.query(User).filter(User.email == user_data.email).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
# Create new user
hashed_password = get_password_hash(user_data.password)
db_user = User(
email=user_data.email,
hashed_password=hashed_password,
first_name=user_data.first_name,
last_name=user_data.last_name,
phone=user_data.phone,
address=user_data.address,
role=UserRole.MEMBER
)
db.add(db_user)
db.commit()
db.refresh(db_user)
# Send welcome email (non-blocking, ignore errors)
try:
await email_service.send_welcome_email(
to_email=db_user.email,
first_name=db_user.first_name
)
except Exception as e:
# Log error but don't fail registration
print(f"Failed to send welcome email: {e}")
return db_user
@router.post("/login", response_model=Token)
async def login(
form_data: OAuth2PasswordRequestForm = Depends(),
db: Session = Depends(get_db)
):
"""Login with email and password"""
# Find user
user = db.query(User).filter(User.email == form_data.username).first()
if not user or not verify_password(form_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user account"
)
# Update last login
user.last_login = datetime.utcnow()
db.commit()
# Create access token
access_token = create_access_token(subject=user.id)
return {
"access_token": access_token,
"token_type": "bearer"
}
@router.post("/login-json", response_model=Token)
async def login_json(
login_data: LoginRequest,
db: Session = Depends(get_db)
):
"""Login with JSON body (email and password)"""
# Find user
user = db.query(User).filter(User.email == login_data.email).first()
if not user or not verify_password(login_data.password, user.hashed_password):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect email or password",
headers={"WWW-Authenticate": "Bearer"},
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Inactive user account"
)
# Update last login
user.last_login = datetime.utcnow()
db.commit()
# Create access token
access_token = create_access_token(subject=user.id)
return {
"access_token": access_token,
"token_type": "bearer"
}

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from ...services.email_service import email_service
from ...api.dependencies import get_admin_user
from ...models.models import User
router = APIRouter()
class TestEmailRequest(BaseModel):
to_email: EmailStr
subject: str
message: str
class WelcomeEmailRequest(BaseModel):
to_email: EmailStr
first_name: str
@router.post("/test-email")
async def send_test_email(
request: TestEmailRequest,
current_user: User = Depends(get_admin_user)
):
"""Send a test email (admin only)"""
html_body = f"<html><body><p>{request.message}</p></body></html>"
result = await email_service.send_email(
to_email=request.to_email,
subject=request.subject,
html_body=html_body,
text_body=request.message
)
return {"success": True, "result": result}
@router.post("/test-welcome-email")
async def send_test_welcome_email(
request: WelcomeEmailRequest,
current_user: User = Depends(get_admin_user)
):
"""Send a test welcome email (admin only)"""
result = await email_service.send_welcome_email(
to_email=request.to_email,
first_name=request.first_name
)
return {"success": True, "result": result}

View File

@@ -0,0 +1,163 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import date, timedelta
from ...core.database import get_db
from ...models.models import Membership, MembershipStatus, User, MembershipTier
from ...schemas import (
MembershipCreate, MembershipUpdate, MembershipResponse, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/my-memberships", response_model=List[MembershipResponse])
async def get_my_memberships(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get current user's memberships"""
memberships = db.query(Membership).filter(
Membership.user_id == current_user.id
).all()
return memberships
@router.post("/", response_model=MembershipResponse, status_code=status.HTTP_201_CREATED)
async def create_membership(
membership_data: MembershipCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create a new membership for current user"""
# Verify tier exists
tier = db.query(MembershipTier).filter(
MembershipTier.id == membership_data.tier_id
).first()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership tier not found"
)
if not tier.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Membership tier is not active"
)
# Create membership
membership = Membership(
user_id=current_user.id,
tier_id=membership_data.tier_id,
start_date=membership_data.start_date,
end_date=membership_data.end_date,
auto_renew=membership_data.auto_renew,
status=MembershipStatus.PENDING
)
db.add(membership)
db.commit()
db.refresh(membership)
return membership
@router.get("/{membership_id}", response_model=MembershipResponse)
async def get_membership(
membership_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get membership by ID"""
membership = db.query(Membership).filter(
Membership.id == membership_id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found"
)
# Check if user has permission to view this membership
if membership.user_id != current_user.id and current_user.role.value not in ["admin", "super_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this membership"
)
return membership
@router.put("/{membership_id}", response_model=MembershipResponse)
async def update_membership(
membership_id: int,
membership_update: MembershipUpdate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Update membership (admin only)"""
membership = db.query(Membership).filter(
Membership.id == membership_id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found"
)
update_data = membership_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(membership, field, value)
db.commit()
db.refresh(membership)
return membership
@router.get("/", response_model=List[MembershipResponse])
async def list_memberships(
skip: int = 0,
limit: int = 100,
status: MembershipStatus | None = None,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""List all memberships (admin only)"""
query = db.query(Membership)
if status:
query = query.filter(Membership.status == status)
memberships = query.offset(skip).limit(limit).all()
return memberships
@router.delete("/{membership_id}", response_model=MessageResponse)
async def delete_membership(
membership_id: int,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Delete membership (admin only)"""
membership = db.query(Membership).filter(
Membership.id == membership_id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found"
)
db.delete(membership)
db.commit()
return {"message": "Membership deleted successfully"}

View File

@@ -0,0 +1,181 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from datetime import datetime
from ...core.database import get_db
from ...models.models import Payment, PaymentStatus, User, Membership
from ...schemas import (
PaymentCreate, PaymentUpdate, PaymentResponse, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/my-payments", response_model=List[PaymentResponse])
async def get_my_payments(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get current user's payment history"""
payments = db.query(Payment).filter(
Payment.user_id == current_user.id
).order_by(Payment.created_at.desc()).all()
return payments
@router.post("/", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
async def create_payment(
payment_data: PaymentCreate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Create a new payment"""
# Verify membership exists if provided
if payment_data.membership_id:
membership = db.query(Membership).filter(
Membership.id == payment_data.membership_id,
Membership.user_id == current_user.id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found or does not belong to user"
)
payment = Payment(
user_id=current_user.id,
membership_id=payment_data.membership_id,
amount=payment_data.amount,
payment_method=payment_data.payment_method,
notes=payment_data.notes,
status=PaymentStatus.PENDING
)
db.add(payment)
db.commit()
db.refresh(payment)
return payment
@router.get("/{payment_id}", response_model=PaymentResponse)
async def get_payment(
payment_id: int,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Get payment by ID"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
# Check if user has permission to view this payment
if payment.user_id != current_user.id and current_user.role.value not in ["admin", "super_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Not authorized to view this payment"
)
return payment
@router.put("/{payment_id}", response_model=PaymentResponse)
async def update_payment(
payment_id: int,
payment_update: PaymentUpdate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Update payment (admin only)"""
payment = db.query(Payment).filter(Payment.id == payment_id).first()
if not payment:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Payment not found"
)
update_data = payment_update.model_dump(exclude_unset=True)
# If marking as completed, set payment_date if not already set
if update_data.get("status") == PaymentStatus.COMPLETED and not payment.payment_date:
update_data["payment_date"] = datetime.utcnow()
for field, value in update_data.items():
setattr(payment, field, value)
db.commit()
db.refresh(payment)
return payment
@router.get("/", response_model=List[PaymentResponse])
async def list_payments(
skip: int = 0,
limit: int = 100,
status: PaymentStatus | None = None,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""List all payments (admin only)"""
query = db.query(Payment)
if status:
query = query.filter(Payment.status == status)
payments = query.order_by(Payment.created_at.desc()).offset(skip).limit(limit).all()
return payments
@router.post("/manual-payment", response_model=PaymentResponse, status_code=status.HTTP_201_CREATED)
async def record_manual_payment(
user_id: int,
payment_data: PaymentCreate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Record a manual payment (cash/check) for a user (admin only)"""
# Verify user exists
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
# Verify membership if provided
if payment_data.membership_id:
membership = db.query(Membership).filter(
Membership.id == payment_data.membership_id,
Membership.user_id == user_id
).first()
if not membership:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership not found or does not belong to user"
)
payment = Payment(
user_id=user_id,
membership_id=payment_data.membership_id,
amount=payment_data.amount,
payment_method=payment_data.payment_method,
notes=payment_data.notes,
status=PaymentStatus.COMPLETED,
payment_date=datetime.utcnow()
)
db.add(payment)
db.commit()
db.refresh(payment)
return payment

116
backend/app/api/v1/tiers.py Normal file
View File

@@ -0,0 +1,116 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from ...core.database import get_db
from ...models.models import MembershipTier
from ...schemas import (
MembershipTierCreate, MembershipTierUpdate, MembershipTierResponse, MessageResponse
)
from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/", response_model=List[MembershipTierResponse])
async def list_membership_tiers(
skip: int = 0,
limit: int = 100,
show_inactive: bool = False,
db: Session = Depends(get_db)
):
"""List all membership tiers"""
query = db.query(MembershipTier)
if not show_inactive:
query = query.filter(MembershipTier.is_active == True)
tiers = query.offset(skip).limit(limit).all()
return tiers
@router.get("/{tier_id}", response_model=MembershipTierResponse)
async def get_membership_tier(
tier_id: int,
db: Session = Depends(get_db)
):
"""Get membership tier by ID"""
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership tier not found"
)
return tier
@router.post("/", response_model=MembershipTierResponse, status_code=status.HTTP_201_CREATED)
async def create_membership_tier(
tier_data: MembershipTierCreate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Create a new membership tier (admin only)"""
# Check if tier with same name exists
existing_tier = db.query(MembershipTier).filter(
MembershipTier.name == tier_data.name
).first()
if existing_tier:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Membership tier with this name already exists"
)
tier = MembershipTier(**tier_data.model_dump())
db.add(tier)
db.commit()
db.refresh(tier)
return tier
@router.put("/{tier_id}", response_model=MembershipTierResponse)
async def update_membership_tier(
tier_id: int,
tier_update: MembershipTierUpdate,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Update membership tier (admin only)"""
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership tier not found"
)
update_data = tier_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(tier, field, value)
db.commit()
db.refresh(tier)
return tier
@router.delete("/{tier_id}", response_model=MessageResponse)
async def delete_membership_tier(
tier_id: int,
current_user = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Delete membership tier (admin only)"""
tier = db.query(MembershipTier).filter(MembershipTier.id == tier_id).first()
if not tier:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Membership tier not found"
)
db.delete(tier)
db.commit()
return {"message": "Membership tier deleted successfully"}

View File

@@ -0,0 +1,85 @@
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session
from typing import List
from ...core.database import get_db
from ...core.security import get_password_hash
from ...models.models import User
from ...schemas import UserResponse, UserUpdate, MessageResponse
from ...api.dependencies import get_current_active_user, get_admin_user
router = APIRouter()
@router.get("/me", response_model=UserResponse)
async def get_current_user_profile(
current_user: User = Depends(get_current_active_user)
):
"""Get current user's profile"""
return current_user
@router.put("/me", response_model=UserResponse)
async def update_current_user_profile(
user_update: UserUpdate,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
"""Update current user's profile"""
update_data = user_update.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(current_user, field, value)
db.commit()
db.refresh(current_user)
return current_user
@router.get("/", response_model=List[UserResponse])
async def list_users(
skip: int = 0,
limit: int = 100,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""List all users (admin only)"""
users = db.query(User).offset(skip).limit(limit).all()
return users
@router.get("/{user_id}", response_model=UserResponse)
async def get_user(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Get user by ID (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
return user
@router.delete("/{user_id}", response_model=MessageResponse)
async def delete_user(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Delete user (admin only)"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="User not found"
)
db.delete(user)
db.commit()
return {"message": "User deleted successfully"}

View File

@@ -0,0 +1 @@
# Core package initialization

View File

@@ -0,0 +1,53 @@
from pydantic_settings import BaseSettings
from typing import List
import os
class Settings(BaseSettings):
# Application
APP_NAME: str = "Swansea Airport Stakeholders Alliance"
APP_VERSION: str = "1.0.0"
DEBUG: bool = True
ENVIRONMENT: str = "development"
# API
API_V1_PREFIX: str = "/api/v1"
SECRET_KEY: str
ALGORITHM: str = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
# Database
DATABASE_HOST: str
DATABASE_PORT: int = 3306
DATABASE_USER: str
DATABASE_PASSWORD: str
DATABASE_NAME: str
@property
def DATABASE_URL(self) -> str:
return f"mysql+pymysql://{self.DATABASE_USER}:{self.DATABASE_PASSWORD}@{self.DATABASE_HOST}:{self.DATABASE_PORT}/{self.DATABASE_NAME}"
# Square Payment
SQUARE_ACCESS_TOKEN: str
SQUARE_ENVIRONMENT: str = "sandbox"
SQUARE_LOCATION_ID: str
# Email
SMTP2GO_API_KEY: str
SMTP2GO_API_URL: str = "https://api.smtp2go.com/v3/email/send"
EMAIL_FROM: str
EMAIL_FROM_NAME: str
# CORS
BACKEND_CORS_ORIGINS: List[str] = ["http://localhost:3000", "http://localhost:8080"]
# File Storage
UPLOAD_DIR: str = "/app/uploads"
MAX_UPLOAD_SIZE: int = 10485760 # 10MB
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@@ -0,0 +1,23 @@
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import sessionmaker
from .config import settings
engine = create_engine(
settings.DATABASE_URL,
pool_pre_ping=True,
pool_recycle=3600,
)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
def get_db():
"""Dependency for getting database session"""
db = SessionLocal()
try:
yield db
finally:
db.close()

View File

@@ -0,0 +1,56 @@
from sqlalchemy.orm import Session
from ..models.models import MembershipTier, User, UserRole
from .security import get_password_hash
from datetime import datetime
def init_default_data(db: Session):
"""Initialize database with default data if empty"""
# Check if membership tiers exist
existing_tiers = db.query(MembershipTier).count()
if existing_tiers == 0:
print("Creating default membership tiers...")
default_tiers = [
MembershipTier(
name="Personal",
description="Basic membership for individual members",
annual_fee=5.00,
benefits="Access to member portal, meeting notifications, event participation",
is_active=True
),
MembershipTier(
name="Aircraft Owners",
description="Group membership for aircraft owners",
annual_fee=25.00,
benefits="All Personal benefits plus priority event registration, aircraft owner resources",
is_active=True
),
MembershipTier(
name="Corporate",
description="Corporate membership for businesses",
annual_fee=100.00,
benefits="All benefits plus corporate recognition, promotional opportunities, file access",
is_active=True
)
]
db.add_all(default_tiers)
db.commit()
print(f"✓ Created {len(default_tiers)} default membership tiers")
# Check if admin user exists
admin_exists = db.query(User).filter(User.email == "admin@swanseaairport.org").first()
if not admin_exists:
print("Creating default admin user...")
admin_user = User(
email="admin@swanseaairport.org",
hashed_password=get_password_hash("admin123"),
first_name="System",
last_name="Administrator",
role=UserRole.SUPER_ADMIN,
is_active=True
)
db.add(admin_user)
db.commit()
print("✓ Created default admin user (admin@swanseaairport.org / admin123)")
print(" ⚠️ Remember to change the admin password!")

View File

@@ -0,0 +1,44 @@
from datetime import datetime, timedelta
from typing import Optional, Union, Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from .config import settings
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def create_access_token(
subject: Union[str, Any], expires_delta: Optional[timedelta] = None
) -> str:
"""Create JWT access token"""
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(
minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES
)
to_encode = {"exp": expire, "sub": str(subject)}
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
return encoded_jwt
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Verify a password against a hash"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Hash a password"""
return pwd_context.hash(password)
def decode_token(token: str) -> Optional[str]:
"""Decode JWT token and return subject"""
try:
payload = jwt.decode(
token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]
)
return payload.get("sub")
except JWTError:
return None

48
backend/app/main.py Normal file
View File

@@ -0,0 +1,48 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from .core.config import settings
from .api.v1 import api_router
from .core.database import engine, Base, SessionLocal
from .core.init_db import init_default_data
# Create database tables
Base.metadata.create_all(bind=engine)
# Initialize default data
db = SessionLocal()
try:
init_default_data(db)
finally:
db.close()
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
)
# Set up CORS
app.add_middleware(
CORSMiddleware,
allow_origins=settings.BACKEND_CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Include API router
app.include_router(api_router, prefix=settings.API_V1_PREFIX)
@app.get("/")
async def root():
return {
"message": f"Welcome to {settings.APP_NAME}",
"version": settings.APP_VERSION,
"docs": "/docs"
}
@app.get("/health")
async def health_check():
return {"status": "healthy"}

View File

@@ -0,0 +1,42 @@
# Import all models here for Alembic
from .models import (
User,
UserRole,
MembershipTier,
Membership,
MembershipStatus,
Payment,
PaymentStatus,
PaymentMethod,
Event,
EventStatus,
EventRSVP,
RSVPStatus,
VolunteerRole,
VolunteerAssignment,
VolunteerSchedule,
Certificate,
File,
Notification,
)
__all__ = [
"User",
"UserRole",
"MembershipTier",
"Membership",
"MembershipStatus",
"Payment",
"PaymentStatus",
"PaymentMethod",
"Event",
"EventStatus",
"EventRSVP",
"RSVPStatus",
"VolunteerRole",
"VolunteerAssignment",
"VolunteerSchedule",
"Certificate",
"File",
"Notification",
]

View File

@@ -0,0 +1,261 @@
from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
Float, Text, ForeignKey, Date
)
from sqlalchemy.orm import relationship
from datetime import datetime
import enum
from ..core.database import Base
class UserRole(str, enum.Enum):
MEMBER = "member"
ADMIN = "admin"
SUPER_ADMIN = "super_admin"
class MembershipStatus(str, enum.Enum):
ACTIVE = "active"
EXPIRED = "expired"
PENDING = "pending"
CANCELLED = "cancelled"
class PaymentStatus(str, enum.Enum):
PENDING = "pending"
COMPLETED = "completed"
FAILED = "failed"
REFUNDED = "refunded"
class PaymentMethod(str, enum.Enum):
SQUARE = "square"
CASH = "cash"
CHECK = "check"
DUMMY = "dummy"
class EventStatus(str, enum.Enum):
DRAFT = "draft"
PUBLISHED = "published"
CANCELLED = "cancelled"
COMPLETED = "completed"
class RSVPStatus(str, enum.Enum):
PENDING = "pending"
ATTENDING = "attending"
NOT_ATTENDING = "not_attending"
MAYBE = "maybe"
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, index=True)
email = Column(String(255), unique=True, index=True, nullable=False)
hashed_password = Column(String(255), nullable=False)
first_name = Column(String(100), nullable=False)
last_name = Column(String(100), nullable=False)
phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True)
role = Column(SQLEnum(UserRole), default=UserRole.MEMBER, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
last_login = Column(DateTime, nullable=True)
# Relationships
memberships = relationship("Membership", back_populates="user", cascade="all, delete-orphan")
payments = relationship("Payment", back_populates="user", cascade="all, delete-orphan")
event_rsvps = relationship("EventRSVP", back_populates="user", cascade="all, delete-orphan")
volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
class MembershipTier(Base):
__tablename__ = "membership_tiers"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), unique=True, nullable=False)
description = Column(Text, nullable=True)
annual_fee = Column(Float, nullable=False)
benefits = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
memberships = relationship("Membership", back_populates="tier")
class Membership(Base):
__tablename__ = "memberships"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=False)
status = Column(SQLEnum(MembershipStatus), default=MembershipStatus.PENDING, nullable=False)
start_date = Column(Date, nullable=False)
end_date = Column(Date, nullable=False)
auto_renew = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="memberships")
tier = relationship("MembershipTier", back_populates="memberships")
payments = relationship("Payment", back_populates="membership")
class Payment(Base):
__tablename__ = "payments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
membership_id = Column(Integer, ForeignKey("memberships.id"), nullable=True)
amount = Column(Float, nullable=False)
payment_method = Column(SQLEnum(PaymentMethod), nullable=False)
status = Column(SQLEnum(PaymentStatus), default=PaymentStatus.PENDING, nullable=False)
transaction_id = Column(String(255), nullable=True)
payment_date = Column(DateTime, nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="payments")
membership = relationship("Membership", back_populates="payments")
class Event(Base):
__tablename__ = "events"
id = Column(Integer, primary_key=True, index=True)
title = Column(String(255), nullable=False)
description = Column(Text, nullable=True)
event_date = Column(DateTime, nullable=False)
location = Column(String(255), nullable=True)
max_attendees = Column(Integer, nullable=True)
status = Column(SQLEnum(EventStatus), default=EventStatus.DRAFT, nullable=False)
created_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
rsvps = relationship("EventRSVP", back_populates="event", cascade="all, delete-orphan")
class EventRSVP(Base):
__tablename__ = "event_rsvps"
id = Column(Integer, primary_key=True, index=True)
event_id = Column(Integer, ForeignKey("events.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
status = Column(SQLEnum(RSVPStatus), default=RSVPStatus.PENDING, nullable=False)
attended = Column(Boolean, default=False, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
event = relationship("Event", back_populates="rsvps")
user = relationship("User", back_populates="event_rsvps")
class VolunteerRole(Base):
__tablename__ = "volunteer_roles"
id = Column(Integer, primary_key=True, index=True)
name = Column(String(100), nullable=False)
description = Column(Text, nullable=True)
is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
assignments = relationship("VolunteerAssignment", back_populates="role", cascade="all, delete-orphan")
class VolunteerAssignment(Base):
__tablename__ = "volunteer_assignments"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
role_id = Column(Integer, ForeignKey("volunteer_roles.id"), nullable=False)
assigned_date = Column(Date, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="volunteer_assignments")
role = relationship("VolunteerRole", back_populates="assignments")
schedules = relationship("VolunteerSchedule", back_populates="assignment", cascade="all, delete-orphan")
class VolunteerSchedule(Base):
__tablename__ = "volunteer_schedules"
id = Column(Integer, primary_key=True, index=True)
assignment_id = Column(Integer, ForeignKey("volunteer_assignments.id"), nullable=False)
schedule_date = Column(Date, nullable=False)
start_time = Column(DateTime, nullable=False)
end_time = Column(DateTime, nullable=False)
location = Column(String(255), nullable=True)
notes = Column(Text, nullable=True)
completed = Column(Boolean, default=False, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
assignment = relationship("VolunteerAssignment", back_populates="schedules")
class Certificate(Base):
__tablename__ = "certificates"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
certificate_name = Column(String(255), nullable=False)
issuing_organization = Column(String(255), nullable=True)
issue_date = Column(Date, nullable=False)
expiry_date = Column(Date, nullable=True)
certificate_number = Column(String(100), nullable=True)
file_path = Column(String(500), nullable=True)
notes = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = relationship("User", back_populates="certificates")
class File(Base):
__tablename__ = "files"
id = Column(Integer, primary_key=True, index=True)
filename = Column(String(255), nullable=False)
original_filename = Column(String(255), nullable=False)
file_path = Column(String(500), nullable=False)
file_size = Column(Integer, nullable=False)
mime_type = Column(String(100), nullable=False)
min_tier_id = Column(Integer, ForeignKey("membership_tiers.id"), nullable=True)
description = Column(Text, nullable=True)
uploaded_by = Column(Integer, ForeignKey("users.id"), nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
class Notification(Base):
__tablename__ = "notifications"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
subject = Column(String(255), nullable=False)
message = Column(Text, nullable=False)
email_sent = Column(Boolean, default=False, nullable=False)
sent_at = Column(DateTime, nullable=True)
error_message = Column(Text, nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)

View File

@@ -0,0 +1,47 @@
from .schemas import (
UserBase,
UserCreate,
UserUpdate,
UserResponse,
UserInDB,
Token,
TokenData,
LoginRequest,
MembershipTierBase,
MembershipTierCreate,
MembershipTierUpdate,
MembershipTierResponse,
MembershipBase,
MembershipCreate,
MembershipUpdate,
MembershipResponse,
PaymentBase,
PaymentCreate,
PaymentUpdate,
PaymentResponse,
MessageResponse,
)
__all__ = [
"UserBase",
"UserCreate",
"UserUpdate",
"UserResponse",
"UserInDB",
"Token",
"TokenData",
"LoginRequest",
"MembershipTierBase",
"MembershipTierCreate",
"MembershipTierUpdate",
"MembershipTierResponse",
"MembershipBase",
"MembershipCreate",
"MembershipUpdate",
"MembershipResponse",
"PaymentBase",
"PaymentCreate",
"PaymentUpdate",
"PaymentResponse",
"MessageResponse",
]

View File

@@ -0,0 +1,152 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional
from datetime import datetime, date
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
# User Schemas
class UserBase(BaseModel):
email: EmailStr
first_name: str = Field(..., min_length=1, max_length=100)
last_name: str = Field(..., min_length=1, max_length=100)
phone: Optional[str] = None
address: Optional[str] = None
class UserCreate(UserBase):
password: str = Field(..., min_length=8)
class UserUpdate(BaseModel):
first_name: Optional[str] = Field(None, min_length=1, max_length=100)
last_name: Optional[str] = Field(None, min_length=1, max_length=100)
phone: Optional[str] = None
address: Optional[str] = None
class UserResponse(UserBase):
model_config = ConfigDict(from_attributes=True)
id: int
role: UserRole
is_active: bool
created_at: datetime
last_login: Optional[datetime] = None
class UserInDB(UserResponse):
hashed_password: str
# Authentication Schemas
class Token(BaseModel):
access_token: str
token_type: str = "bearer"
class TokenData(BaseModel):
user_id: Optional[int] = None
class LoginRequest(BaseModel):
email: EmailStr
password: str
# Membership Tier Schemas
class MembershipTierBase(BaseModel):
name: str = Field(..., min_length=1, max_length=100)
description: Optional[str] = None
annual_fee: float = Field(..., ge=0)
benefits: Optional[str] = None
class MembershipTierCreate(MembershipTierBase):
pass
class MembershipTierUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=100)
description: Optional[str] = None
annual_fee: Optional[float] = Field(None, ge=0)
benefits: Optional[str] = None
is_active: Optional[bool] = None
class MembershipTierResponse(MembershipTierBase):
model_config = ConfigDict(from_attributes=True)
id: int
is_active: bool
created_at: datetime
# Membership Schemas
class MembershipBase(BaseModel):
tier_id: int
auto_renew: bool = False
class MembershipCreate(MembershipBase):
start_date: date
end_date: date
class MembershipUpdate(BaseModel):
tier_id: Optional[int] = None
status: Optional[MembershipStatus] = None
end_date: Optional[date] = None
auto_renew: Optional[bool] = None
class MembershipResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
tier_id: int
status: MembershipStatus
start_date: date
end_date: date
auto_renew: bool
created_at: datetime
tier: MembershipTierResponse
# Payment Schemas
class PaymentBase(BaseModel):
amount: float = Field(..., gt=0)
payment_method: PaymentMethod
notes: Optional[str] = None
class PaymentCreate(PaymentBase):
membership_id: Optional[int] = None
class PaymentUpdate(BaseModel):
status: Optional[PaymentStatus] = None
transaction_id: Optional[str] = None
payment_date: Optional[datetime] = None
notes: Optional[str] = None
class PaymentResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
user_id: int
membership_id: Optional[int] = None
amount: float
payment_method: PaymentMethod
status: PaymentStatus
transaction_id: Optional[str] = None
payment_date: Optional[datetime] = None
notes: Optional[str] = None
created_at: datetime
# Message Response
class MessageResponse(BaseModel):
message: str
detail: Optional[str] = None

View File

@@ -0,0 +1 @@
# Services package

View File

@@ -0,0 +1,197 @@
import httpx
from typing import List, Optional
from ..core.config import settings
class EmailService:
"""Email service using SMTP2GO API"""
def __init__(self):
self.api_key = settings.SMTP2GO_API_KEY
self.api_url = settings.SMTP2GO_API_URL
self.from_email = settings.EMAIL_FROM
self.from_name = settings.EMAIL_FROM_NAME
async def send_email(
self,
to_email: str,
subject: str,
html_body: str,
text_body: Optional[str] = None
) -> dict:
"""
Send an email using SMTP2GO API
Args:
to_email: Recipient email address
subject: Email subject
html_body: HTML content of the email
text_body: Plain text content (optional)
Returns:
dict: API response
"""
payload = {
"to": [to_email],
"sender": f"{self.from_name} <{self.from_email}>",
"subject": subject,
"html_body": html_body,
}
if text_body:
payload["text_body"] = text_body
headers = {
"Content-Type": "application/json",
"X-Smtp2go-Api-Key": self.api_key
}
async with httpx.AsyncClient() as client:
response = await client.post(self.api_url, json=payload, headers=headers)
return response.json()
async def send_welcome_email(self, to_email: str, first_name: str) -> dict:
"""Send welcome email to new user"""
subject = f"Welcome to {settings.APP_NAME}!"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Welcome to {settings.APP_NAME}!</h2>
<p>Hello {first_name},</p>
<p>Thank you for registering with us. Your account has been successfully created.</p>
<p>You can now:</p>
<ul>
<li>Browse membership tiers and select one that suits you</li>
<li>View upcoming events and meetings</li>
<li>Access your membership portal</li>
</ul>
<p>If you have any questions, please don't hesitate to contact us.</p>
<p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p>
</body>
</html>
"""
text_body = f"""
Welcome to {settings.APP_NAME}!
Hello {first_name},
Thank you for registering with us. Your account has been successfully created.
You can now:
- Browse membership tiers and select one that suits you
- View upcoming events and meetings
- Access your membership portal
If you have any questions, please don't hesitate to contact us.
Best regards,
{settings.APP_NAME}
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_payment_confirmation(
self,
to_email: str,
first_name: str,
amount: float,
payment_method: str,
membership_tier: str
) -> dict:
"""Send payment confirmation email"""
subject = "Payment Confirmation"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Payment Confirmed!</h2>
<p>Hello {first_name},</p>
<p>We have received your payment. Thank you!</p>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 5px 0;"><strong>Amount:</strong> £{amount:.2f}</p>
<p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p>
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
</div>
<p>Your membership is now active. You can access all the benefits associated with your tier.</p>
<p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p>
</body>
</html>
"""
text_body = f"""
Payment Confirmed!
Hello {first_name},
We have received your payment. Thank you!
Amount: £{amount:.2f}
Payment Method: {payment_method}
Membership Tier: {membership_tier}
Your membership is now active. You can access all the benefits associated with your tier.
Best regards,
{settings.APP_NAME}
"""
return await self.send_email(to_email, subject, html_body, text_body)
async def send_membership_renewal_reminder(
self,
to_email: str,
first_name: str,
expiry_date: str,
membership_tier: str,
annual_fee: float
) -> dict:
"""Send membership renewal reminder"""
subject = "Membership Renewal Reminder"
html_body = f"""
<html>
<body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;">
<h2 style="color: #0066cc;">Membership Renewal Reminder</h2>
<p>Hello {first_name},</p>
<p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p>
<p>To continue enjoying your membership benefits, please renew your membership.</p>
<div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;">
<p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p>
<p style="margin: 5px 0;"><strong>Annual Fee:</strong> £{annual_fee:.2f}</p>
<p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p>
</div>
<p>Please log in to your account to renew your membership.</p>
<p>Best regards,<br>
<strong>{settings.APP_NAME}</strong></p>
</body>
</html>
"""
text_body = f"""
Membership Renewal Reminder
Hello {first_name},
This is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.
To continue enjoying your membership benefits, please renew your membership.
Membership Tier: {membership_tier}
Annual Fee: £{annual_fee:.2f}
Expires: {expiry_date}
Please log in to your account to renew your membership.
Best regards,
{settings.APP_NAME}
"""
return await self.send_email(to_email, subject, html_body, text_body)
# Create a singleton instance
email_service = EmailService()