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