3 Commits

Author SHA1 Message Date
nathanb 34489fd7b7 Merge pull request 'volunteer-profile-questions' (#1) from volunteer-profile-questions into main
Reviewed-on: nathanb/sasa-membership#1
2026-05-05 05:22:59 -04:00
nathanb 1a0b4dc25d Add member profile questions, admin tooling, legal pages, and fast tests
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
  - Add admin UI for managing profile questions and member profile answers
  - Add volunteer level/profile data support across backend schemas, models, API, and migration
  - Update dashboard/profile UI, super admin menu, membership service types, and related styling
  - Add privacy policy, terms of service, cookie notice, and footer links
  - Add frontend Vitest coverage for profile question logic
  - Add backend pytest coverage for profile answer normalization and validation
  - Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
  - Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
    - Protect feature flag reload behind super-admin access
    - Restrict admin-triggered password resets so admins can only reset member accounts
    - Replace email template HTML preview rendering with escaped text preview
    - Update docs for feature flag reload access, password reset scope, and email template preview safety

  -- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
  -- all tests pass on my vm and seems to run fine
2026-05-04 22:15:51 +01:00
nathanb 632e66e21d Add member profile questions, admin tooling, legal pages, and fast tests
- Add configurable profile questions with conditional visibility, admin-only fields, user answers, and seeded onboarding/volunteer questions
  - Add admin UI for managing profile questions and member profile answers
  - Add volunteer level/profile data support across backend schemas, models, API, and migration
  - Update dashboard/profile UI, super admin menu, membership service types, and related styling
  - Add privacy policy, terms of service, cookie notice, and footer links
  - Add frontend Vitest coverage for profile question logic
  - Add backend pytest coverage for profile answer normalization and validation
  - Update restart.sh to build, run frontend/backend unit tests, and restart only after tests pass
  - Refresh README, quickstart, project structure, instructions, and Square docs to match current app features
    - Protect feature flag reload behind super-admin access
    - Restrict admin-triggered password resets so admins can only reset member accounts
    - Replace email template HTML preview rendering with escaped text preview
    - Update docs for feature flag reload access, password reset scope, and email template preview safety

    -- test user questions are also made by AI and not very useful. but i didn't know what to put there so its good enough for a test
2026-05-04 22:05:58 +01:00
37 changed files with 3947 additions and 757 deletions
+1
View File
@@ -6,6 +6,7 @@ __pycache__/
.Python .Python
env/ env/
venv/ venv/
.venv/
ENV/ ENV/
build/ build/
develop-eggs/ develop-eggs/
+43 -13
View File
@@ -4,25 +4,34 @@
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. 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.
## Current Implementation Status
The app now includes a FastAPI backend, React/Vite frontend, Docker Compose development gateway, Alembic migrations, Square payment integration, SMTP2GO email integration, event/RSVP endpoints, configurable profile questions, privacy/terms pages, feature flags, and a fast test gate in `restart.sh`.
## Core Features ## Core Features
### Public Member Features ### Public Member Features
- **Self-Service Registration**: Members can sign up online and select their membership tier - **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 - **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 - **Membership Portal**: Secure login to view membership status, payment history, and upcoming meetings
- **Renewal Reminders**: Automated email notifications for membership renewal deadlines - **Profile Questions**: Members can answer configurable profile questions, including conditional and volunteering-related questions
- **Event Management**: View upcoming events and RSVP to participate - **Event Management**: View upcoming events and RSVP to participate
- **Volunteering**: View assigned volunteer roles, schedule availability for roles, and access certificates/training records - **Account Management**: Members can update profile details, change passwords, request password resets, and review privacy/terms pages
- **Renewal Reminders**: Planned automated email notifications for membership renewal deadlines
- **Volunteering**: Volunteer-related profile fields are implemented; richer role, schedule, and certificate screens are planned
### Administrative Features ### Administrative Features
- **Member Database Management**: Query and modify member records - **Member Database Management**: Query and modify member records
- **Manual Payment Entry**: Record cash payments to activate memberships - **Manual Payment Entry**: Record cash payments to activate memberships
- **Membership Tier Management**: Configure different membership levels and associated fees - **Membership Tier Management**: Configure different membership levels and associated fees
- **Meeting Management**: Post notices and updates about upcoming alliance meetings - **Profile Question Management**: Create, edit, deactivate, order, and configure dependencies for member profile questions
- **Reporting**: Generate reports on membership statistics and payment status - **Meeting/Event Management**: Create, edit, and manage events, track RSVPs and attendance
- **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. - **Email Management**: Edit database-backed email templates with escaped previews, send test emails, and monitor SMTP2GO bounces
- **Feature Flags**: View backend feature flags and reload them from the super-admin interface
- **Reporting**: Planned reports on membership statistics and payment status
- **Files**: Planned repository for member files based on tier, such as meeting minutes and manuals
- **Event Management**: Create, edit, and manage events, track RSVPs and attendance - **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. - **Volunteering**: Models exist for configurable volunteer roles, assignments, schedules, and certificates/training. Note: A member may not necessarily be a volunteer, but all volunteers are members.
## Technical Stack ## Technical Stack
@@ -32,7 +41,8 @@ This project aims to develop a comprehensive membership management system for th
- **Authentication**: JWT-based authentication system - **Authentication**: JWT-based authentication system
- **Payment Integration**: Square API for payment processing - **Payment Integration**: Square API for payment processing
- **Email Service**: SMTP2GO API for automated reminders and notifications - **Email Service**: SMTP2GO API for automated reminders and notifications
- **Frontend**: Modern web interface (to be determined - potentially React/Vue.js) - **Frontend**: React 18, TypeScript, Vite, and Tailwind CSS
- **Testing**: Vitest for frontend unit tests and pytest for backend unit tests
## Membership Tiers ## Membership Tiers
@@ -73,21 +83,41 @@ Each tier will have associated annual fees and benefits.
- `memberships`: Membership records with tier and status - `memberships`: Membership records with tier and status
- `payments`: Payment transactions - `payments`: Payment transactions
- `tiers`: Membership tier definitions - `tiers`: Membership tier definitions
- `profile_questions`: Configurable profile/onboarding questions
- `user_profile_answers`: Per-member profile answers
- `events`: Event information and details - `events`: Event information and details
- `event_rsvps`: Event registration and attendance tracking - `event_rsvps`: Event registration and attendance tracking
- `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General) - `volunteer_roles`: Configurable volunteer role definitions (e.g., Fire, Radio, General)
- `volunteer_assignments`: Member-to-role assignments - `volunteer_assignments`: Member-to-role assignments
- `volunteer_schedules`: Volunteer shift scheduling and availability - `volunteer_schedules`: Volunteer shift scheduling and availability
- `certificates`: Training certificates and qualifications - `certificates`: Training certificates and qualifications
- `email_templates`: Editable SMTP2GO email templates
- `email_bounces`: Bounce/complaint/unsubscribe tracking
- `password_reset_tokens`: One-time reset tokens
- `notifications`: Email notification logs - `notifications`: Email notification logs
## Testing and Restart Workflow
`./restart.sh` rebuilds Docker images with cache, runs the fast frontend and backend unit tests, shuts down the current stack, and starts it again only if tests pass.
```bash
./restart.sh
```
Individual test commands:
```bash
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Development Phases ## Development Phases
1. **Phase 1**: Core API development (authentication, user management) 1. **Phase 1**: Core API development (authentication, user management) - implemented
2. **Phase 2**: Payment integration and membership management 2. **Phase 2**: Payment integration and membership management - implemented
3. **Phase 3**: Admin interface development 3. **Phase 3**: Admin interface development - implemented for users, tiers, payments, emails, bounces, profile questions, and feature flags
4. **Phase 4**: Member portal, email system, event management, and volunteering features 4. **Phase 4**: Member portal, email system, event management, and volunteering features - partially implemented; richer volunteer screens and renewal reminders remain
5. **Phase 5**: Testing, deployment, and documentation 5. **Phase 5**: Testing, deployment, and documentation - active; fast unit tests and documentation are in place
## Deployment Considerations ## Deployment Considerations
@@ -152,4 +182,4 @@ Each tier will have associated annual fees and benefits.
- Payment processing success rate - Payment processing success rate
- User engagement with portal - User engagement with portal
- Administrative efficiency improvements - Administrative efficiency improvements
- System uptime and performance - System uptime and performance
+110 -76
View File
@@ -2,115 +2,149 @@
``` ```
membership/ membership/
├── .env # Environment configuration (ready to use) ├── .env # Local environment configuration
├── .env.example # Template for environment variables ├── .env.example # Environment variable template
├── .gitignore # Git ignore rules ├── .gitignore # Git ignore rules
├── docker-compose.yml # Docker services configuration ├── docker-compose.yml # Backend, frontend, gateway, and prod frontend services
├── INSTRUCTIONS.md # Original project requirements ├── restart.sh # Build, run fast tests, and restart the app
├── README.md # Complete documentation ├── INSTRUCTIONS.md # Product requirements and roadmap context
├── QUICKSTART.md # Quick start guide ├── README.md # Full project documentation
├── QUICKSTART.md # Short operator/developer guide
├── backend/ # FastAPI application ├── backend/ # FastAPI application
│ ├── Dockerfile # Backend container configuration │ ├── Dockerfile
│ ├── requirements.txt # Python dependencies │ ├── requirements.txt
│ ├── alembic.ini
│ ├── alembic/ # Database migrations
│ └── app/ │ └── app/
│ ├── __init__.py │ ├── main.py # App, CORS, health check, router registration
│ ├── main.py # Application entry point │ ├── api/
│ │ │ │ ├── dependencies.py # Auth dependencies
│ ├── api/ # API endpoints
│ │ ├── __init__.py
│ │ ├── dependencies.py # Auth dependencies
│ │ └── v1/ │ │ └── v1/
│ │ ├── __init__.py │ │ ├── auth.py # Register, login, password reset/change
│ │ ├── auth.py # Registration, login │ │ ├── users.py # Users, profile questions, profile answers
│ │ ├── users.py # User management │ │ ├── tiers.py # Membership tiers
│ │ ├── tiers.py # Membership tiers │ │ ├── memberships.py
│ │ ├── memberships.py # Membership management │ │ ├── payments.py # Manual, Square, refund, payment history
│ │ ── payments.py # Payment processing │ │ ── email.py # SMTP2GO email tests and bounce webhooks
│ │ │ │ ├── email_templates.py
├── core/ # Core functionality │ ├── events.py # Events and RSVPs
│ │ ├── __init__.py │ │ └── feature_flags.py
├── config.py # Configuration settings │ ├── core/ # Config, database, security, default data
│ ├── database.py # Database connection ├── models/ # SQLAlchemy models
│ └── security.py # Auth & password hashing ├── schemas/ # Pydantic schemas
├── services/ # Email, bounce, Square, feature flags
── models/ # Database models ── tests/ # Fast backend pytest unit tests
│ │ ├── __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 ├── docker/
│ └── init.sql # Default data & admin user │ └── gateway/ # Nginx dev gateway and self-signed TLS setup
└── frontend/ # Frontend (placeholder for future) └── frontend/ # React/Vite frontend
├── Dockerfile
├── package.json
├── vite.config.ts
└── src/
├── App.tsx # Routes, footer links, cookie notice
├── components/ # Dashboard, admin, payment, email, profile UI
├── contexts/ # Feature flag context/provider
├── pages/ # Login, register, dashboard, policy pages
├── services/ # API clients
└── utils/ # Shared frontend logic and Vitest tests
``` ```
## Key Files ## Key Files
### Configuration ### Configuration
- **`.env`** - Environment variables (database, API keys, etc.) - **`.env`** - Runtime configuration for database, auth, Square, SMTP2GO, ports, and gateway TLS.
- **`docker-compose.yml`** - Services: MySQL + FastAPI backend - **`docker-compose.yml`** - Services for FastAPI backend, Vite frontend, Nginx gateway, and production static frontend.
- **`restart.sh`** - Rebuilds images, runs frontend/backend unit tests, and restarts the stack only if tests pass.
### Backend Application ### Backend Application
- **`backend/app/main.py`** - FastAPI app initialization, CORS, routes - **`backend/app/main.py`** - FastAPI app initialization, CORS, startup default-data seeding, routes, and health checks.
- **`backend/app/core/config.py`** - Settings management - **`backend/app/core/config.py`** - Settings management.
- **`backend/app/core/security.py`** - JWT tokens, password hashing - **`backend/app/core/init_db.py`** - Default membership tiers, super admin, email templates, and profile questions.
- **`backend/app/models/models.py`** - Database tables (User, Membership, Payment, etc.) - **`backend/app/core/security.py`** - JWT tokens and password hashing.
- **`backend/app/schemas/schemas.py`** - API request/response models - **`backend/app/models/models.py`** - Database tables.
- **`backend/app/schemas/schemas.py`** - API request/response models.
- **`backend/app/tests/test_profile_question_logic.py`** - Fast backend unit tests for profile answer validation.
### API Endpoints (v1) ### Frontend Application
- **`auth.py`** - Register, login - **`frontend/src/pages/Dashboard.tsx`** - Main member/admin dashboard.
- **`users.py`** - User profile, admin user management - **`frontend/src/components/MembershipSetup.tsx`** - Membership tier selection and payment flow.
- **`tiers.py`** - Membership tier CRUD - **`frontend/src/components/SquarePayment.tsx`** - Square Web Payments SDK form.
- **`memberships.py`** - Membership management - **`frontend/src/components/AdminProfileQuestionManager.tsx`** - Admin profile-question configuration.
- **`payments.py`** - Payment processing & history - **`frontend/src/components/ProfileQuestionsForm.tsx`** - Member/admin answer form with dependency handling.
- **`frontend/src/components/EmailTemplateManagement.tsx`** - Email template editing.
- **`frontend/src/components/BounceManagement.tsx`** - SMTP2GO bounce management.
- **`frontend/src/utils/profileQuestionLogic.test.ts`** - Fast frontend unit tests for profile-question visibility/editability.
## API Endpoints
- **`auth.py`** - Register, login, forgot password, reset password, change password.
- **`users.py`** - Current user profile, admin user CRUD, profile-question CRUD, member/admin profile answers, and role-guarded admin password reset emails.
- **`tiers.py`** - Membership tier CRUD.
- **`memberships.py`** - Member/admin membership management.
- **`payments.py`** - Payment history, manual payments, Square config/process/refund.
- **`events.py`** - Event CRUD, upcoming events, RSVP create/update, RSVP listing.
- **`email.py`** - SMTP2GO test emails, welcome email tests, bounce webhook, bounce stats, cleanup, deactivation.
- **`email_templates.py`** - Database-backed template listing, lookup, update, and default seeding.
- **`feature_flags.py`** - Public feature flag listing/lookup and super-admin-only reload.
## Database Models ## Database Models
Fully implemented: Fully implemented:
- **User** - Authentication, profile, roles (member/admin/super_admin) - **User** - Authentication, profile, roles, volunteer level.
- **MembershipTier** - Configurable tiers with fees and benefits - **ProfileQuestion** - Configurable profile fields, options, dependencies, admin-only edit flags.
- **Membership** - User memberships with status tracking - **UserProfileAnswer** - Per-user answers with update attribution.
- **Payment** - Payment records with multiple methods - **MembershipTier** - Configurable tiers with fees and benefits.
- **Event** - Event management (model ready, endpoints TODO) - **Membership** - User memberships with status, dates, and auto-renew flag.
- **EventRSVP** - Event registration (model ready, endpoints TODO) - **Payment** - Payment records for Square, cash, check, and dummy methods.
- **VolunteerRole** - Volunteer roles (model ready, endpoints TODO) - **Event** - Event management records.
- **VolunteerAssignment** - Role assignments (model ready, endpoints TODO) - **EventRSVP** - RSVP and attendance records.
- **VolunteerSchedule** - Shift scheduling (model ready, endpoints TODO) - **EmailTemplate** - Editable database-backed email templates.
- **Certificate** - Training certificates (model ready, endpoints TODO) - **EmailBounce** - SMTP2GO bounce, complaint, and unsubscribe tracking.
- **File** - File repository (model ready, endpoints TODO) - **PasswordResetToken** - One-time password reset support.
- **Notification** - Email tracking (model ready, endpoints TODO) - **VolunteerRole** - Volunteer role definitions.
- **VolunteerAssignment** - Member-to-role assignments.
- **VolunteerSchedule** - Volunteer shift schedules.
- **Certificate** - Training/certificate records.
- **File** - File repository metadata.
- **Notification** - Email notification logs.
## Quick Start ## Quick Start
```bash ```bash
# Start everything # Start everything
docker-compose up -d docker compose up -d
# View logs # View logs
docker-compose logs -f docker compose logs -f
# Access API docs # Access API docs
# http://localhost:8000/docs # http://localhost:8050/docs
```
## Tests
```bash
# Run both fast test suites and restart only if they pass
./restart.sh
# Run test suites individually
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
``` ```
## Default Credentials ## Default Credentials
**Admin**: admin@swanseaairport.org / admin123 **Admin**: admin@swanseaairport.org / admin123
**Database**: Configured via environment variables (see .env file) **Database**: Configured via environment variables in `.env`.
## What's Next ## Remaining Roadmap
1. Test the API endpoints 1. Expand authenticated API tests for member/admin workflows
2. Add Square payment integration 2. Add member file repository endpoints and UI
3. Implement email notifications 3. Build richer volunteer assignment, schedule, and certificate screens
4. Create event management endpoints 4. Add renewal reminder batch jobs
5. Add volunteer management endpoints 5. Add reporting and analytics
6. Build frontend interface
+37 -1
View File
@@ -21,6 +21,20 @@ For Square payment form testing, use HTTPS at `https://localhost:8443`.
Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`. Set `APP_TLS_PORT` in `.env` / `.env.example` to change `8443`.
TLS certs are auto-generated by the gateway container on first start. TLS certs are auto-generated by the gateway container on first start.
## Restart With Tests
Use the restart helper when you want to rebuild, run the fast test suite, and restart only after tests pass:
```bash
./restart.sh
```
It runs:
- `docker compose run --rm frontend npm test`
- `docker compose run --rm backend pytest -q`
The current tests cover frontend profile-question visibility/editability rules and backend profile-question answer normalization/validation. They are designed to complete quickly.
## Testing the API ## Testing the API
### 1. Register a new user ### 1. Register a new user
@@ -108,6 +122,26 @@ docker compose logs -f gateway
1. Login as admin 1. Login as admin
2. GET `/api/v1/users/` 2. GET `/api/v1/users/`
### Manage profile questions (admin)
1. Login as admin or super admin
2. Open the dashboard Admin area
3. Create, edit, deactivate, and order configurable profile questions
4. Use dependencies to show questions only after a matching parent answer
### Edit member profile answers
1. Members can update normal profile questions from the Questions dashboard tab
2. Admin-only answers, such as verified training fields, must be updated by an admin
### Manage events and RSVPs
1. Admins can create and edit events from the dashboard
2. Members can view upcoming events and submit RSVP status
3. Admins can view RSVP lists and attendance data
### Manage email templates and bounces
1. Super admins can edit database-backed email templates; previews are shown as escaped HTML text
2. SMTP2GO bounce webhooks are stored and visible in bounce management
3. Bounce cleanup and manual deactivation are available through the API/admin screens
## Troubleshooting ## Troubleshooting
### Check service status ### Check service status
@@ -143,4 +177,6 @@ docker compose up -d
3. Create additional admin users 3. Create additional admin users
4. Configure membership tiers as needed 4. Configure membership tiers as needed
5. Test payment processing 5. Test payment processing
6. Customize email templates (coming soon) 6. Customize email templates
7. Configure profile questions for onboarding and volunteer data
8. Use `./restart.sh` before deploying changes so frontend and backend unit tests run first
+85 -19
View File
@@ -1,24 +1,32 @@
# Swansea Airport Stakeholders' Alliance Membership Management System # Swansea Airport Stakeholders' Alliance Membership Management System
A comprehensive membership management system built with FastAPI, MySQL, and Docker. A membership management system for Swansea Airport Stakeholders' Alliance, built with FastAPI, React, MySQL-compatible storage, Square payments, SMTP2GO email services, and Docker Compose.
## Features ## Features
- **User Management**: Registration, authentication, and profile management - **Authentication and accounts**: Registration, JSON/form login, JWT sessions, password reset, password change, and role-based access for members, admins, and super admins.
- **Membership Tiers**: Configurable membership levels with different benefits and fees - **Member portal**: Dashboard with membership status, payment history, membership setup, account settings, profile editing, configurable profile questions, cookie notice, privacy policy, and terms of service pages.
- **Payment Processing**: Support for Square payments, cash, and check payments - **Admin operations**: User listing/editing, admin-triggered member password reset emails, membership tier CRUD, manual payment recording, Square refunds, email template editing with escaped previews, SMTP2GO bounce management, profile-question management, and super-admin feature-flag reloads.
- **Admin Dashboard**: Complete administrative control over members and payments - **Membership tiers**: Configurable Personal, Aircraft Owners, Corporate, and custom tiers with annual fees, descriptions, active/inactive state, and benefits.
- **Event Management**: Create and manage events with RSVP tracking (coming soon) - **Memberships and payments**: Membership lifecycle tracking, Square card payments, cash/check/manual payments, dummy test payments, payment history, transaction IDs, refund state, and payment-to-membership linking.
- **Volunteer Management**: Role assignments, scheduling, and certificates (coming soon) - **Events and RSVPs**: Event CRUD, upcoming event listing, member RSVP updates, RSVP status tracking, attendance fields, and admin RSVP visibility.
- **Email Notifications**: Automated notifications via SMTP2GO (coming soon) - **Volunteer and profile data**: Volunteer flag/level support, configurable member profile questions, conditional questions, admin-only answers, seeded aviation/volunteering questions, and data models for volunteer roles, assignments, schedules, and certificates.
- **Email system**: SMTP2GO-backed email sending, default database templates, editable templates, welcome/password-reset/test emails, bounce webhooks, bounce stats, cleanup, and manual deactivation.
- **Feature flags**: Backend feature-flag service with frontend context and admin status/reload controls.
- **Testing**: Fast frontend Vitest unit tests and backend pytest unit tests wired into `restart.sh`.
## Tech Stack ## Tech Stack
- **Backend**: FastAPI (Python 3.11) - **Backend**: FastAPI (Python 3.11)
- **Frontend**: React 18, TypeScript, Vite, Tailwind CSS
- **Database**: MySQL 8.0 - **Database**: MySQL 8.0
- **Authentication**: JWT tokens with OAuth2 - **Authentication**: JWT tokens with OAuth2
- **Containerization**: Docker & Docker Compose - **Containerization**: Docker & Docker Compose
- **ORM**: SQLAlchemy - **ORM**: SQLAlchemy
- **Migrations**: Alembic
- **Payments**: Square Web Payments SDK and Square API
- **Email**: SMTP2GO
- **Tests**: Vitest and pytest
## Project Structure ## Project Structure
@@ -37,7 +45,11 @@ membership/
│ │ │ │ ├── users.py # User management │ │ │ │ ├── users.py # User management
│ │ │ │ ├── tiers.py # Membership tiers │ │ │ │ ├── tiers.py # Membership tiers
│ │ │ │ ├── memberships.py # Membership management │ │ │ │ ├── memberships.py # Membership management
│ │ │ │ ── payments.py # Payment processing │ │ │ │ ── payments.py # Payment processing
│ │ │ │ ├── email.py # SMTP2GO email and bounces
│ │ │ │ ├── email_templates.py
│ │ │ │ ├── events.py # Events and RSVPs
│ │ │ │ └── feature_flags.py
│ │ │ └── dependencies.py # Auth dependencies │ │ │ └── dependencies.py # Auth dependencies
│ │ ├── core/ │ │ ├── core/
│ │ │ ├── config.py # Configuration │ │ │ ├── config.py # Configuration
@@ -50,8 +62,13 @@ membership/
│ │ └── main.py # Application entry point │ │ └── main.py # Application entry point
│ ├── Dockerfile │ ├── Dockerfile
│ └── requirements.txt │ └── requirements.txt
├── database/ ├── frontend/
── init.sql # Legacy database initialization (deprecated - use Alembic migrations) ── src/
│ │ ├── components/ # Dashboard, payment, admin, profile components
│ │ ├── contexts/ # Feature flag context
│ │ ├── pages/ # Login, register, dashboard, policy pages
│ │ ├── services/ # API clients
│ │ └── utils/ # Tested frontend logic
├── docker-compose.yml ├── docker-compose.yml
├── .env.example ├── .env.example
└── README.md └── README.md
@@ -95,6 +112,25 @@ membership/
- API Documentation: http://localhost:8050/docs - API Documentation: http://localhost:8050/docs
- TLS certs are generated automatically by the gateway container on first start - TLS certs are generated automatically by the gateway container on first start
## Restart and Test Gate
`restart.sh` rebuilds images with cache, runs the fast frontend and backend unit tests, then restarts the stack only if tests pass:
```bash
./restart.sh
```
The current fast test suite covers:
- frontend profile-question visibility and editability rules with Vitest
- backend profile-question option parsing, answer normalization/deserialization, select validation, and volunteer flag normalization with pytest
You can also run them individually:
```bash
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Frontend Development vs Production ## Frontend Development vs Production
### Development Mode (Vite) ### Development Mode (Vite)
@@ -191,6 +227,39 @@ docker compose --profile prod down
- `PUT /api/v1/payments/{id}` - Update payment (admin) - `PUT /api/v1/payments/{id}` - Update payment (admin)
- `GET /api/v1/payments/` - List all payments (admin) - `GET /api/v1/payments/` - List all payments (admin)
- `POST /api/v1/payments/manual-payment` - Record manual payment (admin) - `POST /api/v1/payments/manual-payment` - Record manual payment (admin)
- `GET /api/v1/payments/config/square` - Get frontend Square config
- `POST /api/v1/payments/square/process` - Process Square card payment
- `POST /api/v1/payments/square/refund` - Refund Square payment (admin)
### Profile Questions
- `GET /api/v1/users/me/profile-questions` - List active questions with current answers
- `PUT /api/v1/users/me/profile-answers` - Update editable answers
- `GET /api/v1/users/admin/profile-questions` - List all profile questions (admin)
- `POST /api/v1/users/admin/profile-questions` - Create profile question (admin)
- `PUT /api/v1/users/admin/profile-questions/{id}` - Update profile question (admin)
- `DELETE /api/v1/users/admin/profile-questions/{id}` - Deactivate profile question (admin)
- `GET /api/v1/users/admin/users/{id}/profile-answers` - View user answers (admin)
- `PUT /api/v1/users/admin/users/{id}/profile-answers` - Update user answers (admin)
### Events
- `GET /api/v1/events/` - List events
- `GET /api/v1/events/upcoming` - List upcoming events
- `POST /api/v1/events/` - Create event (admin)
- `PUT /api/v1/events/{id}` - Update event (admin)
- `DELETE /api/v1/events/{id}` - Delete event (admin)
- `GET /api/v1/events/{id}/rsvps` - List RSVPs (admin)
- `POST /api/v1/events/{id}/rsvp` - Create or update current user's RSVP
### Email and Feature Flags
- `POST /api/v1/email/test-email` - Send test email
- `POST /api/v1/email/test-welcome-email` - Send test welcome email
- `POST /api/v1/email/webhooks/smtp2go/bounce` - Receive SMTP2GO bounce webhook
- `GET /api/v1/email/bounces` - List bounces
- `GET /api/v1/email/bounces/stats` - Bounce statistics
- `GET /api/v1/email-templates/` - List templates
- `PUT /api/v1/email-templates/{template_key}` - Update template
- `GET /api/v1/feature-flags/flags` - List flags
- `POST /api/v1/feature-flags/flags/reload` - Reload flags (super admin)
## Docker Compose Commands ## Docker Compose Commands
@@ -362,16 +431,13 @@ docker compose up -d
docker compose logs -f docker compose logs -f
``` ```
## Next Steps ## Remaining Roadmap
- [ ] Implement Square payment integration - [ ] Add member file upload/repository endpoints and UI
- [ ] Add email notification system - [ ] Add richer volunteer role, assignment, schedule, and certificate screens on top of the existing models
- [ ] Create event management endpoints - [ ] Implement automated renewal reminder batch jobs
- [ ] Add volunteer management features
- [ ] Build frontend interface
- [ ] Add file upload/management
- [ ] Implement automated renewal reminders
- [ ] Add reporting and analytics - [ ] Add reporting and analytics
- [ ] Expand test coverage around authenticated API flows and payment/email service boundaries
## License ## License
+15 -13
View File
@@ -36,7 +36,7 @@
- [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide - [x] Created `SQUARE_PAYMENT_SETUP.md` - Comprehensive setup guide
- [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details - [x] Created `SQUARE_IMPLEMENTATION.md` - Implementation details
- [x] Created `SQUARE_QUICKSTART.md` - Quick start guide - [x] Created `SQUARE_QUICKSTART.md` - Quick start guide
- [x] Created `deploy-square.sh` - Deployment helper script - [x] Updated `restart.sh` - Build, fast tests, and restart helper
### Code Quality ### Code Quality
- [x] No Python syntax errors - [x] No Python syntax errors
@@ -64,13 +64,15 @@ Before deploying, complete these steps:
- [ ] Set SQUARE_ENVIRONMENT=sandbox - [ ] Set SQUARE_ENVIRONMENT=sandbox
### 3. Deployment ### 3. Deployment
- [ ] Run `./deploy-square.sh` OR - [ ] Run `./restart.sh` OR
- [ ] Run `docker-compose down` - [ ] Run `docker compose build`
- [ ] Run `docker-compose up -d --build` - [ ] Run `docker compose run --rm frontend npm test`
- [ ] Verify containers are running: `docker-compose ps` - [ ] Run `docker compose run --rm backend pytest -q`
- [ ] Run `docker compose up -d`
- [ ] Verify containers are running: `docker compose ps`
### 4. Testing ### 4. Testing
- [ ] Access frontend at http://localhost:3000 - [ ] Access frontend at http://localhost:8050 or HTTPS at https://localhost:8443
- [ ] Login/register a user - [ ] Login/register a user
- [ ] Navigate to membership setup - [ ] Navigate to membership setup
- [ ] Select a membership tier - [ ] Select a membership tier
@@ -104,7 +106,7 @@ After deployment, run these commands to verify:
```bash ```bash
# Check backend is running # Check backend is running
curl http://localhost:8000/api/v1/payments/config/square curl http://localhost:8050/api/v1/payments/config/square
# Expected output (with your actual IDs): # Expected output (with your actual IDs):
# { # {
@@ -114,10 +116,10 @@ curl http://localhost:8000/api/v1/payments/config/square
# } # }
# Check frontend is running # Check frontend is running
curl http://localhost:3000 curl http://localhost:8050
# Check logs # Check logs
docker-compose logs backend | grep -i square docker compose logs backend | grep -i square
``` ```
## 📊 Testing Matrix ## 📊 Testing Matrix
@@ -135,13 +137,13 @@ docker-compose logs backend | grep -i square
```bash ```bash
# Check Square SDK installed # Check Square SDK installed
docker-compose exec backend pip list | grep square docker compose exec backend pip list | grep square
# Check configuration loaded # Check configuration loaded
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)" docker compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
# Check database has payments # Check database has payments
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;" docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
# Check frontend files # Check frontend files
ls -la frontend/src/components/SquarePayment.tsx ls -la frontend/src/components/SquarePayment.tsx
@@ -151,7 +153,7 @@ ls -la frontend/src/components/SquarePayment.tsx
| Issue | Solution | | Issue | Solution |
|-------|----------| |-------|----------|
| "Module not found: squareup" | Rebuild backend: `docker-compose build backend` | | "Module not found: squareup" | Rebuild backend: `docker compose build backend` |
| "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers | | "SQUARE_APPLICATION_ID not found" | Add to `.env` and restart containers |
| Square SDK not loading | Check browser console, verify script tag in index.html | | Square SDK not loading | Check browser console, verify script tag in index.html |
| Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct | | Payment fails with 401 | Check SQUARE_ACCESS_TOKEN is correct |
+9
View File
@@ -193,6 +193,15 @@ The Square payment integration is complete, tested, and working in sandbox mode:
- Users can retry failed payments - Users can retry failed payments
- Cash payments still work with PENDING status for admin approval - Cash payments still work with PENDING status for admin approval
- All payment flows properly tested with Square sandbox test cards - All payment flows properly tested with Square sandbox test cards
- `restart.sh` now runs the fast Vitest and pytest suites before restarting the stack
Fast verification commands:
```bash
./restart.sh
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
```
## Summary ## Summary
+4 -3
View File
@@ -66,13 +66,14 @@ SQUARE_APPLICATION_ID=sq0idp-... # Your Production Application ID
### 5. Restart the Application ### 5. Restart the Application
After updating the environment variables, restart your Docker containers: After updating the environment variables, run the tested restart helper:
```bash ```bash
docker-compose down ./restart.sh
docker-compose up -d --build
``` ```
For a manual restart, run `docker compose build`, `docker compose run --rm frontend npm test`, `docker compose run --rm backend pytest -q`, and then `docker compose up -d`.
## Testing with Sandbox ## Testing with Sandbox
Square provides test card numbers for sandbox testing: Square provides test card numbers for sandbox testing:
+9 -7
View File
@@ -38,19 +38,21 @@ SQUARE_APPLICATION_ID=sandbox-sq0idb-...your-app-id...
Run the deployment script: Run the deployment script:
```bash ```bash
./deploy-square.sh ./restart.sh
``` ```
Or manually: Or manually:
```bash ```bash
docker-compose down docker compose build
docker-compose up -d --build docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
docker compose up -d
``` ```
### Step 4: Test It Out! ### Step 4: Test It Out!
1. Open http://localhost:3000 1. Open http://localhost:8050 or https://localhost:8443 for HTTPS Square testing
2. Register/login 2. Register/login
3. Go to "Setup Membership" 3. Go to "Setup Membership"
4. Select a tier 4. Select a tier
@@ -78,7 +80,7 @@ docker-compose up -d --build
-`.env.example` - UPDATED -`.env.example` - UPDATED
-`SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide) -`SQUARE_PAYMENT_SETUP.md` - NEW (detailed setup guide)
-`SQUARE_IMPLEMENTATION.md` - NEW (implementation details) -`SQUARE_IMPLEMENTATION.md` - NEW (implementation details)
-`deploy-square.sh` - NEW (deployment helper) -`restart.sh` - build, fast tests, and restart helper
## 🔧 Key Features ## 🔧 Key Features
@@ -118,7 +120,7 @@ User → Select Tier → Choose Payment Method
### Backend won't start? ### Backend won't start?
```bash ```bash
docker-compose logs backend docker compose logs backend
``` ```
Check for missing dependencies or configuration errors. Check for missing dependencies or configuration errors.
@@ -156,7 +158,7 @@ When ready for production payments:
1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions 1. Check `SQUARE_PAYMENT_SETUP.md` for detailed instructions
2. Review Square's documentation 2. Review Square's documentation
3. Check application logs: `docker-compose logs -f backend` 3. Check application logs: `docker compose logs -f backend`
4. Contact Square support for payment-specific issues 4. Contact Square support for payment-specific issues
--- ---
@@ -0,0 +1,77 @@
"""Add volunteer level and dynamic profile questions
Revision ID: 2e8a0f9d4b31
Revises: b583fd2cf202
Create Date: 2026-05-04 17:50:00.000000
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = '2e8a0f9d4b31'
down_revision: Union[str, None] = 'b583fd2cf202'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.add_column('users', sa.Column('volunteer_level', sa.String(length=50), nullable=True))
op.create_table(
'profile_questions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('key', sa.String(length=100), nullable=False),
sa.Column('label', sa.String(length=255), nullable=False),
sa.Column('help_text', sa.Text(), nullable=True),
sa.Column('input_type', sa.String(length=30), nullable=False),
sa.Column('placeholder', sa.String(length=255), nullable=True),
sa.Column('options_json', sa.Text(), nullable=True),
sa.Column('is_required', sa.Boolean(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('admin_only_edit', sa.Boolean(), nullable=False),
sa.Column('display_order', sa.Integer(), nullable=False),
sa.Column('depends_on_question_id', sa.Integer(), nullable=True),
sa.Column('depends_on_value', sa.String(length=255), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['depends_on_question_id'], ['profile_questions.id']),
sa.PrimaryKeyConstraint('id'),
)
op.create_index(op.f('ix_profile_questions_id'), 'profile_questions', ['id'], unique=False)
op.create_index(op.f('ix_profile_questions_key'), 'profile_questions', ['key'], unique=True)
op.create_table(
'user_profile_answers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('question_id', sa.Integer(), nullable=False),
sa.Column('value_text', sa.Text(), nullable=True),
sa.Column('updated_by_user_id', sa.Integer(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['question_id'], ['profile_questions.id']),
sa.ForeignKeyConstraint(['updated_by_user_id'], ['users.id']),
sa.ForeignKeyConstraint(['user_id'], ['users.id']),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('user_id', 'question_id', name='uq_user_profile_answer'),
)
op.create_index(op.f('ix_user_profile_answers_id'), 'user_profile_answers', ['id'], unique=False)
op.create_index(op.f('ix_user_profile_answers_question_id'), 'user_profile_answers', ['question_id'], unique=False)
op.create_index(op.f('ix_user_profile_answers_user_id'), 'user_profile_answers', ['user_id'], unique=False)
def downgrade() -> None:
op.drop_index(op.f('ix_user_profile_answers_user_id'), table_name='user_profile_answers')
op.drop_index(op.f('ix_user_profile_answers_question_id'), table_name='user_profile_answers')
op.drop_index(op.f('ix_user_profile_answers_id'), table_name='user_profile_answers')
op.drop_table('user_profile_answers')
op.drop_index(op.f('ix_profile_questions_key'), table_name='profile_questions')
op.drop_index(op.f('ix_profile_questions_id'), table_name='profile_questions')
op.drop_table('profile_questions')
op.drop_column('users', 'volunteer_level')
+6 -5
View File
@@ -1,7 +1,7 @@
from fastapi import APIRouter, Depends from fastapi import APIRouter, Depends
from typing import Dict, Any
from app.services.feature_flag_service import feature_flags from app.services.feature_flag_service import feature_flags
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
from app.api.dependencies import get_super_admin_user
router = APIRouter() router = APIRouter()
@@ -38,10 +38,11 @@ async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
@router.post("/flags/reload") @router.post("/flags/reload")
async def reload_feature_flags(): async def reload_feature_flags(
current_user = Depends(get_super_admin_user),
):
""" """
Reload feature flags from environment variables Reload feature flags from environment variables.
This could be protected with admin permissions in production
""" """
feature_flags.reload_flags() feature_flags.reload_flags()
return {"message": "Feature flags reloaded successfully"} return {"message": "Feature flags reloaded successfully"}
+641 -17
View File
@@ -1,16 +1,188 @@
import json
from datetime import date, datetime, timedelta
from typing import Any, List, Optional
import uuid
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from typing import List
from ...core.database import get_db from ...core.database import get_db
from ...core.security import get_password_hash from ...models.models import ProfileQuestion, User, UserProfileAnswer, UserRole, PasswordResetToken
from ...models.models import User from ...schemas import (
from ...schemas import UserResponse, UserUpdate, MessageResponse MessageResponse,
ProfileAnswersUpdateRequest,
ProfileQuestionCreate,
ProfileQuestionForUser,
ProfileQuestionResponse,
ProfileQuestionUpdate,
UserResponse,
UserUpdate,
)
from ...api.dependencies import get_current_active_user, get_admin_user from ...api.dependencies import get_current_active_user, get_admin_user
from ...services.email_service import email_service
router = APIRouter() router = APIRouter()
def _parse_options(options_json: Optional[str]) -> list[dict[str, str]]:
if not options_json:
return []
try:
parsed = json.loads(options_json)
except (TypeError, json.JSONDecodeError):
return []
if not isinstance(parsed, list):
return []
normalized: list[dict[str, str]] = []
for item in parsed:
if not isinstance(item, dict):
continue
label = str(item.get("label", "")).strip()
value = str(item.get("value", "")).strip()
if label and value:
normalized.append({"label": label, "value": value})
return normalized
def _serialize_options(options: Optional[list[Any]]) -> Optional[str]:
if not options:
return None
normalized = []
for item in options:
data = item.model_dump() if hasattr(item, "model_dump") else item
normalized.append({"label": str(data["label"]), "value": str(data["value"])})
return json.dumps(normalized)
def _normalize_answer_value(question: ProfileQuestion, value: Any) -> Optional[str]:
if value is None:
return None
if isinstance(value, str) and value.strip() == "":
return None
input_type = question.input_type
if input_type == "boolean":
if isinstance(value, bool):
return "true" if value else "false"
text = str(value).strip().lower()
if text in {"true", "1", "yes", "y"}:
return "true"
if text in {"false", "0", "no", "n"}:
return "false"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid boolean answer for question '{question.key}'"
)
if input_type == "number":
try:
number = float(value)
return str(int(number)) if number.is_integer() else str(number)
except (TypeError, ValueError):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid number answer for question '{question.key}'"
)
if input_type == "date":
if isinstance(value, datetime):
return value.date().isoformat()
if isinstance(value, date):
return value.isoformat()
text = str(value).strip()
try:
parsed = datetime.strptime(text, "%Y-%m-%d")
return parsed.date().isoformat()
except ValueError:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid date answer for question '{question.key}'. Use YYYY-MM-DD"
)
if input_type == "select":
selected = str(value).strip()
option_values = {opt["value"] for opt in _parse_options(question.options_json)}
if selected not in option_values:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"Invalid selection for question '{question.key}'"
)
return selected
return str(value).strip()
def _deserialize_answer_value(question: ProfileQuestion, value_text: Optional[str]) -> Any:
if value_text is None:
return None
if question.input_type == "boolean":
return value_text.lower() == "true"
if question.input_type == "number":
try:
number = float(value_text)
return int(number) if number.is_integer() else number
except ValueError:
return value_text
return value_text
def _validate_question_dependencies(
db: Session,
depends_on_question_id: Optional[int],
depends_on_value: Optional[str],
current_question_id: Optional[int] = None,
) -> None:
if depends_on_question_id is None:
if depends_on_value is not None:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="depends_on_value requires depends_on_question_id"
)
return
dependent_question = db.query(ProfileQuestion).filter(ProfileQuestion.id == depends_on_question_id).first()
if not dependent_question:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="depends_on_question_id does not exist"
)
if current_question_id is not None and current_question_id == depends_on_question_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="A question cannot depend on itself"
)
def _normalize_volunteer_level(value: Optional[str]) -> Optional[str]:
if value is None:
return None
normalized = str(value).strip().lower()
if normalized == "":
return None
if normalized in {"yes", "true", "1"}:
return "yes"
if normalized in {"no", "false", "0"}:
return "no"
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Volunteer flag must be yes or no"
)
@router.get("/me", response_model=UserResponse) @router.get("/me", response_model=UserResponse)
async def get_current_user_profile( async def get_current_user_profile(
current_user: User = Depends(get_current_active_user) current_user: User = Depends(get_current_active_user)
@@ -27,25 +199,123 @@ async def update_current_user_profile(
): ):
"""Update current user's profile""" """Update current user's profile"""
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
# Check email uniqueness if email is being updated # Prevent privilege and volunteer-level edits through self-service profile endpoint.
if 'email' in update_data and update_data['email'] != current_user.email: update_data.pop("role", None)
existing_user = db.query(User).filter(User.email == update_data['email']).first() update_data.pop("volunteer_level", None)
if "email" in update_data and update_data["email"] != current_user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user: if existing_user:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST, status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered" detail="Email already registered"
) )
for field, value in update_data.items(): for field, value in update_data.items():
setattr(current_user, field, value) setattr(current_user, field, value)
db.commit() db.commit()
db.refresh(current_user) db.refresh(current_user)
return current_user return current_user
@router.get("/me/profile-questions", response_model=List[ProfileQuestionForUser])
async def list_my_profile_questions(
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
questions = db.query(ProfileQuestion).filter(ProfileQuestion.is_active == True).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == current_user.id).all()
answers_by_question = {answer.question_id: answer for answer in answers}
response: list[ProfileQuestionForUser] = []
for question in questions:
user_answer = answers_by_question.get(question.id)
can_edit = (not question.admin_only_edit) or (current_user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN])
response.append(
ProfileQuestionForUser(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
answer=_deserialize_answer_value(question, user_answer.value_text if user_answer else None),
can_edit=can_edit,
)
)
return response
@router.put("/me/profile-answers", response_model=MessageResponse)
async def update_my_profile_answers(
payload: ProfileAnswersUpdateRequest,
current_user: User = Depends(get_current_active_user),
db: Session = Depends(get_db)
):
if not payload.answers:
return {"message": "No changes submitted"}
question_ids = {item.question_id for item in payload.answers}
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids), ProfileQuestion.is_active == True).all()
questions_by_id = {question.id: question for question in questions}
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Questions not found: {missing_ids}"
)
for item in payload.answers:
question = questions_by_id[item.question_id]
if question.admin_only_edit:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Question '{question.label}' can only be changed by admins"
)
normalized_value = _normalize_answer_value(question, item.value)
answer = db.query(UserProfileAnswer).filter(
UserProfileAnswer.user_id == current_user.id,
UserProfileAnswer.question_id == question.id
).first()
if normalized_value is None:
if answer:
db.delete(answer)
continue
if answer:
answer.value_text = normalized_value
answer.updated_by_user_id = current_user.id
else:
db.add(UserProfileAnswer(
user_id=current_user.id,
question_id=question.id,
value_text=normalized_value,
updated_by_user_id=current_user.id,
))
db.commit()
return {"message": "Profile answers updated successfully"}
@router.get("/", response_model=List[UserResponse]) @router.get("/", response_model=List[UserResponse])
async def list_users( async def list_users(
skip: int = 0, skip: int = 0,
@@ -58,6 +328,281 @@ async def list_users(
return users return users
@router.get("/admin/profile-questions", response_model=List[ProfileQuestionResponse])
async def list_profile_questions_admin(
include_inactive: bool = True,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
query = db.query(ProfileQuestion)
if not include_inactive:
query = query.filter(ProfileQuestion.is_active == True)
questions = query.order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
return [
ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
for question in questions
]
@router.post("/admin/profile-questions", response_model=ProfileQuestionResponse)
async def create_profile_question_admin(
payload: ProfileQuestionCreate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
if payload.input_type == "select" and not payload.options:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Select questions require options"
)
_validate_question_dependencies(db, payload.depends_on_question_id, payload.depends_on_value)
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == payload.key).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Question key already exists"
)
question = ProfileQuestion(
key=payload.key,
label=payload.label,
help_text=payload.help_text,
input_type=payload.input_type,
placeholder=payload.placeholder,
options_json=_serialize_options(payload.options),
is_required=payload.is_required,
is_active=payload.is_active,
admin_only_edit=payload.admin_only_edit,
display_order=payload.display_order,
depends_on_question_id=payload.depends_on_question_id,
depends_on_value=payload.depends_on_value,
)
db.add(question)
db.commit()
db.refresh(question)
return ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
@router.put("/admin/profile-questions/{question_id}", response_model=ProfileQuestionResponse)
async def update_profile_question_admin(
question_id: int,
payload: ProfileQuestionUpdate,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
update_data = payload.model_dump(exclude_unset=True)
if "key" in update_data and update_data["key"] != question.key:
existing = db.query(ProfileQuestion).filter(ProfileQuestion.key == update_data["key"]).first()
if existing:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Question key already exists"
)
input_type = update_data.get("input_type", question.input_type)
options = update_data.get("options")
options_to_validate = options if options is not None else _parse_options(question.options_json)
if input_type == "select" and not options_to_validate:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Select questions require options"
)
depends_on_question_id = update_data.get("depends_on_question_id", question.depends_on_question_id)
depends_on_value = update_data.get("depends_on_value", question.depends_on_value)
_validate_question_dependencies(db, depends_on_question_id, depends_on_value, current_question_id=question.id)
for field, value in update_data.items():
if field == "options":
question.options_json = _serialize_options(value)
else:
setattr(question, field, value)
db.commit()
db.refresh(question)
return ProfileQuestionResponse(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
)
@router.delete("/admin/profile-questions/{question_id}", response_model=MessageResponse)
async def deactivate_profile_question_admin(
question_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
question = db.query(ProfileQuestion).filter(ProfileQuestion.id == question_id).first()
if not question:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Question not found"
)
question.is_active = False
db.commit()
return {"message": "Question deactivated successfully"}
@router.get("/admin/users/{user_id}/profile-answers", response_model=List[ProfileQuestionForUser])
async def get_user_profile_answers_admin(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
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"
)
questions = db.query(ProfileQuestion).order_by(ProfileQuestion.display_order.asc(), ProfileQuestion.id.asc()).all()
answers = db.query(UserProfileAnswer).filter(UserProfileAnswer.user_id == user_id).all()
answers_by_question = {answer.question_id: answer for answer in answers}
return [
ProfileQuestionForUser(
id=question.id,
key=question.key,
label=question.label,
help_text=question.help_text,
input_type=question.input_type,
placeholder=question.placeholder,
options=_parse_options(question.options_json),
is_required=question.is_required,
is_active=question.is_active,
admin_only_edit=question.admin_only_edit,
display_order=question.display_order,
depends_on_question_id=question.depends_on_question_id,
depends_on_value=question.depends_on_value,
created_at=question.created_at,
updated_at=question.updated_at,
answer=_deserialize_answer_value(question, answers_by_question.get(question.id).value_text if answers_by_question.get(question.id) else None),
can_edit=True,
)
for question in questions
]
@router.put("/admin/users/{user_id}/profile-answers", response_model=MessageResponse)
async def update_user_profile_answers_admin(
user_id: int,
payload: ProfileAnswersUpdateRequest,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
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"
)
if not payload.answers:
return {"message": "No changes submitted"}
question_ids = {item.question_id for item in payload.answers}
questions = db.query(ProfileQuestion).filter(ProfileQuestion.id.in_(question_ids)).all()
questions_by_id = {question.id: question for question in questions}
missing_ids = [q_id for q_id in question_ids if q_id not in questions_by_id]
if missing_ids:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"Questions not found: {missing_ids}"
)
for item in payload.answers:
question = questions_by_id[item.question_id]
normalized_value = _normalize_answer_value(question, item.value)
answer = db.query(UserProfileAnswer).filter(
UserProfileAnswer.user_id == user_id,
UserProfileAnswer.question_id == question.id
).first()
if normalized_value is None:
if answer:
db.delete(answer)
continue
if answer:
answer.value_text = normalized_value
answer.updated_by_user_id = current_user.id
else:
db.add(UserProfileAnswer(
user_id=user_id,
question_id=question.id,
value_text=normalized_value,
updated_by_user_id=current_user.id,
))
db.commit()
return {"message": "User profile answers updated successfully"}
@router.get("/{user_id}", response_model=UserResponse) @router.get("/{user_id}", response_model=UserResponse)
async def get_user( async def get_user(
user_id: int, user_id: int,
@@ -88,18 +633,97 @@ async def update_user(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail="User not found"
) )
update_data = user_update.model_dump(exclude_unset=True) update_data = user_update.model_dump(exclude_unset=True)
if "email" in update_data and update_data["email"] != user.email:
existing_user = db.query(User).filter(User.email == update_data["email"]).first()
if existing_user:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Email already registered"
)
if "role" in update_data and update_data["role"] == UserRole.SUPER_ADMIN and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can assign super admin role"
)
if "volunteer_level" in update_data:
update_data["volunteer_level"] = _normalize_volunteer_level(update_data["volunteer_level"])
for field, value in update_data.items(): for field, value in update_data.items():
setattr(user, field, value) setattr(user, field, value)
db.commit() db.commit()
db.refresh(user) db.refresh(user)
return user return user
@router.post("/{user_id}/send-password-reset", response_model=MessageResponse)
async def send_user_password_reset(
user_id: int,
current_user: User = Depends(get_admin_user),
db: Session = Depends(get_db)
):
"""Send a one-time password reset link email for a user."""
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"
)
if user.role in [UserRole.ADMIN, UserRole.SUPER_ADMIN] and current_user.role != UserRole.SUPER_ADMIN:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only super admins can send password reset emails for admin users"
)
if not user.is_active:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="Cannot reset password for inactive user"
)
db.query(PasswordResetToken).filter(
PasswordResetToken.user_id == user.id,
PasswordResetToken.used == False,
PasswordResetToken.expires_at > datetime.utcnow()
).update({"used": True})
reset_token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=1)
db_token = PasswordResetToken(
user_id=user.id,
token=reset_token,
expires_at=expires_at,
used=False
)
db.add(db_token)
db.commit()
try:
await email_service.send_password_reset_email(
to_email=user.email,
first_name=user.first_name,
reset_token=reset_token,
db=db
)
except Exception as exc:
print(f"Failed to send admin password reset email: {exc}")
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Failed to send reset email"
)
return {"message": "One-time password reset email sent successfully"}
@router.delete("/{user_id}", response_model=MessageResponse) @router.delete("/{user_id}", response_model=MessageResponse)
async def delete_user( async def delete_user(
user_id: int, user_id: int,
@@ -113,8 +737,8 @@ async def delete_user(
status_code=status.HTTP_404_NOT_FOUND, status_code=status.HTTP_404_NOT_FOUND,
detail="User not found" detail="User not found"
) )
db.delete(user) db.delete(user)
db.commit() db.commit()
return {"message": "User deleted successfully"} return {"message": "User deleted successfully"}
+12 -5
View File
@@ -1,6 +1,11 @@
from pydantic_settings import BaseSettings from pathlib import Path
from typing import List from typing import List
import os
from pydantic_settings import BaseSettings, SettingsConfigDict
PROJECT_ROOT = Path(__file__).resolve().parents[3]
BACKEND_ROOT = Path(__file__).resolve().parents[2]
class Settings(BaseSettings): class Settings(BaseSettings):
@@ -47,9 +52,11 @@ class Settings(BaseSettings):
UPLOAD_DIR: str = "/app/uploads" UPLOAD_DIR: str = "/app/uploads"
MAX_UPLOAD_SIZE: int = 10485760 # 10MB MAX_UPLOAD_SIZE: int = 10485760 # 10MB
class Config: model_config = SettingsConfigDict(
env_file = ".env" env_file=(PROJECT_ROOT / ".env", BACKEND_ROOT / ".env", ".env"),
case_sensitive = True case_sensitive=True,
extra="ignore",
)
settings = Settings() settings = Settings()
+1 -2
View File
@@ -1,6 +1,5 @@
from sqlalchemy import create_engine from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import declarative_base, sessionmaker
from sqlalchemy.orm import sessionmaker
from .config import settings from .config import settings
engine = create_engine( engine = create_engine(
+99 -1
View File
@@ -1,5 +1,7 @@
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from ..models.models import MembershipTier, User, UserRole, EmailTemplate import json
from ..models.models import MembershipTier, User, UserRole, EmailTemplate, ProfileQuestion
from .security import get_password_hash from .security import get_password_hash
from datetime import datetime from datetime import datetime
@@ -70,3 +72,99 @@ def init_default_data(db: Session):
db.add_all(default_templates) db.add_all(default_templates)
db.commit() db.commit()
print(f"✓ Created {len(default_templates)} default email templates") print(f"✓ Created {len(default_templates)} default email templates")
# Seed default profile questions for onboarding and profile attributes
existing_questions = db.query(ProfileQuestion).count()
if existing_questions == 0:
print("Creating default profile questions...")
default_questions = [
ProfileQuestion(
key="has_professional_license",
label="Do you hold a professional aviation-related license?",
help_text="Select your current license status.",
input_type="select",
options_json=json.dumps([
{"label": "No", "value": "none"},
{"label": "Student", "value": "student"},
{"label": "Private Pilot", "value": "ppl"},
{"label": "Commercial Pilot", "value": "cpl"},
{"label": "ATPL", "value": "atpl"},
{"label": "Instructor", "value": "instructor"},
]),
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=10,
),
ProfileQuestion(
key="license_number",
label="License number",
help_text="Optional: your current license number.",
input_type="text",
placeholder="e.g. UK.FCL.123456",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=20,
depends_on_value="ppl",
),
ProfileQuestion(
key="can_support_events",
label="Can you support airport or membership events?",
help_text="Choose yes if you're open to helping with events.",
input_type="boolean",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=30,
),
ProfileQuestion(
key="event_support_notes",
label="What support can you offer?",
help_text="Examples: stewarding, admin desk, setup/packdown, mentoring.",
input_type="text",
placeholder="Type details here",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=40,
depends_on_value="true",
),
ProfileQuestion(
key="hours_available_monthly",
label="Approximate volunteer hours available each month",
help_text="Optional estimate in hours.",
input_type="number",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=50,
),
ProfileQuestion(
key="medical_expiry_date",
label="Medical certificate expiry date",
help_text="Optional date in YYYY-MM-DD format.",
input_type="date",
is_required=False,
is_active=True,
admin_only_edit=False,
display_order=60,
),
ProfileQuestion(
key="completed_training_x",
label="Completed Training X",
help_text="This is set by admins once verified.",
input_type="boolean",
is_required=False,
is_active=True,
admin_only_edit=True,
display_order=70,
),
]
db.add_all(default_questions)
db.commit()
question_by_key = {question.key: question for question in db.query(ProfileQuestion).all()}
question_by_key["license_number"].depends_on_question_id = question_by_key["has_professional_license"].id
question_by_key["event_support_notes"].depends_on_question_id = question_by_key["can_support_events"].id
db.commit()
print(f"✓ Created {len(default_questions)} default profile questions")
+4
View File
@@ -15,6 +15,8 @@ from .models import (
VolunteerRole, VolunteerRole,
VolunteerAssignment, VolunteerAssignment,
VolunteerSchedule, VolunteerSchedule,
ProfileQuestion,
UserProfileAnswer,
Certificate, Certificate,
File, File,
Notification, Notification,
@@ -36,6 +38,8 @@ __all__ = [
"VolunteerRole", "VolunteerRole",
"VolunteerAssignment", "VolunteerAssignment",
"VolunteerSchedule", "VolunteerSchedule",
"ProfileQuestion",
"UserProfileAnswer",
"Certificate", "Certificate",
"File", "File",
"Notification", "Notification",
+50 -1
View File
@@ -1,6 +1,6 @@
from sqlalchemy import ( from sqlalchemy import (
Column, Integer, String, DateTime, Boolean, Enum as SQLEnum, Column, Integer, String, DateTime, Boolean, Enum as SQLEnum,
Float, Text, ForeignKey, Date Float, Text, ForeignKey, Date, UniqueConstraint
) )
from sqlalchemy.orm import relationship from sqlalchemy.orm import relationship
from datetime import datetime from datetime import datetime
@@ -60,6 +60,7 @@ class User(Base):
phone = Column(String(20), nullable=True) phone = Column(String(20), nullable=True)
address = Column(Text, nullable=True) address = Column(Text, nullable=True)
role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False) role = Column(SQLEnum(UserRole, values_callable=lambda x: [e.value for e in x]), default=UserRole.MEMBER, nullable=False)
volunteer_level = Column(String(50), nullable=True)
is_active = Column(Boolean, default=True, nullable=False) is_active = Column(Boolean, default=True, nullable=False)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False) created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
@@ -71,6 +72,54 @@ class User(Base):
event_rsvps = relationship("EventRSVP", 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") volunteer_assignments = relationship("VolunteerAssignment", back_populates="user", cascade="all, delete-orphan")
certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan") certificates = relationship("Certificate", back_populates="user", cascade="all, delete-orphan")
profile_answers = relationship(
"UserProfileAnswer",
back_populates="user",
cascade="all, delete-orphan",
foreign_keys="UserProfileAnswer.user_id"
)
class ProfileQuestion(Base):
__tablename__ = "profile_questions"
id = Column(Integer, primary_key=True, index=True)
key = Column(String(100), unique=True, nullable=False, index=True)
label = Column(String(255), nullable=False)
help_text = Column(Text, nullable=True)
input_type = Column(String(30), nullable=False) # text, number, boolean, date, select
placeholder = Column(String(255), nullable=True)
options_json = Column(Text, nullable=True) # JSON array: [{"label":"Yes","value":"true"}]
is_required = Column(Boolean, default=False, nullable=False)
is_active = Column(Boolean, default=True, nullable=False)
admin_only_edit = Column(Boolean, default=False, nullable=False)
display_order = Column(Integer, default=0, nullable=False)
depends_on_question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=True)
depends_on_value = Column(String(255), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
depends_on_question = relationship("ProfileQuestion", remote_side=[id], backref="dependent_questions")
answers = relationship("UserProfileAnswer", back_populates="question", cascade="all, delete-orphan")
class UserProfileAnswer(Base):
__tablename__ = "user_profile_answers"
__table_args__ = (
UniqueConstraint("user_id", "question_id", name="uq_user_profile_answer"),
)
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
question_id = Column(Integer, ForeignKey("profile_questions.id"), nullable=False, index=True)
value_text = Column(Text, nullable=True)
updated_by_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
user = relationship("User", foreign_keys=[user_id], back_populates="profile_answers")
question = relationship("ProfileQuestion", back_populates="answers")
updated_by_user = relationship("User", foreign_keys=[updated_by_user_id])
class MembershipTier(Base): class MembershipTier(Base):
+14
View File
@@ -37,6 +37,13 @@ from .schemas import (
EventRSVPBase, EventRSVPBase,
EventRSVPUpdate, EventRSVPUpdate,
EventRSVPResponse, EventRSVPResponse,
QuestionOption,
ProfileQuestionCreate,
ProfileQuestionUpdate,
ProfileQuestionResponse,
ProfileQuestionForUser,
ProfileAnswerUpdate,
ProfileAnswersUpdateRequest,
) )
__all__ = [ __all__ = [
@@ -78,4 +85,11 @@ __all__ = [
"EventRSVPBase", "EventRSVPBase",
"EventRSVPUpdate", "EventRSVPUpdate",
"EventRSVPResponse", "EventRSVPResponse",
"QuestionOption",
"ProfileQuestionCreate",
"ProfileQuestionUpdate",
"ProfileQuestionResponse",
"ProfileQuestionForUser",
"ProfileAnswerUpdate",
"ProfileAnswersUpdateRequest",
] ]
+80 -1
View File
@@ -1,5 +1,5 @@
from pydantic import BaseModel, EmailStr, Field, ConfigDict from pydantic import BaseModel, EmailStr, Field, ConfigDict
from typing import Optional from typing import Optional, Literal, Any
from datetime import datetime, date from datetime import datetime, date
from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod from ..models.models import UserRole, MembershipStatus, PaymentStatus, PaymentMethod
@@ -24,6 +24,7 @@ class UserUpdate(BaseModel):
phone: Optional[str] = None phone: Optional[str] = None
address: Optional[str] = None address: Optional[str] = None
role: Optional[UserRole] = None role: Optional[UserRole] = None
volunteer_level: Optional[str] = Field(None, max_length=50)
class UserResponse(UserBase): class UserResponse(UserBase):
@@ -31,6 +32,7 @@ class UserResponse(UserBase):
id: int id: int
role: UserRole role: UserRole
volunteer_level: Optional[str] = None
is_active: bool is_active: bool
created_at: datetime created_at: datetime
last_login: Optional[datetime] = None last_login: Optional[datetime] = None
@@ -285,3 +287,80 @@ class EventRSVPResponse(EventRSVPBase):
attended: bool attended: bool
created_at: datetime created_at: datetime
updated_at: datetime updated_at: datetime
# Profile Question Schemas
ProfileQuestionInputType = Literal["text", "number", "boolean", "date", "select"]
class QuestionOption(BaseModel):
label: str = Field(..., min_length=1, max_length=100)
value: str = Field(..., min_length=1, max_length=100)
class ProfileQuestionBase(BaseModel):
key: str = Field(..., min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: str = Field(..., min_length=2, max_length=255)
help_text: Optional[str] = None
input_type: ProfileQuestionInputType
placeholder: Optional[str] = Field(None, max_length=255)
options: Optional[list[QuestionOption]] = None
is_required: bool = False
is_active: bool = True
admin_only_edit: bool = False
display_order: int = 0
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionCreate(ProfileQuestionBase):
pass
class ProfileQuestionUpdate(BaseModel):
key: Optional[str] = Field(None, min_length=2, max_length=100, pattern=r"^[a-z0-9_]+$")
label: Optional[str] = Field(None, min_length=2, max_length=255)
help_text: Optional[str] = None
input_type: Optional[ProfileQuestionInputType] = None
placeholder: Optional[str] = Field(None, max_length=255)
options: Optional[list[QuestionOption]] = None
is_required: Optional[bool] = None
is_active: Optional[bool] = None
admin_only_edit: Optional[bool] = None
display_order: Optional[int] = None
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = Field(None, max_length=255)
class ProfileQuestionResponse(BaseModel):
model_config = ConfigDict(from_attributes=True)
id: int
key: str
label: str
help_text: Optional[str] = None
input_type: ProfileQuestionInputType
placeholder: Optional[str] = None
options: list[QuestionOption] = []
is_required: bool
is_active: bool
admin_only_edit: bool
display_order: int
depends_on_question_id: Optional[int] = None
depends_on_value: Optional[str] = None
created_at: datetime
updated_at: datetime
class ProfileQuestionForUser(ProfileQuestionResponse):
answer: Optional[Any] = None
can_edit: bool = True
class ProfileAnswerUpdate(BaseModel):
question_id: int
value: Optional[Any] = None
class ProfileAnswersUpdateRequest(BaseModel):
answers: list[ProfileAnswerUpdate]
+7
View File
@@ -0,0 +1,7 @@
import sys
from pathlib import Path
APP_ROOT = Path(__file__).resolve().parents[2]
if str(APP_ROOT) not in sys.path:
sys.path.insert(0, str(APP_ROOT))
@@ -0,0 +1,113 @@
from datetime import date, datetime
import pytest
from fastapi import HTTPException
from app.api.v1.users import (
_deserialize_answer_value,
_normalize_answer_value,
_normalize_volunteer_level,
_parse_options,
_serialize_options,
)
from app.models.models import ProfileQuestion
from app.schemas import QuestionOption
def make_question(input_type: str, options_json: str | None = None) -> ProfileQuestion:
return ProfileQuestion(
key=f"{input_type}_question",
label=f"{input_type.title()} Question",
input_type=input_type,
options_json=options_json,
)
def test_option_parsing_and_serialization_filters_invalid_items() -> None:
assert _parse_options('[{"label":" Yes ","value":" yes "}, {"label":"","value":"no"}, "bad"]') == [
{"label": "Yes", "value": "yes"}
]
assert _parse_options("not-json") == []
serialized = _serialize_options([QuestionOption(label="Private Pilot", value="ppl")])
assert _parse_options(serialized) == [{"label": "Private Pilot", "value": "ppl"}]
@pytest.mark.parametrize(
("value", "expected"),
[
(True, "true"),
("yes", "true"),
("0", "false"),
(False, "false"),
],
)
def test_boolean_answers_are_normalized(value: object, expected: str) -> None:
assert _normalize_answer_value(make_question("boolean"), value) == expected
def test_invalid_boolean_answer_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(make_question("boolean"), "maybe")
assert exc.value.status_code == 400
@pytest.mark.parametrize(
("value", "expected"),
[
(3, "3"),
("3.50", "3.5"),
(date(2026, 5, 4), "2026-05-04"),
(datetime(2026, 5, 4, 12, 30), "2026-05-04"),
],
)
def test_number_and_date_answers_are_normalized(value: object, expected: str) -> None:
input_type = "date" if isinstance(value, (date, datetime)) else "number"
assert _normalize_answer_value(make_question(input_type), value) == expected
def test_select_answers_must_match_configured_options() -> None:
question = make_question("select", '[{"label":"Private Pilot","value":"ppl"}]')
assert _normalize_answer_value(question, "ppl") == "ppl"
with pytest.raises(HTTPException) as exc:
_normalize_answer_value(question, "cpl")
assert exc.value.status_code == 400
def test_empty_answers_clear_existing_values() -> None:
assert _normalize_answer_value(make_question("text"), "") is None
assert _normalize_answer_value(make_question("text"), None) is None
def test_answer_deserialization_restores_frontend_types() -> None:
assert _deserialize_answer_value(make_question("boolean"), "true") is True
assert _deserialize_answer_value(make_question("boolean"), "false") is False
assert _deserialize_answer_value(make_question("number"), "10") == 10
assert _deserialize_answer_value(make_question("number"), "10.5") == 10.5
assert _deserialize_answer_value(make_question("text"), "SASA") == "SASA"
@pytest.mark.parametrize(
("value", "expected"),
[
("yes", "yes"),
("true", "yes"),
("0", "no"),
("", None),
(None, None),
],
)
def test_volunteer_level_accepts_boolean_like_values(value: str | None, expected: str | None) -> None:
assert _normalize_volunteer_level(value) == expected
def test_invalid_volunteer_level_raises_400() -> None:
with pytest.raises(HTTPException) as exc:
_normalize_volunteer_level("sometimes")
assert exc.value.status_code == 400
+4 -1
View File
@@ -6,7 +6,7 @@ pydantic-settings==2.6.1
python-multipart==0.0.6 python-multipart==0.0.6
# Database # Database
sqlalchemy==2.0.23 sqlalchemy==2.0.49
pymysql==1.1.0 pymysql==1.1.0
cryptography==41.0.7 cryptography==41.0.7
alembic==1.13.0 alembic==1.13.0
@@ -28,3 +28,6 @@ email-validator==2.1.0
aiofiles==23.2.1 aiofiles==23.2.1
Jinja2==3.1.2 Jinja2==3.1.2
python-dateutil==2.8.2 python-dateutil==2.8.2
# Tests
pytest==8.3.4
+4 -2
View File
@@ -17,10 +17,12 @@
"scripts": { "scripts": {
"dev": "vite --host 0.0.0.0", "dev": "vite --host 0.0.0.0",
"build": "tsc && vite build", "build": "tsc && vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-react": "^4.2.0", "@vitejs/plugin-react": "^4.2.0",
"vite": "^5.0.5" "vite": "^5.0.5",
"vitest": "^1.6.1"
} }
} }
+779 -114
View File
File diff suppressed because it is too large Load Diff
+47 -14
View File
@@ -6,26 +6,59 @@ import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword'; import ForgotPassword from './pages/ForgotPassword';
import ResetPassword from './pages/ResetPassword'; import ResetPassword from './pages/ResetPassword';
import Dashboard from './pages/Dashboard'; import Dashboard from './pages/Dashboard';
import EmailTemplates from './pages/EmailTemplates'; import PrivacyPolicy from './pages/PrivacyPolicy';
import MembershipTiers from './pages/MembershipTiers'; import TermsOfService from './pages/TermsOfService';
import BounceManagement from './pages/BounceManagement';
import './App.css'; import './App.css';
import { useState } from 'react';
import { Link } from 'react-router-dom';
const App: React.FC = () => { const App: React.FC = () => {
const [cookieDismissed, setCookieDismissed] = useState(
() => localStorage.getItem('cookie_notice_dismissed') === 'true'
);
const dismissCookies = () => {
localStorage.setItem('cookie_notice_dismissed', 'true');
setCookieDismissed(true);
};
return ( return (
<FeatureFlagProvider> <FeatureFlagProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <div className="app-shell">
<Route path="/" element={<Navigate to="/login" />} /> <main className="app-main">
<Route path="/register" element={<Register />} /> <Routes>
<Route path="/login" element={<Login />} /> <Route path="/" element={<Navigate to="/login" />} />
<Route path="/forgot-password" element={<ForgotPassword />} /> <Route path="/register" element={<Register />} />
<Route path="/reset-password" element={<ResetPassword />} /> <Route path="/login" element={<Login />} />
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/email-templates" element={<EmailTemplates />} /> <Route path="/reset-password" element={<ResetPassword />} />
<Route path="/membership-tiers" element={<MembershipTiers />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/bounce-management" element={<BounceManagement />} /> <Route path="/email-templates" element={<Navigate to="/dashboard" />} />
</Routes> <Route path="/membership-tiers" element={<Navigate to="/dashboard" />} />
<Route path="/bounce-management" element={<Navigate to="/dashboard" />} />
<Route path="/privacy-policy" element={<PrivacyPolicy />} />
<Route path="/terms-of-service" element={<TermsOfService />} />
</Routes>
</main>
<footer className="site-footer">
<div>
<Link to="/privacy-policy">Privacy Policy</Link>
<Link to="/terms-of-service">Terms of Service</Link>
</div>
<div style={{ marginTop: '8px' }}>SASA Portal</div>
</footer>
{!cookieDismissed && (
<div className="cookie-banner">
<div>
We use cookies for session authentication, security, and basic site functionality.
</div>
<button className="btn btn-primary" style={{ padding: '6px 12px' }} onClick={dismissCookies}>
OK
</button>
</div>
)}
</div>
</BrowserRouter> </BrowserRouter>
</FeatureFlagProvider> </FeatureFlagProvider>
); );
@@ -0,0 +1,410 @@
import React, { useEffect, useMemo, useState } from 'react';
import {
ProfileQuestion,
ProfileQuestionInputType,
ProfileQuestionOption,
ProfileQuestionUpsertData,
userService
} from '../services/membershipService';
interface AdminProfileQuestionManagerProps {
onQuestionsChanged?: () => void;
}
const INPUT_TYPES: ProfileQuestionInputType[] = ['text', 'number', 'boolean', 'date', 'select'];
const optionsToText = (options: ProfileQuestionOption[] | null | undefined): string => {
if (!options || options.length === 0) {
return '';
}
return options.map((option) => `${option.label}|${option.value}`).join('\n');
};
const textToOptions = (value: string): ProfileQuestionOption[] => {
return value
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [labelPart, valuePart] = line.split('|');
const label = (labelPart || '').trim();
const optionValue = (valuePart || labelPart || '').trim();
return { label, value: optionValue };
})
.filter((option) => option.label.length > 0 && option.value.length > 0);
};
const AdminProfileQuestionManager: React.FC<AdminProfileQuestionManagerProps> = ({ onQuestionsChanged }) => {
const [questions, setQuestions] = useState<ProfileQuestion[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [editingQuestionId, setEditingQuestionId] = useState<number | null>(null);
const [listSearch, setListSearch] = useState('');
const emptyForm: ProfileQuestionUpsertData = {
key: '',
label: '',
help_text: '',
input_type: 'text',
placeholder: '',
options: null,
is_required: false,
is_active: true,
admin_only_edit: false,
display_order: 0,
depends_on_question_id: null,
depends_on_value: null
};
const [formData, setFormData] = useState<ProfileQuestionUpsertData>(emptyForm);
const [optionsText, setOptionsText] = useState('');
const loadQuestions = async () => {
try {
setLoading(true);
const data = await userService.getAdminProfileQuestions(true);
setQuestions(data);
setError(null);
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to load questions');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadQuestions();
}, []);
const dependencyCandidates = useMemo(() => {
return questions.filter((question) => question.id !== editingQuestionId);
}, [questions, editingQuestionId]);
const selectedDependencyQuestion = useMemo(() => {
if (!formData.depends_on_question_id) {
return null;
}
return questions.find((question) => question.id === formData.depends_on_question_id) || null;
}, [questions, formData.depends_on_question_id]);
const filteredQuestions = useMemo(() => {
const term = listSearch.trim().toLowerCase();
if (!term) {
return questions;
}
return questions.filter((question) =>
question.label.toLowerCase().includes(term) ||
question.key.toLowerCase().includes(term)
);
}, [questions, listSearch]);
const resetForm = () => {
setFormData(emptyForm);
setOptionsText('');
setEditingQuestionId(null);
};
const handleEdit = (question: ProfileQuestion) => {
setEditingQuestionId(question.id);
setFormData({
key: question.key,
label: question.label,
help_text: question.help_text,
input_type: question.input_type,
placeholder: question.placeholder,
options: question.options,
is_required: question.is_required,
is_active: question.is_active,
admin_only_edit: question.admin_only_edit,
display_order: question.display_order,
depends_on_question_id: question.depends_on_question_id,
depends_on_value: question.depends_on_value
});
setOptionsText(optionsToText(question.options));
};
const handleSave = async () => {
setSaving(true);
setError(null);
try {
const payload: ProfileQuestionUpsertData = {
...formData,
key: formData.key.trim(),
label: formData.label.trim(),
help_text: formData.help_text?.trim() || null,
placeholder: formData.placeholder?.trim() || null,
depends_on_value: formData.depends_on_value?.trim() || null,
options: formData.input_type === 'select' ? textToOptions(optionsText) : null,
};
if (editingQuestionId) {
await userService.updateAdminProfileQuestion(editingQuestionId, payload);
} else {
await userService.createAdminProfileQuestion(payload);
}
await loadQuestions();
resetForm();
onQuestionsChanged?.();
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save question');
} finally {
setSaving(false);
}
};
const handleDeactivate = async (questionId: number) => {
if (!window.confirm('Deactivate this question? Existing answers are kept.')) {
return;
}
try {
await userService.deactivateAdminProfileQuestion(questionId);
await loadQuestions();
onQuestionsChanged?.();
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to deactivate question');
}
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '10px' }}>Profile Questions (Admin)</h3>
<p style={{ marginBottom: '16px', color: '#555' }}>
Manage the set of profile questions users can answer. You can add follow-up questions with dependencies.
</p>
{error && <div className="alert alert-error">{error}</div>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '20px' }}>
<input
type="text"
placeholder="Question key (e.g. pilot_license_type)"
value={formData.key}
onChange={(event) => setFormData((prev) => ({ ...prev, key: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Question label"
value={formData.label}
onChange={(event) => setFormData((prev) => ({ ...prev, label: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<textarea
placeholder="Help text (optional)"
value={formData.help_text || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, help_text: event.target.value }))}
rows={2}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.input_type}
onChange={(event) => setFormData((prev) => ({ ...prev, input_type: event.target.value as ProfileQuestionInputType }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
{INPUT_TYPES.map((type) => (
<option key={type} value={type}>{type}</option>
))}
</select>
<input
type="number"
placeholder="Display order"
value={formData.display_order ?? 0}
onChange={(event) => setFormData((prev) => ({ ...prev, display_order: Number(event.target.value) }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
<input
type="text"
placeholder="Placeholder"
value={formData.placeholder || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, placeholder: event.target.value }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(180px, 1fr))', gap: '10px' }}>
<select
value={formData.depends_on_question_id ?? ''}
onChange={(event) => {
const nextValue = event.target.value;
setFormData((prev) => ({
...prev,
depends_on_question_id: nextValue ? Number(nextValue) : null,
depends_on_value: null
}));
}}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">No dependency</option>
{dependencyCandidates.map((question) => (
<option key={question.id} value={question.id}>{question.label}</option>
))}
</select>
{!selectedDependencyQuestion && (
<input
type="text"
placeholder="Choose a dependency question first"
value=""
disabled
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px', background: '#f5f7fa' }}
/>
)}
{selectedDependencyQuestion?.input_type === 'select' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
{selectedDependencyQuestion.options.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{selectedDependencyQuestion?.input_type === 'boolean' && (
<select
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
>
<option value="">Any answered value</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
)}
{selectedDependencyQuestion && !['select', 'boolean'].includes(selectedDependencyQuestion.input_type) && (
<input
type={selectedDependencyQuestion.input_type === 'number' ? 'number' : selectedDependencyQuestion.input_type === 'date' ? 'date' : 'text'}
placeholder="Show when parent answer equals..."
value={formData.depends_on_value || ''}
onChange={(event) => setFormData((prev) => ({ ...prev, depends_on_value: event.target.value || null }))}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
</div>
{formData.input_type === 'select' && (
<textarea
value={optionsText}
onChange={(event) => setOptionsText(event.target.value)}
rows={4}
placeholder={'Options (one per line):\nNo|none\nPrivate Pilot|ppl'}
style={{ padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
)}
<div style={{ display: 'flex', gap: '16px', flexWrap: 'wrap' }}>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_required)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_required: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Required
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.admin_only_edit)}
onChange={(event) => setFormData((prev) => ({ ...prev, admin_only_edit: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Admin-only edits
</label>
<label>
<input
type="checkbox"
checked={Boolean(formData.is_active)}
onChange={(event) => setFormData((prev) => ({ ...prev, is_active: event.target.checked }))}
style={{ marginRight: '6px' }}
/>
Active
</label>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !formData.key || !formData.label}>
{saving ? 'Saving...' : editingQuestionId ? 'Update Question' : 'Create Question'}
</button>
{editingQuestionId && (
<button className="btn btn-secondary" onClick={resetForm}>
Cancel Edit
</button>
)}
</div>
</div>
<h4 style={{ marginBottom: '10px' }}>Existing Questions</h4>
<input
type="text"
placeholder="Search by label or key..."
value={listSearch}
onChange={(event) => setListSearch(event.target.value)}
style={{ marginBottom: '10px', width: '100%', padding: '8px', border: '1px solid #ddd', borderRadius: '4px' }}
/>
{loading ? (
<p>Loading questions...</p>
) : (
<div className="table-container">
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid #ddd' }}>
<th style={{ padding: '8px', textAlign: 'left' }}>Order</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Label</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Type</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Key</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Status</th>
<th style={{ padding: '8px', textAlign: 'left' }}>Actions</th>
</tr>
</thead>
<tbody>
{filteredQuestions.map((question) => (
<tr key={question.id} style={{ borderBottom: '1px solid #eee' }}>
<td style={{ padding: '8px' }}>{question.display_order}</td>
<td style={{ padding: '8px' }}>
{question.label}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</td>
<td style={{ padding: '8px' }}>{question.input_type}</td>
<td style={{ padding: '8px' }}>{question.key}</td>
<td style={{ padding: '8px' }}>{question.is_active ? 'Active' : 'Inactive'}</td>
<td style={{ padding: '8px' }}>
<button className="btn btn-secondary" style={{ fontSize: '12px', padding: '4px 8px', marginRight: '6px' }} onClick={() => handleEdit(question)}>
Edit
</button>
{question.is_active && (
<button className="btn btn-danger" style={{ fontSize: '12px', padding: '4px 8px' }} onClick={() => handleDeactivate(question.id)}>
Deactivate
</button>
)}
</td>
</tr>
))}
</tbody>
</table>
{filteredQuestions.length === 0 && (
<p style={{ padding: '10px', color: '#666' }}>No questions match your search.</p>
)}
</div>
)}
</div>
);
};
export default AdminProfileQuestionManager;
@@ -159,10 +159,14 @@ const EmailTemplateManagement: React.FC = () => {
overflow: 'auto', overflow: 'auto',
fontSize: '13px', fontSize: '13px',
lineHeight: '1.4', lineHeight: '1.4',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
color: '#333' color: '#333'
}} }}
dangerouslySetInnerHTML={{ __html: template.html_body.substring(0, 300) + '...' }} >
/> {template.html_body.substring(0, 300)}
{template.html_body.length > 300 ? '...' : ''}
</div>
</div> </div>
</div> </div>
))} ))}
@@ -398,4 +402,4 @@ const EmailTemplateEditForm: React.FC<EmailTemplateEditFormProps> = ({ template,
); );
}; };
export default EmailTemplateManagement; export default EmailTemplateManagement;
+4 -35
View File
@@ -9,7 +9,7 @@ interface ProfileMenuProps {
onEditProfile?: () => void; onEditProfile?: () => void;
} }
const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onEditProfile }) => { const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, user, onEditProfile }) => {
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [showChangePassword, setShowChangePassword] = useState(false); const [showChangePassword, setShowChangePassword] = useState(false);
const menuRef = useRef<HTMLDivElement>(null); const menuRef = useRef<HTMLDivElement>(null);
@@ -135,42 +135,11 @@ const ProfileMenu: React.FC<ProfileMenuProps> = ({ userName, userRole, user, onE
)} )}
{/* Menu Items */} {/* Menu Items */}
{userRole === 'super_admin' && (
<>
<button
style={{ ...menuItemStyle, borderRadius: user ? '0' : '4px 4px 0 0' }}
onClick={() => {
navigate('/membership-tiers');
setIsOpen(false);
}}
>
Membership Tiers
</button>
<button
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
onClick={() => {
navigate('/email-templates');
setIsOpen(false);
}}
>
Email Templates
</button>
<button
style={{ ...menuItemStyle, borderTop: '1px solid #eee', borderRadius: '0' }}
onClick={() => {
navigate('/bounce-management');
setIsOpen(false);
}}
>
Bounce Management
</button>
</>
)}
<button <button
style={{ style={{
...menuItemStyle, ...menuItemStyle,
borderRadius: '0', borderRadius: user ? '0' : '4px 4px 0 0',
borderTop: (userRole === 'super_admin' || user) ? '1px solid #eee' : 'none' borderTop: user ? '1px solid #eee' : 'none'
}} }}
onClick={handleChangePassword} onClick={handleChangePassword}
> >
@@ -322,4 +291,4 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
); );
}; };
export default ProfileMenu; export default ProfileMenu;
@@ -0,0 +1,310 @@
import React, { useEffect, useMemo, useState } from 'react';
import { ProfileAnswerInput, ProfileQuestionForUser } from '../services/membershipService';
import {
answerToComparable,
canEditProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from '../utils/profileQuestionLogic';
interface ProfileQuestionsFormProps {
title: string;
description?: string;
questions: ProfileQuestionForUser[];
onSave: (answers: ProfileAnswerInput[]) => Promise<void>;
saveLabel?: string;
allowAdminManagedEdit?: boolean;
}
const formatAnswerForDisplay = (question: ProfileQuestionForUser, value: ProfileQuestionAnswerValue): string => {
if (value === null || value === undefined || value === '') {
return 'Not set';
}
if (question.input_type === 'boolean') {
return value === true || value === 'true' ? 'Yes' : 'No';
}
if (question.input_type === 'select') {
const matchingOption = question.options.find((option) => option.value === String(value));
return matchingOption?.label || String(value);
}
return String(value);
};
const ProfileQuestionsForm: React.FC<ProfileQuestionsFormProps> = ({
title,
description,
questions,
onSave,
saveLabel = 'Save Answers',
allowAdminManagedEdit = false
}) => {
const initialAnswers = useMemo(() => {
const values: Record<number, ProfileQuestionAnswerValue> = {};
questions.forEach((question) => {
values[question.id] = question.answer ?? null;
});
return values;
}, [questions]);
const [answers, setAnswers] = useState<Record<number, ProfileQuestionAnswerValue>>(initialAnswers);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const pageSize = 10;
useEffect(() => {
setAnswers(initialAnswers);
setSuccessMessage(null);
setError(null);
}, [initialAnswers]);
const visibleQuestions = useMemo(() => {
const byId = new Map<number, ProfileQuestionForUser>();
questions.forEach((question) => byId.set(question.id, question));
return questions.filter((question) => isProfileQuestionVisible(question, byId, answers));
}, [questions, answers]);
const filteredQuestions = useMemo(() => {
const searchTerm = search.trim().toLowerCase();
return visibleQuestions
.filter((question) => {
if (!searchTerm) {
return true;
}
return (
question.label.toLowerCase().includes(searchTerm) ||
question.key.toLowerCase().includes(searchTerm) ||
(question.help_text || '').toLowerCase().includes(searchTerm)
);
});
}, [visibleQuestions, search]);
const paginatedQuestions = useMemo(() => {
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return filteredQuestions.slice(start, start + pageSize);
}, [filteredQuestions, page]);
const totalPages = Math.max(1, Math.ceil(filteredQuestions.length / pageSize));
const setAnswerValue = (questionId: number, value: ProfileQuestionAnswerValue) => {
setAnswers((prev) => ({
...prev,
[questionId]: value
}));
setSuccessMessage(null);
setError(null);
};
useEffect(() => {
setPage(1);
}, [search]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccessMessage(null);
try {
const visibleQuestionIds = new Set(visibleQuestions.map((question) => question.id));
const changedAnswers: ProfileAnswerInput[] = questions
.filter((question) => canEditProfileQuestion(question, allowAdminManagedEdit) && visibleQuestionIds.has(question.id))
.filter((question) => {
const current = answerToComparable(answers[question.id] ?? null);
const initial = answerToComparable(initialAnswers[question.id] ?? null);
return current !== initial;
})
.map((question) => ({
question_id: question.id,
value: answers[question.id] ?? null
}));
await onSave(changedAnswers);
setSuccessMessage(changedAnswers.length > 0 ? 'Saved successfully.' : 'No changes to save.');
} catch (err: any) {
setError(err.response?.data?.detail || err.message || 'Failed to save answers.');
} finally {
setSaving(false);
}
};
const renderField = (question: ProfileQuestionForUser) => {
const value = answers[question.id] ?? null;
const disabled = !canEditProfileQuestion(question, allowAdminManagedEdit) || saving;
if (disabled && !saving) {
return (
<div className="profile-question-readonly">
{formatAnswerForDisplay(question, value)}
</div>
);
}
if (question.input_type === 'boolean') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === '') {
setAnswerValue(question.id, null);
} else {
setAnswerValue(question.id, nextValue === 'true');
}
}}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Prefer not to say</option>
<option value="true">Yes</option>
<option value="false">No</option>
</select>
);
}
if (question.input_type === 'select') {
return (
<select
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
>
<option value="">Select an option</option>
{question.options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
);
}
if (question.input_type === 'date') {
return (
<input
type="date"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
if (question.input_type === 'number') {
return (
<input
type="number"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value === '' ? null : Number(event.target.value))}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
}
return (
<input
type="text"
value={value === null ? '' : String(value)}
onChange={(event) => setAnswerValue(question.id, event.target.value || null)}
disabled={disabled}
placeholder={question.placeholder || ''}
style={{ width: '100%', padding: '8px', borderRadius: '4px', border: '1px solid #ddd' }}
/>
);
};
return (
<div className="card" style={{ marginTop: '20px' }}>
<h3 style={{ marginBottom: '8px' }}>{title}</h3>
{description && <p style={{ color: '#555', marginBottom: '16px' }}>{description}</p>}
<div style={{ display: 'grid', gap: '10px', marginBottom: '14px' }}>
<input
type="text"
placeholder="Search questions..."
value={search}
onChange={(event) => setSearch(event.target.value)}
style={{ width: '100%', padding: '9px 10px', borderRadius: '6px', border: '1px solid #d5d9e0' }}
/>
</div>
{error && (
<div className="alert alert-error">
{error}
</div>
)}
{successMessage && (
<div className="alert alert-success">
{successMessage}
</div>
)}
{filteredQuestions.length === 0 ? (
<p style={{ color: '#666' }}>No questions available.</p>
) : (
<div style={{ display: 'grid', gap: '16px' }}>
{paginatedQuestions.map((question) => (
<div key={question.id} className="profile-question-row">
<div className="profile-question-meta">
<label style={{ display: 'block', fontWeight: 600, marginBottom: '4px' }}>
{question.label}
{question.is_required && <span style={{ color: '#dc3545' }}> *</span>}
{question.admin_only_edit && (
<span style={{ backgroundColor: '#eef2ff', color: '#3730a3', marginLeft: '8px', padding: '2px 7px', borderRadius: '999px', fontWeight: 600, fontSize: '12px' }}>
Admin Managed
</span>
)}
</label>
{question.help_text && (
<p style={{ marginBottom: '0', color: '#666', fontSize: '13px' }}>{question.help_text}</p>
)}
</div>
<div className="profile-question-answer">{renderField(question)}</div>
{!canEditProfileQuestion(question, allowAdminManagedEdit) && (
<p style={{ marginTop: '6px', color: '#5b6472', fontSize: '12px', fontWeight: 600, gridColumn: '1 / -1' }}>
This field can only be changed by an admin.
</p>
)}
</div>
))}
</div>
)}
{filteredQuestions.length > pageSize && (
<div style={{ marginTop: '14px', display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '10px' }}>
<span style={{ fontSize: '13px', color: '#525a66' }}>
Page {Math.min(page, totalPages)} of {totalPages} ({filteredQuestions.length} questions)
</span>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page <= 1} onClick={() => setPage((prev) => Math.max(1, prev - 1))}>
Previous
</button>
<button className="btn btn-secondary" style={{ padding: '6px 12px', fontSize: '13px' }} disabled={page >= totalPages} onClick={() => setPage((prev) => Math.min(totalPages, prev + 1))}>
Next
</button>
</div>
</div>
)}
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving}>
{saving ? 'Saving...' : saveLabel}
</button>
</div>
</div>
);
};
export default ProfileQuestionsForm;
+2 -2
View File
@@ -163,7 +163,7 @@ interface TierManagementProps {
onCancelEdit: () => void; onCancelEdit: () => void;
} }
const TierManagement: React.FC<TierManagementProps> = ({ export const TierManagement: React.FC<TierManagementProps> = ({
tiers, tiers,
loading, loading,
showCreateForm, showCreateForm,
@@ -384,4 +384,4 @@ const TierForm: React.FC<TierFormProps> = ({ initialData, onSubmit, onCancel, ti
); );
}; };
export default SuperAdminMenu; export default SuperAdminMenu;
File diff suppressed because it is too large Load Diff
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
const PrivacyPolicy: React.FC = () => {
return (
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
<div className="card">
<h2 style={{ marginBottom: '12px' }}>Privacy Policy</h2>
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
Privacy policy content will be added here.
</p>
</div>
</div>
);
};
export default PrivacyPolicy;
+16
View File
@@ -0,0 +1,16 @@
import React from 'react';
const TermsOfService: React.FC = () => {
return (
<div className="container" style={{ paddingTop: '24px', paddingBottom: '24px' }}>
<div className="card">
<h2 style={{ marginBottom: '12px' }}>Terms of Service</h2>
<p style={{ color: '#4b5563', lineHeight: 1.7 }}>
Terms of service content will be added here.
</p>
</div>
</div>
);
};
export default TermsOfService;
@@ -26,11 +26,62 @@ export interface User {
phone: string | null; phone: string | null;
address: string | null; address: string | null;
role: string; role: string;
volunteer_level: string | null;
is_active: boolean; is_active: boolean;
created_at: string; created_at: string;
last_login: string | null; last_login: string | null;
} }
export type ProfileQuestionInputType = 'text' | 'number' | 'boolean' | 'date' | 'select';
export interface ProfileQuestionOption {
label: string;
value: string;
}
export interface ProfileQuestion {
id: number;
key: string;
label: string;
help_text: string | null;
input_type: ProfileQuestionInputType;
placeholder: string | null;
options: ProfileQuestionOption[];
is_required: boolean;
is_active: boolean;
admin_only_edit: boolean;
display_order: number;
depends_on_question_id: number | null;
depends_on_value: string | null;
created_at: string;
updated_at: string;
}
export interface ProfileQuestionForUser extends ProfileQuestion {
answer: string | number | boolean | null;
can_edit: boolean;
}
export interface ProfileQuestionUpsertData {
key: string;
label: string;
help_text?: string | null;
input_type: ProfileQuestionInputType;
placeholder?: string | null;
options?: ProfileQuestionOption[] | null;
is_required?: boolean;
is_active?: boolean;
admin_only_edit?: boolean;
display_order?: number;
depends_on_question_id?: number | null;
depends_on_value?: string | null;
}
export interface ProfileAnswerInput {
question_id: number;
value: string | number | boolean | null;
}
export interface MembershipTier { export interface MembershipTier {
id: number; id: number;
name: string; name: string;
@@ -230,6 +281,51 @@ export const userService = {
const response = await api.delete(`/users/${userId}`); const response = await api.delete(`/users/${userId}`);
return response.data; return response.data;
}, },
async getMyProfileQuestions(): Promise<ProfileQuestionForUser[]> {
const response = await api.get('/users/me/profile-questions');
return response.data;
},
async updateMyProfileAnswers(answers: ProfileAnswerInput[]): Promise<{ message: string }> {
const response = await api.put('/users/me/profile-answers', { answers });
return response.data;
},
async getAdminProfileQuestions(includeInactive: boolean = true): Promise<ProfileQuestion[]> {
const response = await api.get(`/users/admin/profile-questions?include_inactive=${includeInactive}`);
return response.data;
},
async createAdminProfileQuestion(data: ProfileQuestionUpsertData): Promise<ProfileQuestion> {
const response = await api.post('/users/admin/profile-questions', data);
return response.data;
},
async updateAdminProfileQuestion(questionId: number, data: Partial<ProfileQuestionUpsertData>): Promise<ProfileQuestion> {
const response = await api.put(`/users/admin/profile-questions/${questionId}`, data);
return response.data;
},
async deactivateAdminProfileQuestion(questionId: number): Promise<{ message: string }> {
const response = await api.delete(`/users/admin/profile-questions/${questionId}`);
return response.data;
},
async getUserProfileAnswers(userId: number): Promise<ProfileQuestionForUser[]> {
const response = await api.get(`/users/admin/users/${userId}/profile-answers`);
return response.data;
},
async updateUserProfileAnswers(userId: number, answers: ProfileAnswerInput[]): Promise<{ message: string }> {
const response = await api.put(`/users/admin/users/${userId}/profile-answers`, { answers });
return response.data;
},
async sendUserPasswordReset(userId: number): Promise<{ message: string }> {
const response = await api.post(`/users/${userId}/send-password-reset`);
return response.data;
},
}; };
export const membershipService = { export const membershipService = {
@@ -0,0 +1,42 @@
import { describe, expect, it } from 'vitest';
import {
canEditProfileQuestion,
DependentProfileQuestion,
isProfileQuestionVisible,
ProfileQuestionAnswerValue
} from './profileQuestionLogic';
describe('profile question logic', () => {
it('keeps admin-managed questions read-only outside admin editing mode', () => {
const question = {
id: 1,
admin_only_edit: true,
can_edit: true
};
expect(canEditProfileQuestion(question, false)).toBe(false);
expect(canEditProfileQuestion(question, true)).toBe(true);
});
it('does not allow editing when the API marks a question read-only', () => {
expect(canEditProfileQuestion({ id: 1, admin_only_edit: false, can_edit: false }, true)).toBe(false);
});
it('shows dependent questions when boolean answers match', () => {
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'true' };
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: true };
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(true);
});
it('hides dependent questions when select answers do not match', () => {
const parent: DependentProfileQuestion = { id: 1, depends_on_question_id: null, depends_on_value: null };
const child: DependentProfileQuestion = { id: 2, depends_on_question_id: 1, depends_on_value: 'completed' };
const questionsById = new Map<number, DependentProfileQuestion>([[parent.id, parent], [child.id, child]]);
const answers: Record<number, ProfileQuestionAnswerValue> = { 1: 'pending' };
expect(isProfileQuestionVisible(child, questionsById, answers)).toBe(false);
});
});
@@ -0,0 +1,62 @@
export type ProfileQuestionAnswerValue = string | number | boolean | null;
export interface EditableProfileQuestion {
id: number;
admin_only_edit: boolean;
can_edit: boolean;
}
export interface DependentProfileQuestion {
id: number;
depends_on_question_id: number | null;
depends_on_value: string | null;
}
export const answerToComparable = (value: ProfileQuestionAnswerValue): string | null => {
if (value === null || value === undefined) {
return null;
}
if (typeof value === 'boolean') {
return value ? 'true' : 'false';
}
return String(value);
};
export const canEditProfileQuestion = (
question: EditableProfileQuestion,
allowAdminManagedEdit = false
): boolean => {
if (!question.can_edit) {
return false;
}
if (question.admin_only_edit && !allowAdminManagedEdit) {
return false;
}
return true;
};
export const isProfileQuestionVisible = <TQuestion extends DependentProfileQuestion>(
question: TQuestion,
questionsById: Map<number, TQuestion>,
answers: Record<number, ProfileQuestionAnswerValue>
): boolean => {
if (!question.depends_on_question_id) {
return true;
}
const parentQuestion = questionsById.get(question.depends_on_question_id);
if (!parentQuestion) {
return true;
}
const parentAnswer = answerToComparable(answers[parentQuestion.id] ?? null);
if (question.depends_on_value === null || question.depends_on_value === undefined) {
return parentAnswer !== null && parentAnswer !== '';
}
return parentAnswer === question.depends_on_value;
};
Executable
+8
View File
@@ -0,0 +1,8 @@
#!/usr/bin/env bash
set -euo pipefail
docker compose build
docker compose run --rm frontend npm test
docker compose run --rm backend pytest -q
docker compose down
docker compose up -d