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

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"}