First commit
This commit is contained in:
36
.env.example
Normal file
36
.env.example
Normal 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
59
.gitignore
vendored
Normal 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
148
INSTRUCTIONS.md
Normal 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
116
PROJECT_STRUCTURE.md
Normal 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
150
QUICKSTART.md
Normal 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
26
backend/Dockerfile
Normal 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
1
backend/app/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# App package initialization
|
||||
1
backend/app/api/__init__.py
Normal file
1
backend/app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# API package initialization
|
||||
69
backend/app/api/dependencies.py
Normal file
69
backend/app/api/dependencies.py
Normal 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
|
||||
11
backend/app/api/v1/__init__.py
Normal file
11
backend/app/api/v1/__init__.py
Normal 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
128
backend/app/api/v1/auth.py
Normal 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"
|
||||
}
|
||||
47
backend/app/api/v1/email.py
Normal file
47
backend/app/api/v1/email.py
Normal 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}
|
||||
163
backend/app/api/v1/memberships.py
Normal file
163
backend/app/api/v1/memberships.py
Normal 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"}
|
||||
181
backend/app/api/v1/payments.py
Normal file
181
backend/app/api/v1/payments.py
Normal 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
116
backend/app/api/v1/tiers.py
Normal 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"}
|
||||
85
backend/app/api/v1/users.py
Normal file
85
backend/app/api/v1/users.py
Normal 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"}
|
||||
1
backend/app/core/__init__.py
Normal file
1
backend/app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Core package initialization
|
||||
53
backend/app/core/config.py
Normal file
53
backend/app/core/config.py
Normal 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()
|
||||
23
backend/app/core/database.py
Normal file
23
backend/app/core/database.py
Normal 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()
|
||||
56
backend/app/core/init_db.py
Normal file
56
backend/app/core/init_db.py
Normal 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!")
|
||||
44
backend/app/core/security.py
Normal file
44
backend/app/core/security.py
Normal 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
48
backend/app/main.py
Normal 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"}
|
||||
42
backend/app/models/__init__.py
Normal file
42
backend/app/models/__init__.py
Normal 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",
|
||||
]
|
||||
261
backend/app/models/models.py
Normal file
261
backend/app/models/models.py
Normal 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)
|
||||
47
backend/app/schemas/__init__.py
Normal file
47
backend/app/schemas/__init__.py
Normal 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",
|
||||
]
|
||||
152
backend/app/schemas/schemas.py
Normal file
152
backend/app/schemas/schemas.py
Normal 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
|
||||
1
backend/app/services/__init__.py
Normal file
1
backend/app/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Services package
|
||||
197
backend/app/services/email_service.py
Normal file
197
backend/app/services/email_service.py
Normal 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
29
backend/requirements.txt
Normal 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
14
database/init.sql
Normal 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
51
docker-compose.yml
Normal 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:
|
||||
Reference in New Issue
Block a user