diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..c94d518 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a4fdcab --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/INSTRUCTIONS.md b/INSTRUCTIONS.md new file mode 100644 index 0000000..2650d16 --- /dev/null +++ b/INSTRUCTIONS.md @@ -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 \ No newline at end of file diff --git a/PROJECT_STRUCTURE.md b/PROJECT_STRUCTURE.md new file mode 100644 index 0000000..e478788 --- /dev/null +++ b/PROJECT_STRUCTURE.md @@ -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 diff --git a/QUICKSTART.md b/QUICKSTART.md new file mode 100644 index 0000000..96eeb61 --- /dev/null +++ b/QUICKSTART.md @@ -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) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7813069 --- /dev/null +++ b/backend/Dockerfile @@ -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"] diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..d95b0f0 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +# App package initialization diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..6758a96 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API package initialization diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py new file mode 100644 index 0000000..163034d --- /dev/null +++ b/backend/app/api/dependencies.py @@ -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 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..37318a6 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -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"]) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..84607eb --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -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" + } diff --git a/backend/app/api/v1/email.py b/backend/app/api/v1/email.py new file mode 100644 index 0000000..ae1b679 --- /dev/null +++ b/backend/app/api/v1/email.py @@ -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"
{request.message}
" + 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} diff --git a/backend/app/api/v1/memberships.py b/backend/app/api/v1/memberships.py new file mode 100644 index 0000000..e432694 --- /dev/null +++ b/backend/app/api/v1/memberships.py @@ -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"} diff --git a/backend/app/api/v1/payments.py b/backend/app/api/v1/payments.py new file mode 100644 index 0000000..4f58ace --- /dev/null +++ b/backend/app/api/v1/payments.py @@ -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 diff --git a/backend/app/api/v1/tiers.py b/backend/app/api/v1/tiers.py new file mode 100644 index 0000000..eb5241a --- /dev/null +++ b/backend/app/api/v1/tiers.py @@ -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"} diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..021de45 --- /dev/null +++ b/backend/app/api/v1/users.py @@ -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"} diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..cab174e --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1 @@ +# Core package initialization diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..832b783 --- /dev/null +++ b/backend/app/core/config.py @@ -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() diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..2090286 --- /dev/null +++ b/backend/app/core/database.py @@ -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() diff --git a/backend/app/core/init_db.py b/backend/app/core/init_db.py new file mode 100644 index 0000000..acbe95e --- /dev/null +++ b/backend/app/core/init_db.py @@ -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!") diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..6b0f28a --- /dev/null +++ b/backend/app/core/security.py @@ -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 diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..b6c14fc --- /dev/null +++ b/backend/app/main.py @@ -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"} diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..8189865 --- /dev/null +++ b/backend/app/models/__init__.py @@ -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", +] diff --git a/backend/app/models/models.py b/backend/app/models/models.py new file mode 100644 index 0000000..0e59f27 --- /dev/null +++ b/backend/app/models/models.py @@ -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) diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..796adc7 --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -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", +] diff --git a/backend/app/schemas/schemas.py b/backend/app/schemas/schemas.py new file mode 100644 index 0000000..9aaa528 --- /dev/null +++ b/backend/app/schemas/schemas.py @@ -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 diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..a70b302 --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +# Services package diff --git a/backend/app/services/email_service.py b/backend/app/services/email_service.py new file mode 100644 index 0000000..c083399 --- /dev/null +++ b/backend/app/services/email_service.py @@ -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""" + + +Hello {first_name},
+Thank you for registering with us. Your account has been successfully created.
+You can now:
+If you have any questions, please don't hesitate to contact us.
+Best regards,
+ {settings.APP_NAME}
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}
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}