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

36
.env.example Normal file
View File

@@ -0,0 +1,36 @@
# Application Settings
APP_NAME="Swansea Airport Stakeholders Alliance"
APP_VERSION="1.0.0"
DEBUG=True
ENVIRONMENT=development
# API Settings
API_V1_PREFIX=/api/v1
SECRET_KEY=your-secret-key-change-this-in-production
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
# Database Settings
DATABASE_HOST=mysql
DATABASE_PORT=3306
DATABASE_USER=membership_user
DATABASE_PASSWORD=change_this_password
DATABASE_NAME=membership_db
# Square Payment Settings (to be added later)
SQUARE_ACCESS_TOKEN=your-square-access-token
SQUARE_ENVIRONMENT=sandbox
SQUARE_LOCATION_ID=your-location-id
# SMTP2GO Email Settings
SMTP2GO_API_KEY=your-smtp2go-api-key
SMTP2GO_API_URL=https://api.smtp2go.com/v3/email/send
EMAIL_FROM=noreply@swanseaairport.org
EMAIL_FROM_NAME="Swansea Airport Stakeholders Alliance"
# CORS Settings
BACKEND_CORS_ORIGINS=["http://localhost:3000","http://localhost:8080"]
# File Storage
UPLOAD_DIR=/app/uploads
MAX_UPLOAD_SIZE=10485760

59
.gitignore vendored Normal file
View File

@@ -0,0 +1,59 @@
# Python
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
*.egg-info/
.installed.cfg
*.egg
# Environment variables
.env
.env.local
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# Database
*.db
*.sqlite
*.sqlite3
# Logs
*.log
logs/
# OS
.DS_Store
Thumbs.db
# Docker
docker-compose.override.yml
# Uploads
uploads/
# Testing
.coverage
htmlcov/
.pytest_cache/

148
INSTRUCTIONS.md Normal file
View File

@@ -0,0 +1,148 @@
# Swansea Airport Stakeholders' Alliance Membership Management System
## Project Overview
This project aims to develop a comprehensive membership management system for the Swansea Airport Stakeholders' Alliance. The system will handle member registration, payment processing, membership tracking, and administrative functions for a medium-sized alliance.
## Core Features
### Public Member Features
- **Self-Service Registration**: Members can sign up online and select their membership tier
- **Payment Processing**: Integration with Square payment system for secure online payments, and a dummy payment system for initial testing
- **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings
- **Renewal Reminders**: Automated email notifications for membership renewal deadlines
- **Event Management**: View upcoming events and RSVP to participate
- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records
### Administrative Features
- **Member Database Management**: Query and modify member records
- **Manual Payment Entry**: Record cash payments to activate memberships
- **Membership Tier Management**: Configure different membership levels and associated fees
- **Meeting Management**: Post notices and updates about upcoming alliance meetings
- **Reporting**: Generate reports on membership statistics and payment status
- **Files**: A repositry for files which members can access based on their tier - such as meeting minutes and manuals. Admins can upload files to this area.
- **Event Management**: Create, edit, and manage events, track RSVPs and attendance
- **Volunteering**: Assign configurable volunteer roles to members (e.g., Fire, Radio, General), manage volunteer schedules, and record certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
## Technical Stack
- **Backend**: Python with FastAPI/Uvicorn for high-performance async web framework
- **Database**: MySQL for reliable data storage and complex queries
- **Authentication**: JWT-based authentication system
- **Payment Integration**: Square API for payment processing
- **Email Service**: SMTP2GO API for automated reminders and notifications
- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js)
## Membership Tiers
The system should support configurable membership tiers, including:
- Basic membership (Personal) £5 / year
- Group membership (Aircraft Owners) £25 / year
- Corporate membership (£100 / year)
- Other custom tiers as needed
Each tier will have associated annual fees and benefits.
## Payment System
- Primary payment method: Square integration for online payments
- Support for manual payment entry (cash/check payments)
- Annual fee collection with automatic renewal reminders
- Payment history tracking
## Security Requirements
- Secure user authentication and authorization
- Data encryption for sensitive information
- Role-based access control (members vs administrators)
- GDPR compliance for data protection
## Email Integration
- Welcome emails for new members
- Payment confirmation emails
- Renewal reminder emails (configurable timing)
- Meeting notification emails
- Administrative notification emails
## Database Schema (High-Level)
### Core Tables
- `users`: Member information and authentication
- `memberships`: Membership records with tier and status
- `payments`: Payment transactions
- `tiers`: Membership tier definitions
- `events`: Event information and details
- `event_rsvps`: Event registration and attendance tracking
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
- `volunteer_assignments`: Member-to-role assignments
- `volunteer_schedules`: Volunteer shift scheduling and availability
- `certificates`: Training certificates and qualifications
- `notifications`: Email notification logs
## Development Phases
1. **Phase 1**: Core API development (authentication, user management)
2. **Phase 2**: Payment integration and membership management
3. **Phase 3**: Admin interface development
4. **Phase 4**: Member portal, email system, event management, and volunteering features
5. **Phase 5**: Testing, deployment, and documentation
## Deployment Considerations
- Containerized deployment using Docker
- Scalable architecture for medium-sized alliance
- Backup and recovery procedures
- Monitoring and logging
## Future Enhancements
- Mobile app development
- Advanced reporting and analytics
- Integration with other alliance systems
- Multi-language support
## Project Requirements
### Functional Requirements
- User registration and authentication
- Membership tier selection and management
- Payment processing and tracking
- Email notification system
- Administrative dashboard
- Member self-service portal
- Event management and RSVP system
- Volunteer role assignment, scheduling, and certificate/training management
### Non-Functional Requirements
- High availability and performance
- Data security and privacy
- Scalable architecture
- User-friendly interface
- Comprehensive documentation
## Team Considerations
- Backend developers (Python/FastAPI)
- Frontend developers (if separate frontend)
- Database administrators
- DevOps engineers
- QA testers
- Business analysts for requirements refinement
## Risk Assessment
- Payment system integration complexity
- Email deliverability issues
- Data migration from existing systems
- User adoption and training
- Regulatory compliance requirements
## Success Metrics
- Successful member registration rate
- Payment processing success rate
- User engagement with portal
- Administrative efficiency improvements
- System uptime and performance

116
PROJECT_STRUCTURE.md Normal file
View File

@@ -0,0 +1,116 @@
# Project Structure
```
membership/
├── .env # Environment configuration (ready to use)
├── .env.example # Template for environment variables
├── .gitignore # Git ignore rules
├── docker-compose.yml # Docker services configuration
├── INSTRUCTIONS.md # Original project requirements
├── README.md # Complete documentation
├── QUICKSTART.md # Quick start guide
├── backend/ # FastAPI application
│ ├── Dockerfile # Backend container configuration
│ ├── requirements.txt # Python dependencies
│ └── app/
│ ├── __init__.py
│ ├── main.py # Application entry point
│ │
│ ├── api/ # API endpoints
│ │ ├── __init__.py
│ │ ├── dependencies.py # Auth dependencies
│ │ └── v1/
│ │ ├── __init__.py
│ │ ├── auth.py # Registration, login
│ │ ├── users.py # User management
│ │ ├── tiers.py # Membership tiers
│ │ ├── memberships.py # Membership management
│ │ └── payments.py # Payment processing
│ │
│ ├── core/ # Core functionality
│ │ ├── __init__.py
│ │ ├── config.py # Configuration settings
│ │ ├── database.py # Database connection
│ │ └── security.py # Auth & password hashing
│ │
│ ├── models/ # Database models
│ │ ├── __init__.py
│ │ └── models.py # SQLAlchemy models
│ │
│ ├── schemas/ # Pydantic schemas
│ │ ├── __init__.py
│ │ └── schemas.py # Request/response schemas
│ │
│ ├── services/ # Business logic (placeholder)
│ └── utils/ # Utilities (placeholder)
├── database/ # Database initialization
│ └── init.sql # Default data & admin user
└── frontend/ # Frontend (placeholder for future)
```
## Key Files
### Configuration
- **`.env`** - Environment variables (database, API keys, etc.)
- **`docker-compose.yml`** - Services: MySQL + FastAPI backend
### Backend Application
- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes
- **`backend/app/core/config.py`** - Settings management
- **`backend/app/core/security.py`** - JWT tokens, password hashing
- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.)
- **`backend/app/schemas/schemas.py`** - API request/response models
### API Endpoints (v1)
- **`auth.py`** - Register, login
- **`users.py`** - User profile, admin user management
- **`tiers.py`** - Membership tier CRUD
- **`memberships.py`** - Membership management
- **`payments.py`** - Payment processing & history
## Database Models
Fully implemented:
- **User** - Authentication, profile, roles (member/admin/super_admin)
- **MembershipTier** - Configurable tiers with fees and benefits
- **Membership** - User memberships with status tracking
- **Payment** - Payment records with multiple methods
- **Event** - Event management (model ready, endpoints TODO)
- **EventRSVP** - Event registration (model ready, endpoints TODO)
- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO)
- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO)
- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO)
- **Certificate** - Training certificates (model ready, endpoints TODO)
- **File** - File repository (model ready, endpoints TODO)
- **Notification** - Email tracking (model ready, endpoints TODO)
## Quick Start
```bash
# Start everything
docker-compose up -d
# View logs
docker-compose logs -f
# Access API docs
# http://localhost:8000/docs
```
## Default Credentials
**Admin**: admin@swanseaairport.org / admin123
**Database**: membership_user / SecureMembershipPass2024!
## What's Next
1. Test the API endpoints
2. Add Square payment integration
3. Implement email notifications
4. Create event management endpoints
5. Add volunteer management endpoints
6. Build frontend interface

150
QUICKSTART.md Normal file
View File

@@ -0,0 +1,150 @@
# Quick Start Guide
## Starting the System
```bash
# Start all services
docker-compose up -d
# Watch the logs until services are ready
docker-compose logs -f
```
Wait until you see "Application startup complete", then press Ctrl+C.
**Access the API**:
- API: http://localhost:8000
- Docs: http://localhost:8000/docs
## Testing the API
### 1. Register a new user
```bash
curl -X POST "http://localhost:8000/api/v1/auth/register" \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "testpass123",
"first_name": "Test",
"last_name": "User"
}'
```
### 2. Login
```bash
curl -X POST "http://localhost:8000/api/v1/auth/login-json" \
-H "Content-Type: application/json" \
-d '{
"email": "test@example.com",
"password": "testpass123"
}'
```
Save the `access_token` from the response.
### 3. Get your profile
```bash
curl -X GET "http://localhost:8000/api/v1/users/me" \
-H "Authorization: Bearer YOUR_TOKEN_HERE"
```
### 4. List membership tiers
```bash
curl -X GET "http://localhost:8000/api/v1/tiers/"
```
## Docker Compose Commands
```bash
# Start services
docker-compose up -d
# Stop services
docker-compose down
# View logs (all services)
docker-compose logs -f
# View logs (specific service)
docker-compose logs -f backend
docker-compose logs -f mysql
# Restart services
docker-compose restart
# Rebuild after code changes
docker-compose up -d --build
# Check status
docker-compose ps
# Access MySQL CLI
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
# Create database backup
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
```
## Default Admin Access
- **Email**: admin@swanseaairport.org
- **Password**: admin123
⚠️ Change this password immediately!
## Common Tasks
### Create a membership tier (admin)
1. Login as admin
2. Use the token in the Authorization header
3. POST to `/api/v1/tiers/`
### Record a manual payment (admin)
1. Login as admin
2. POST to `/api/v1/payments/manual-payment`
### View all users (admin)
1. Login as admin
2. GET `/api/v1/users/`
## Troubleshooting
### Check service status
```bash
docker-compose ps
```
### View all logs
```bash
docker-compose logs -f
```
### View backend logs only
```bash
docker-compose logs -f backend
```
### View MySQL logs only
```bash
docker-compose logs -f mysql
```
### Restart everything
```bash
docker-compose restart
```
### Clean start (removes all data)
```bash
docker-compose down -v
docker-compose up -d
```
## Next Steps
1. Update `.env` with your Square and SMTP2GO credentials
2. Change the default admin password
3. Create additional admin users
4. Configure membership tiers as needed
5. Test payment processing
6. Customize email templates (coming soon)

26
backend/Dockerfile Normal file
View File

@@ -0,0 +1,26 @@
FROM python:3.11-slim
WORKDIR /app
# Install system dependencies
RUN apt-get update && apt-get install -y \
gcc \
default-libmysqlclient-dev \
pkg-config \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY ./app ./app
# Create uploads directory
RUN mkdir -p /app/uploads
# Expose port
EXPOSE 8000
# Run the application
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

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()

29
backend/requirements.txt Normal file
View File

@@ -0,0 +1,29 @@
# FastAPI and web server
fastapi==0.104.1
uvicorn[standard]==0.24.0
pydantic==2.5.0
pydantic-settings==2.1.0
python-multipart==0.0.6
# Database
sqlalchemy==2.0.23
pymysql==1.1.0
cryptography==41.0.7
alembic==1.13.0
# Authentication and Security
python-jose[cryptography]==3.3.0
passlib[bcrypt]==1.7.4
python-dotenv==1.0.0
bcrypt==4.1.1
# Payment Integration (to be added later)
# squareup==43.2.0.20251016
# Email Service
httpx==0.25.2
# Other utilities
email-validator==2.1.0
aiofiles==23.2.1
Jinja2==3.1.2

14
database/init.sql Normal file
View File

@@ -0,0 +1,14 @@
-- Initialize database with default membership tiers
-- Create default membership tiers
INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at)
VALUES
('Personal', 'Basic membership for individual members', 5.00, 'Access to member portal, meeting notifications, event participation', TRUE, NOW(), NOW()),
('Aircraft Owners', 'Group membership for aircraft owners', 25.00, 'All Personal benefits plus priority event registration, aircraft owner resources', TRUE, NOW(), NOW()),
('Corporate', 'Corporate membership for businesses', 100.00, 'All benefits plus corporate recognition, promotional opportunities, file access', TRUE, NOW(), NOW());
-- Create default admin user (password: admin123)
-- Note: In production, this should be changed immediately
INSERT INTO users (email, hashed_password, first_name, last_name, role, is_active, created_at, updated_at)
VALUES
('admin@swanseaairport.org', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewY5aeWG/7gYjV8W', 'System', 'Administrator', 'super_admin', TRUE, NOW(), NOW());

51
docker-compose.yml Normal file
View File

@@ -0,0 +1,51 @@
services:
mysql:
image: mysql:8.0
container_name: membership_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
MYSQL_USER: ${DATABASE_USER:-membership_user}
MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password}
# No external port exposure - database only accessible on private network
expose:
- "3306"
volumes:
- mysql_data:/var/lib/mysql
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- membership_private
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
timeout: 20s
retries: 10
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: membership_backend
restart: unless-stopped
env_file:
- .env
ports:
- "6000:8000" # Only expose backend API to host
volumes:
- ./backend/app:/app/app
- uploads_data:/app/uploads
depends_on:
mysql:
condition: service_healthy
networks:
- membership_private # Access to database on private network
networks:
membership_private:
driver: bridge
internal: false # Allow outbound internet access for backend
# Database is not exposed to host - only accessible within this network
volumes:
mysql_data:
uploads_data: