Compare commits

...

2 Commits

Author SHA1 Message Date
James Pattinson
e1659c07ea Arch changes and feature flags 2025-11-23 15:46:51 +00:00
James Pattinson
6f1d09cd77 Using alembic 2025-11-22 21:18:43 +00:00
28 changed files with 1174 additions and 391 deletions

View File

@@ -104,7 +104,7 @@ docker-compose logs -f
**Admin**: admin@swanseaairport.org / admin123
**Database**: membership_user / SecureMembershipPass2024!
**Database**: Configured via environment variables (see .env file)
## What's Next

View File

@@ -78,11 +78,11 @@ docker-compose up -d --build
# Check status
docker-compose ps
# Access MySQL CLI
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
# Access MySQL CLI (using environment variables)
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
# Create database backup
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
```
## Default Admin Access

View File

@@ -25,6 +25,11 @@ A comprehensive membership management system built with FastAPI, MySQL, and Dock
```
membership/
├── backend/
│ ├── alembic/ # Database migration scripts
│ │ ├── versions/ # Migration files
│ │ ├── env.py # Migration environment
│ │ └── script.py.mako # Migration template
│ ├── alembic.ini # Alembic configuration
│ ├── app/
│ │ ├── api/
│ │ │ ├── v1/
@@ -46,7 +51,7 @@ membership/
│ ├── Dockerfile
│ └── requirements.txt
├── database/
│ └── init.sql # Database initialization
│ └── init.sql # Legacy database initialization (deprecated - use Alembic migrations)
├── docker-compose.yml
├── .env.example
└── README.md
@@ -205,16 +210,36 @@ docker-compose ps
### Database Operations
```bash
# Access MySQL CLI
docker exec -it membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db
# Access MySQL CLI (using environment variables)
docker exec -it membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}"
# Create backup
docker exec membership_mysql mysqldump -u membership_user -pSecureMembershipPass2024! membership_db > backup_$(date +%Y%m%d_%H%M%S).sql
docker exec membership_mysql mysqldump -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" > backup_$(date +%Y%m%d_%H%M%S).sql
# Restore database
docker exec -i membership_mysql mysql -u membership_user -pSecureMembershipPass2024! membership_db < backup.sql
docker exec -i membership_mysql mysql -u "${DATABASE_USER}" -p"${DATABASE_PASSWORD}" "${DATABASE_NAME}" < backup.sql
```
### Database Migrations
The application uses Alembic for database schema migrations. Migrations are automatically run when the backend container starts.
```bash
# Create a new migration (after making model changes)
sudo docker compose exec backend alembic revision --autogenerate -m "Description of changes"
# Apply migrations
sudo docker compose exec backend alembic upgrade head
# View migration status
sudo docker compose exec backend alembic current
# View migration history
sudo docker compose exec backend alembic history
```
**Note**: The `database/init.sql` file is deprecated. All schema changes should now be made through Alembic migrations.
## API Testing
You can use the interactive API documentation at http://localhost:8000/docs to test endpoints:

View File

@@ -141,7 +141,7 @@ docker-compose exec backend pip list | grep square
docker-compose exec backend python -c "from app.core.config import settings; print(settings.SQUARE_ENVIRONMENT)"
# Check database has payments
docker-compose exec mysql mysql -u membership_user -p -e "SELECT * FROM membership_db.payments LIMIT 5;"
docker-compose exec mysql mysql -u "${DATABASE_USER}" -p -e "SELECT * FROM ${DATABASE_NAME}.payments LIMIT 5;"
# Check frontend files
ls -la frontend/src/components/SquarePayment.tsx

118
backend/alembic.ini Normal file
View File

@@ -0,0 +1,118 @@
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = alembic
# template used to generate migration file names; The default value is %%(rev)s_%%(slug)s
# Uncomment the line below if you want the files to be prepended with date and time
# see https://alembic.sqlalchemy.org/en/latest/tutorial.html#editing-the-ini-file
# for all available tokens
# file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s
# sys.path path, will be prepended to sys.path if present.
# defaults to the current working directory.
prepend_sys_path = .
# timezone to use when rendering the date within the migration file
# as well as the filename.
# If specified, requires the python>=3.9 or backports.zoneinfo library.
# Any required deps can installed by adding `alembic[tz]` to the pip requirements
# string value is passed to ZoneInfo()
# leave blank for localtime
# timezone =
# max length of characters to apply to the
# "slug" field
# truncate_slug_length = 40
# set to 'true' to run the environment during
# the 'revision' command, regardless of autogenerate
# revision_environment = false
# set to 'true' to allow .pyc and .pyo files without
# a source .py file to be detected as revisions in the
# versions/ directory
# sourceless = false
# version location specification; This defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path.
# The path separator used here should be the separator specified by "version_path_separator" below.
# version_locations = %(here)s/bar:%(here)s/bat:alembic/versions
# version path separator; As mentioned above, this is the character used to split
# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep.
# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas.
# Valid values for version_path_separator are:
#
# version_path_separator = :
# version_path_separator = ;
# version_path_separator = space
version_path_separator = os # Use os.pathsep. Default configuration used for new projects.
# set to 'true' to search source files recursively
# in each "version_locations" directory
# new in Alembic version 1.10
# recursive_version_locations = false
# the output encoding used when revision files
# are written from script.py.mako
# output_encoding = utf-8
# Database URL - will be overridden by environment variables in production
# sqlalchemy.url = mysql+pymysql://username:password@host:port/database
sqlalchemy.url = driver://user:pass@localhost/dbname
[post_write_hooks]
# post_write_hooks defines scripts or Python functions that are run
# on newly generated revision scripts. See the documentation for further
# detail and examples
# format using "black" - use the console_scripts runner, against the "black" entrypoint
# hooks = black
# black.type = console_scripts
# black.entrypoint = black
# black.options = -l 79 REVISION_SCRIPT_FILENAME
# lint with attempts to fix using "ruff" - use the exec runner, execute a binary
# hooks = ruff
# ruff.type = exec
# ruff.executable = %(here)s/.venv/bin/ruff
# ruff.options = --fix REVISION_SCRIPT_FILENAME
# Logging configuration
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

1
backend/alembic/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration.

97
backend/alembic/env.py Normal file
View File

@@ -0,0 +1,97 @@
from logging.config import fileConfig
import os
from sqlalchemy import engine_from_config
from sqlalchemy import pool
from alembic import context
# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config
# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# add your model's MetaData object here
# for 'autogenerate' support
from app.models.models import Base
target_metadata = Base.metadata
# Set database URL from environment variables if available
def get_database_url():
"""Get database URL from environment variables or config"""
# Try to get from environment variables first
db_host = os.getenv("DATABASE_HOST")
db_port = os.getenv("DATABASE_PORT", "3306")
db_user = os.getenv("DATABASE_USER")
db_password = os.getenv("DATABASE_PASSWORD")
db_name = os.getenv("DATABASE_NAME")
if all([db_host, db_user, db_password, db_name]):
return f"mysql+pymysql://{db_user}:{db_password}@{db_host}:{db_port}/{db_name}"
# Fallback to config file
return config.get_main_option("sqlalchemy.url")
# Set the database URL
config.set_main_option("sqlalchemy.url", get_database_url())
# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode.
This configures the context with just a URL
and not an Engine, though an Engine is acceptable
here as well. By skipping the Engine creation
we don't even need a DBAPI to be available.
Calls to context.execute() here emit the given string to the
script output.
"""
url = get_database_url()
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode.
In this scenario we need to create an Engine
and associate a connection with the context.
"""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection, target_metadata=target_metadata
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,300 @@
"""Create initial database schema from scratch
Revision ID: b583fd2cf202
Revises: f9fbfa70654e
Create Date: 2025-11-22 21:02:08.977255
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'b583fd2cf202'
down_revision: Union[str, None] = 'f9fbfa70654e'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# Create tables in dependency order (no foreign keys first, then with foreign keys)
# Users table (no dependencies)
op.create_table('users',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('hashed_password', sa.String(length=255), nullable=False),
sa.Column('first_name', sa.String(length=100), nullable=False),
sa.Column('last_name', sa.String(length=100), nullable=False),
sa.Column('phone', sa.String(length=20), nullable=True),
sa.Column('address', sa.Text(), nullable=True),
sa.Column('role', sa.Enum('member', 'admin', 'super_admin', name='userrole'), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.Column('last_login', sa.DateTime(), nullable=True),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_users_email'), 'users', ['email'], unique=True)
op.create_index(op.f('ix_users_id'), 'users', ['id'], unique=False)
# Membership tiers table (no dependencies)
op.create_table('membership_tiers',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('annual_fee', sa.Float(), nullable=False),
sa.Column('benefits', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_membership_tiers_id'), 'membership_tiers', ['id'], unique=False)
op.create_index('ix_membership_tiers_name', 'membership_tiers', ['name'], unique=True)
# Volunteer roles table (no dependencies)
op.create_table('volunteer_roles',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_roles_id'), 'volunteer_roles', ['id'], unique=False)
# Email templates table (no dependencies)
op.create_table('email_templates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('template_key', sa.String(length=100), nullable=False),
sa.Column('name', sa.String(length=255), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('html_body', sa.Text(), nullable=False),
sa.Column('text_body', sa.Text(), nullable=True),
sa.Column('variables', sa.Text(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_templates_id'), 'email_templates', ['id'], unique=False)
op.create_index(op.f('ix_email_templates_template_key'), 'email_templates', ['template_key'], unique=True)
# Email bounces table (no dependencies)
op.create_table('email_bounces',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('email', sa.String(length=255), nullable=False),
sa.Column('bounce_type', sa.Enum('hard', 'soft', 'complaint', 'unsubscribe', name='bouncetype'), nullable=False),
sa.Column('bounce_reason', sa.String(length=500), nullable=True),
sa.Column('smtp2go_message_id', sa.String(length=255), nullable=True),
sa.Column('bounce_date', sa.DateTime(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_email_bounces_email'), 'email_bounces', ['email'], unique=False)
op.create_index(op.f('ix_email_bounces_id'), 'email_bounces', ['id'], unique=False)
op.create_index(op.f('ix_email_bounces_smtp2go_message_id'), 'email_bounces', ['smtp2go_message_id'], unique=False)
# Memberships table (depends on users, membership_tiers)
op.create_table('memberships',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('tier_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('active', 'expired', 'pending', 'cancelled', name='membershipstatus'), nullable=False),
sa.Column('start_date', sa.Date(), nullable=False),
sa.Column('end_date', sa.Date(), nullable=False),
sa.Column('auto_renew', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['tier_id'], ['membership_tiers.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_memberships_id'), 'memberships', ['id'], unique=False)
# Payments table (depends on users, memberships)
op.create_table('payments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('membership_id', sa.Integer(), nullable=True),
sa.Column('amount', sa.Float(), nullable=False),
sa.Column('payment_method', sa.Enum('square', 'cash', 'check', 'dummy', name='paymentmethod'), nullable=False),
sa.Column('status', sa.Enum('pending', 'completed', 'failed', 'refunded', name='paymentstatus'), nullable=False),
sa.Column('transaction_id', sa.String(length=255), nullable=True),
sa.Column('payment_date', sa.DateTime(), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['membership_id'], ['memberships.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_payments_id'), 'payments', ['id'], unique=False)
# Events table (depends on users)
op.create_table('events',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('title', sa.String(length=255), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('event_date', sa.DateTime(), nullable=False),
sa.Column('event_time', sa.String(length=10), nullable=True),
sa.Column('location', sa.String(length=255), nullable=True),
sa.Column('max_attendees', sa.Integer(), nullable=True),
sa.Column('status', sa.Enum('draft', 'published', 'cancelled', 'completed', name='eventstatus'), nullable=False),
sa.Column('created_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_events_id'), 'events', ['id'], unique=False)
# Event RSVPs table (depends on events, users)
op.create_table('event_rsvps',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('event_id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('status', sa.Enum('pending', 'attending', 'not_attending', 'maybe', name='rsvpstatus'), nullable=False),
sa.Column('attended', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['event_id'], ['events.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_event_rsvps_id'), 'event_rsvps', ['id'], unique=False)
op.create_index('ix_event_rsvps_event_id_user_id', 'event_rsvps', ['event_id', 'user_id'], unique=True)
# Volunteer assignments table (depends on users, volunteer_roles)
op.create_table('volunteer_assignments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('role_id', sa.Integer(), nullable=False),
sa.Column('assigned_date', sa.Date(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=False),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['role_id'], ['volunteer_roles.id'], ),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_assignments_id'), 'volunteer_assignments', ['id'], unique=False)
# Volunteer schedules table (depends on volunteer_assignments)
op.create_table('volunteer_schedules',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('assignment_id', sa.Integer(), nullable=False),
sa.Column('schedule_date', sa.Date(), nullable=False),
sa.Column('start_time', sa.DateTime(), nullable=False),
sa.Column('end_time', sa.DateTime(), nullable=False),
sa.Column('location', sa.String(length=255), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('completed', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['assignment_id'], ['volunteer_assignments.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_volunteer_schedules_id'), 'volunteer_schedules', ['id'], unique=False)
# Certificates table (depends on users)
op.create_table('certificates',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('certificate_name', sa.String(length=255), nullable=False),
sa.Column('issuing_organization', sa.String(length=255), nullable=True),
sa.Column('issue_date', sa.Date(), nullable=False),
sa.Column('expiry_date', sa.Date(), nullable=True),
sa.Column('certificate_number', sa.String(length=100), nullable=True),
sa.Column('file_path', sa.String(length=500), nullable=True),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_certificates_id'), 'certificates', ['id'], unique=False)
# Files table (depends on users, membership_tiers)
op.create_table('files',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=False),
sa.Column('min_tier_id', sa.Integer(), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['min_tier_id'], ['membership_tiers.id'], ),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_files_id'), 'files', ['id'], unique=False)
# Notifications table (depends on users)
op.create_table('notifications',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('subject', sa.String(length=255), nullable=False),
sa.Column('message', sa.Text(), nullable=False),
sa.Column('email_sent', sa.Boolean(), nullable=False),
sa.Column('sent_at', sa.DateTime(), nullable=True),
sa.Column('error_message', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_notifications_id'), 'notifications', ['id'], unique=False)
# Password reset tokens table (depends on users)
op.create_table('password_reset_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('token', sa.String(length=255), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=False),
sa.Column('used', sa.Boolean(), nullable=False),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id')
)
op.create_index(op.f('ix_password_reset_tokens_id'), 'password_reset_tokens', ['id'], unique=False)
op.create_index(op.f('ix_password_reset_tokens_token'), 'password_reset_tokens', ['token'], unique=True)
def downgrade() -> None:
# Drop tables in reverse dependency order
op.drop_table('password_reset_tokens')
op.drop_table('notifications')
op.drop_table('files')
op.drop_table('certificates')
op.drop_table('volunteer_schedules')
op.drop_table('volunteer_assignments')
op.drop_table('event_rsvps')
op.drop_table('events')
op.drop_table('payments')
op.drop_table('memberships')
op.drop_table('email_bounces')
op.drop_table('email_templates')
op.drop_table('volunteer_roles')
op.drop_table('membership_tiers')
op.drop_table('users')
# Drop enums
op.execute("DROP TYPE IF EXISTS userrole")
op.execute("DROP TYPE IF EXISTS membershipstatus")
op.execute("DROP TYPE IF EXISTS paymentmethod")
op.execute("DROP TYPE IF EXISTS paymentstatus")
op.execute("DROP TYPE IF EXISTS eventstatus")
op.execute("DROP TYPE IF EXISTS rsvpstatus")
op.execute("DROP TYPE IF EXISTS bouncetype")

View File

@@ -0,0 +1,26 @@
"""Initial baseline migration
Revision ID: f9fbfa70654e
Revises:
Create Date: 2025-11-22 20:53:20.604227
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = 'f9fbfa70654e'
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass

View File

@@ -1,5 +1,5 @@
from fastapi import APIRouter
from . import auth, users, tiers, memberships, payments, email, email_templates, events
from . import auth, users, tiers, memberships, payments, email, email_templates, events, feature_flags
api_router = APIRouter()
@@ -11,3 +11,4 @@ api_router.include_router(payments.router, prefix="/payments", tags=["payments"]
api_router.include_router(email.router, prefix="/email", tags=["email"])
api_router.include_router(email_templates.router, prefix="/email-templates", tags=["email-templates"])
api_router.include_router(events.router, prefix="/events", tags=["events"])
api_router.include_router(feature_flags.router, prefix="/feature-flags", tags=["feature-flags"])

View File

@@ -0,0 +1,47 @@
from fastapi import APIRouter, Depends
from typing import Dict, Any
from app.services.feature_flag_service import feature_flags
from app.schemas.feature_flags import FeatureFlagsResponse, FeatureFlagResponse
router = APIRouter()
@router.get("/flags", response_model=FeatureFlagsResponse)
async def get_all_feature_flags() -> FeatureFlagsResponse:
"""
Get all feature flags for the frontend
This endpoint is public as it only returns feature configuration
"""
all_flags = feature_flags.get_all_flags()
enabled_flags = feature_flags.get_enabled_flags()
return FeatureFlagsResponse(
flags=all_flags,
enabled_flags=enabled_flags
)
@router.get("/flags/{flag_name}", response_model=FeatureFlagResponse)
async def get_feature_flag(flag_name: str) -> FeatureFlagResponse:
"""
Get a specific feature flag value
"""
flag_name_upper = flag_name.upper()
enabled = feature_flags.is_enabled(flag_name_upper)
value = feature_flags.get_flag_value(flag_name_upper)
return FeatureFlagResponse(
name=flag_name_upper,
enabled=enabled,
value=value
)
@router.post("/flags/reload")
async def reload_feature_flags():
"""
Reload feature flags from environment variables
This could be protected with admin permissions in production
"""
feature_flags.reload_flags()
return {"message": "Feature flags reloaded successfully"}

View File

@@ -1,12 +1,34 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from contextlib import asynccontextmanager
from .core.config import settings
from .api.v1 import api_router
from .core.database import get_db
from .core.init_db import init_default_data
from sqlalchemy.orm import Session
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Handle startup and shutdown events"""
# Startup
db: Session = next(get_db())
try:
init_default_data(db)
finally:
db.close()
yield
# Shutdown (if needed)
pass
app = FastAPI(
title=settings.APP_NAME,
version=settings.APP_VERSION,
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json"
openapi_url=f"{settings.API_V1_PREFIX}/openapi.json",
lifespan=lifespan
)
# Set up CORS

View File

@@ -0,0 +1,15 @@
from pydantic import BaseModel
from typing import Dict, Any, List
class FeatureFlagsResponse(BaseModel):
"""Response model for feature flags"""
flags: Dict[str, Any]
enabled_flags: List[str]
class FeatureFlagResponse(BaseModel):
"""Response model for a single feature flag"""
name: str
enabled: bool
value: Any

View File

@@ -0,0 +1,80 @@
"""
Feature Flag Service for managing application features
"""
import os
from typing import Dict, List, Any
from app.core.config import settings
class FeatureFlagService:
"""Service for managing feature flags"""
def __init__(self):
"""Initialize feature flags from environment variables"""
self._flags = self._load_flags_from_env()
def _load_flags_from_env(self) -> Dict[str, Any]:
"""Load feature flags from environment variables"""
# Get the FEATURE_FLAGS environment variable (comma-separated list)
feature_flags_env = os.getenv("FEATURE_FLAGS", "")
# Default feature flags - these can be overridden by environment
default_flags = {
"CASH_PAYMENT_ENABLED": True,
"EMAIL_NOTIFICATIONS_ENABLED": True,
"EVENT_MANAGEMENT_ENABLED": True,
"AUTO_RENEWAL_ENABLED": False,
"MEMBERSHIP_TRANSFERS_ENABLED": False,
"BULK_OPERATIONS_ENABLED": False,
"ADVANCED_REPORTING_ENABLED": False,
"API_RATE_LIMITING_ENABLED": True,
}
# Parse environment variable
flags = default_flags.copy()
if feature_flags_env:
# Parse comma-separated key=value pairs
for flag_pair in feature_flags_env.split(","):
flag_pair = flag_pair.strip()
if "=" in flag_pair:
key, value = flag_pair.split("=", 1)
key = key.strip().upper()
value = value.strip().lower()
# Convert string to boolean
if value in ("true", "1", "yes", "on"):
flags[key] = True
elif value in ("false", "0", "no", "off"):
flags[key] = False
else:
# For non-boolean values, keep as string
flags[key] = value
return flags
def is_enabled(self, flag_name: str) -> bool:
"""Check if a feature flag is enabled"""
flag_name = flag_name.upper()
return bool(self._flags.get(flag_name, False))
def get_flag_value(self, flag_name: str, default: Any = None) -> Any:
"""Get the value of a feature flag"""
flag_name = flag_name.upper()
return self._flags.get(flag_name, default)
def get_all_flags(self) -> Dict[str, Any]:
"""Get all feature flags"""
return self._flags.copy()
def get_enabled_flags(self) -> List[str]:
"""Get list of enabled feature flag names"""
return [name for name, value in self._flags.items() if value is True]
def reload_flags(self) -> None:
"""Reload feature flags from environment (useful for runtime updates)"""
self._flags = self._load_flags_from_env()
# Global instance
feature_flags = FeatureFlagService()

View File

@@ -1,270 +0,0 @@
-- Initialize database with complete schema and default data
-- Set character set to UTF-8 to prevent encoding issues
SET NAMES utf8mb4;
SET CHARACTER SET utf8mb4;
-- Create all tables
CREATE TABLE users (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) UNIQUE NOT NULL,
hashed_password VARCHAR(255) NOT NULL,
first_name VARCHAR(100) NOT NULL,
last_name VARCHAR(100) NOT NULL,
phone VARCHAR(20),
address TEXT,
role ENUM('member', 'admin', 'super_admin') NOT NULL DEFAULT 'member',
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
INDEX idx_email (email)
);
CREATE TABLE membership_tiers (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) UNIQUE NOT NULL,
description TEXT,
annual_fee DECIMAL(10,2) NOT NULL,
benefits TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE memberships (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
tier_id INT NOT NULL,
status ENUM('active', 'expired', 'pending', 'cancelled') NOT NULL DEFAULT 'pending',
start_date DATE NOT NULL,
end_date DATE NOT NULL,
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (tier_id) REFERENCES membership_tiers(id),
INDEX idx_user_id (user_id),
INDEX idx_tier_id (tier_id)
);
CREATE TABLE payments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
membership_id INT,
amount DECIMAL(10,2) NOT NULL,
payment_method ENUM('square', 'cash', 'check', 'dummy') NOT NULL,
status ENUM('pending', 'completed', 'failed', 'refunded') NOT NULL DEFAULT 'pending',
transaction_id VARCHAR(255),
payment_date TIMESTAMP NULL,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (membership_id) REFERENCES memberships(id),
INDEX idx_user_id (user_id),
INDEX idx_membership_id (membership_id)
);
CREATE TABLE events (
id INT AUTO_INCREMENT PRIMARY KEY,
title VARCHAR(255) NOT NULL,
description TEXT,
event_date TIMESTAMP NOT NULL,
event_time VARCHAR(10), -- HH:MM format
location VARCHAR(255),
max_attendees INT,
status ENUM('draft', 'published', 'cancelled', 'completed') NOT NULL DEFAULT 'draft',
created_by INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (created_by) REFERENCES users(id),
INDEX idx_created_by (created_by),
INDEX idx_event_date (event_date)
);
CREATE TABLE event_rsvps (
id INT AUTO_INCREMENT PRIMARY KEY,
event_id INT NOT NULL,
user_id INT NOT NULL,
status ENUM('pending', 'attending', 'not_attending', 'maybe') NOT NULL DEFAULT 'pending',
attended BOOLEAN NOT NULL DEFAULT FALSE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES events(id) ON DELETE CASCADE,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
UNIQUE KEY unique_event_user (event_id, user_id),
INDEX idx_event_id (event_id),
INDEX idx_user_id (user_id)
);
CREATE TABLE volunteer_roles (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE volunteer_assignments (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
role_id INT NOT NULL,
assigned_date DATE NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (role_id) REFERENCES volunteer_roles(id),
INDEX idx_user_id (user_id),
INDEX idx_role_id (role_id)
);
CREATE TABLE volunteer_schedules (
id INT AUTO_INCREMENT PRIMARY KEY,
assignment_id INT NOT NULL,
schedule_date DATE NOT NULL,
start_time TIMESTAMP NOT NULL,
end_time TIMESTAMP NOT NULL,
location VARCHAR(255),
notes TEXT,
completed BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (assignment_id) REFERENCES volunteer_assignments(id) ON DELETE CASCADE,
INDEX idx_assignment_id (assignment_id),
INDEX idx_schedule_date (schedule_date)
);
CREATE TABLE certificates (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
certificate_name VARCHAR(255) NOT NULL,
issuing_organization VARCHAR(255),
issue_date DATE NOT NULL,
expiry_date DATE,
certificate_number VARCHAR(100),
file_path VARCHAR(500),
notes TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
);
CREATE TABLE files (
id INT AUTO_INCREMENT PRIMARY KEY,
filename VARCHAR(255) NOT NULL,
original_filename VARCHAR(255) NOT NULL,
file_path VARCHAR(500) NOT NULL,
file_size INT NOT NULL,
mime_type VARCHAR(100) NOT NULL,
min_tier_id INT,
description TEXT,
uploaded_by INT NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (min_tier_id) REFERENCES membership_tiers(id),
FOREIGN KEY (uploaded_by) REFERENCES users(id),
INDEX idx_min_tier_id (min_tier_id),
INDEX idx_uploaded_by (uploaded_by)
);
CREATE TABLE notifications (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
subject VARCHAR(255) NOT NULL,
message TEXT NOT NULL,
email_sent BOOLEAN NOT NULL DEFAULT FALSE,
sent_at TIMESTAMP NULL,
error_message TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_user_id (user_id)
);
CREATE TABLE password_reset_tokens (
id INT AUTO_INCREMENT PRIMARY KEY,
user_id INT NOT NULL,
token VARCHAR(255) UNIQUE NOT NULL,
expires_at TIMESTAMP NOT NULL,
used BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
INDEX idx_token (token),
INDEX idx_user_id (user_id)
);
CREATE TABLE email_templates (
id INT AUTO_INCREMENT PRIMARY KEY,
template_key VARCHAR(100) UNIQUE NOT NULL,
name VARCHAR(255) NOT NULL,
subject VARCHAR(255) NOT NULL,
html_body TEXT NOT NULL,
text_body TEXT,
variables TEXT, -- JSON string of available variables
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_template_key (template_key)
);
CREATE TABLE email_bounces (
id INT AUTO_INCREMENT PRIMARY KEY,
email VARCHAR(255) NOT NULL,
bounce_type ENUM('hard', 'soft', 'complaint', 'unsubscribe') NOT NULL,
bounce_reason VARCHAR(500),
smtp2go_message_id VARCHAR(255),
bounce_date TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_email (email),
INDEX idx_smtp2go_message_id (smtp2go_message_id),
INDEX idx_active (is_active)
);
-- Seed initial data
-- Create default membership tiers
INSERT INTO membership_tiers (name, description, annual_fee, benefits, is_active, created_at, updated_at)
VALUES
('Personal', 'Basic membership for individual members', 5.00, 'Access to member portal, meeting notifications, event participation', TRUE, NOW(), NOW()),
('Aircraft Owners', 'Group membership for aircraft owners', 25.00, 'All Personal benefits plus priority event registration, aircraft owner resources', TRUE, NOW(), NOW()),
('Corporate', 'Corporate membership for businesses', 100.00, 'All benefits plus corporate recognition, promotional opportunities, file access', TRUE, NOW(), NOW());
-- Create default admin user (password: admin123)
INSERT INTO users (email, hashed_password, first_name, last_name, role, is_active, created_at, updated_at)
VALUES
('admin@swanseaairport.org', '$2b$12$eeuS7kW4xUYZPLx4LgBGaeoGhPu/cg/9M3WEanWToTwtLOLppJmzq', 'System', 'Administrator', 'super_admin', TRUE, NOW(), NOW());
-- Create default email templates
INSERT INTO email_templates (template_key, name, subject, html_body, text_body, variables, is_active, created_at, updated_at)
VALUES
('welcome', 'Welcome Email', 'Welcome to Swansea Airport Stakeholders Alliance - Your Account is Ready',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Thank you for registering with us. Your account has been successfully created.</p><p>You can now:</p><ul><li>Browse membership tiers and select one that suits you</li><li>View upcoming events and meetings</li><li>Access your membership portal</li></ul><p>If you have any questions, please don\'t hesitate to contact us.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nThank you for registering with us. Your account has been successfully created.\n\nYou can now:\n- Browse membership tiers and select one that suits you\n- View upcoming events and meetings\n- Access your membership portal\n\nIf you have any questions, please don\'t hesitate to contact us.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name"]', TRUE, NOW(), NOW()),
('payment_confirmation', 'Payment Confirmation', 'Payment Confirmation - Swansea Airport Stakeholders Alliance',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Payment Confirmed!</h2><p>Hello {first_name},</p><p>We have received your payment. Thank you!</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Amount:</strong> {amount}</p><p style="margin: 5px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p></div><p>Your membership is now active. You can access all the benefits associated with your tier.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Payment Confirmed!\n\nHello {first_name},\n\nWe have received your payment. Thank you!\n\nAmount: {amount}\nPayment Method: {payment_method}\nMembership Tier: {membership_tier}\n\nYour membership is now active. You can access all the benefits associated with your tier.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "amount", "payment_method", "membership_tier"]', TRUE, NOW(), NOW()),
('membership_activation', 'Membership Activation', 'Your Swansea Airport Stakeholders Alliance Membership is Now Active!',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #28a745;">Welcome to Swansea Airport Stakeholders Alliance!</h2><p>Hello {first_name},</p><p>Great news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.</p><div style="background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin: 20px 0; border-left: 4px solid #28a745;"><h3 style="margin-top: 0; color: #28a745;">Membership Details</h3><p style="margin: 8px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 8px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 8px 0;"><strong>Next Renewal Date:</strong> {renewal_date}</p></div><div style="background-color: #e9ecef; padding: 20px; border-radius: 8px; margin: 20px 0;"><h3 style="margin-top: 0; color: #495057;">Payment Information</h3><p style="margin: 8px 0;"><strong>Amount Paid:</strong> {payment_amount}</p><p style="margin: 8px 0;"><strong>Payment Method:</strong> {payment_method}</p><p style="margin: 8px 0;"><strong>Payment Date:</strong> {payment_date}</p></div><p>Your membership will automatically renew on <strong>{renewal_date}</strong> unless you choose to cancel it. You can manage your membership settings in your account dashboard.</p><p>If you have any questions about your membership or need assistance, please don\'t hesitate to contact us.</p><p>Welcome to the Swansea Airport Stakeholders Alliance community!</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance Team</strong></p></body></html>',
'Welcome to Swansea Airport Stakeholders Alliance!\n\nHello {first_name},\n\nGreat news! Your membership has been successfully activated. You now have full access to all the benefits of your membership tier.\n\nMEMBERSHIP DETAILS\n------------------\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nNext Renewal Date: {renewal_date}\n\nPAYMENT INFORMATION\n-------------------\nAmount Paid: {payment_amount}\nPayment Method: {payment_method}\nPayment Date: {payment_date}\n\nYour membership will automatically renew on {renewal_date} unless you choose to cancel it. You can manage your membership settings in your account dashboard.\n\nIf you have any questions about your membership or need assistance, please don\'t hesitate to contact us.\n\nWelcome to the Swansea Airport Stakeholders Alliance community!\n\nBest regards,\nSwansea Airport Stakeholders Alliance Team',
'["first_name", "membership_tier", "annual_fee", "payment_amount", "payment_method", "renewal_date", "payment_date"]', TRUE, NOW(), NOW()),
('renewal_reminder', 'Renewal Reminder', 'Membership Renewal Reminder - Swansea Airport Stakeholders Alliance',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Membership Renewal Reminder</h2><p>Hello {first_name},</p><p>This is a friendly reminder that your <strong>{membership_tier}</strong> membership will expire on <strong>{expiry_date}</strong>.</p><p>To continue enjoying your membership benefits, please renew your membership.</p><div style="background-color: #f5f5f5; padding: 15px; border-radius: 5px; margin: 20px 0;"><p style="margin: 5px 0;"><strong>Membership Tier:</strong> {membership_tier}</p><p style="margin: 5px 0;"><strong>Annual Fee:</strong> {annual_fee}</p><p style="margin: 5px 0;"><strong>Expires:</strong> {expiry_date}</p></div><p>Please log in to your account to renew your membership.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Membership Renewal Reminder\n\nHello {first_name},\n\nThis is a friendly reminder that your {membership_tier} membership will expire on {expiry_date}.\n\nTo continue enjoying your membership benefits, please renew your membership.\n\nMembership Tier: {membership_tier}\nAnnual Fee: {annual_fee}\nExpires: {expiry_date}\n\nPlease log in to your account to renew your membership.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "expiry_date", "membership_tier", "annual_fee"]', TRUE, NOW(), NOW()),
('password_reset', 'Password Reset', 'Password Reset Request - Swansea Airport Stakeholders Alliance Member Portal',
'<html><body style="font-family: Arial, sans-serif; line-height: 1.6; color: #333;"><h2 style="color: #0066cc;">Password Reset Request</h2><p>Hello {first_name},</p><p>You have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.</p><p>Please click the button below to reset your password:</p><div style="text-align: center; margin: 30px 0;"><a href="{reset_url}" style="background-color: #0066cc; color: white; padding: 12px 24px; text-decoration: none; border-radius: 5px; display: inline-block;">Reset Password</a></div><p>If the button doesn\'t work, you can copy and paste this link into your browser:</p><p style="word-break: break-all; background-color: #f5f5f5; padding: 10px; border-radius: 3px;">{reset_url}</p><p><strong>This link will expire in 1 hour.</strong></p><p>If you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.</p><p>For security reasons, please don\'t share this email with anyone.</p><p>Best regards,<br><strong>Swansea Airport Stakeholders Alliance</strong></p></body></html>',
'Password Reset Request\n\nHello {first_name},\n\nYou have requested to reset your password for your Swansea Airport Stakeholders Alliance member account.\n\nPlease use this link to reset your password: {reset_url}\n\nThis link will expire in 1 hour.\n\nIf you didn\'t request this password reset, please ignore this email. Your password will remain unchanged.\n\nFor security reasons, please don\'t share this email with anyone.\n\nBest regards,\nSwansea Airport Stakeholders Alliance',
'["first_name", "reset_url"]', TRUE, NOW(), NOW());

View File

@@ -1,21 +0,0 @@
services:
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
container_name: membership_frontend
restart: unless-stopped
environment:
- VITE_HOST_CHECK=false
- VITE_ALLOWED_HOSTS=${VITE_ALLOWED_HOSTS}
ports:
- "8050:3000" # Expose frontend to host
volumes:
- ./frontend/src:/app/src
- ./frontend/public:/app/public
- ./frontend/vite.config.ts:/app/vite.config.ts
depends_on:
- backend
networks:
- membership_private

View File

@@ -1,14 +0,0 @@
services:
frontend-prod:
build:
context: ./frontend
dockerfile: Dockerfile
target: production
container_name: membership_frontend_prod
restart: unless-stopped
ports:
- "8050:80" # Nginx default port
depends_on:
- backend
networks:
- membership_private

View File

@@ -1,53 +1,65 @@
services:
mysql:
image: mysql:8.0
container_name: membership_mysql
restart: unless-stopped
environment:
MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-rootpassword}
MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
MYSQL_USER: ${DATABASE_USER:-membership_user}
MYSQL_PASSWORD: ${DATABASE_PASSWORD:-change_this_password}
# No external port exposure - database only accessible on private network
expose:
- "3306"
volumes:
- mysql_data:/var/lib/mysql
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
networks:
- membership_private
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
start_period: 10s
interval: 5s
timeout: 5s
retries: 10
# mysql:
# image: mysql:8.0
# container_name: membership_mysql
# restart: unless-stopped
# environment:
# MYSQL_ROOT_PASSWORD: ${DATABASE_PASSWORD:-secure_root_password_change_this}
# MYSQL_DATABASE: ${DATABASE_NAME:-membership_db}
# MYSQL_USER: ${DATABASE_USER:-membership_user}
# MYSQL_PASSWORD: ${DATABASE_PASSWORD:-secure_password_change_this}
# # No external port exposure - database only accessible on private network
# expose:
# - "3306"
# volumes:
# - mysql_data:/var/lib/mysql
# networks:
# - membership_private
# healthcheck:
# test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
# start_period: 10s
# interval: 5s
# timeout: 5s
# retries: 10
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: membership_backend
restart: unless-stopped
env_file:
- .env
environment:
# Database configuration
- DATABASE_HOST=${DATABASE_HOST}
- DATABASE_PORT=${DATABASE_PORT}
- DATABASE_USER=${DATABASE_USER}
- DATABASE_PASSWORD=${DATABASE_PASSWORD}
- DATABASE_NAME=${DATABASE_NAME}
# Application configuration
- SECRET_KEY=${SECRET_KEY}
- ALGORITHM=${ALGORITHM}
- ACCESS_TOKEN_EXPIRE_MINUTES=${ACCESS_TOKEN_EXPIRE_MINUTES}
extra_hosts:
- "host.docker.internal:host-gateway"
ports:
- "6000:8000" # Only expose backend API to host
volumes:
- ./backend/app:/app/app
- ./backend/alembic:/app/alembic
- ./backend/alembic.ini:/app/alembic.ini
- uploads_data:/app/uploads
depends_on:
mysql:
condition: service_healthy
networks:
- membership_private # Access to database on private network
command: >
sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
# depends_on:
# mysql:
# condition: service_healthy
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
target: development
container_name: membership_frontend
restart: unless-stopped
environment:
- VITE_HOST_CHECK=false
@@ -60,8 +72,6 @@ services:
- ./frontend/vite.config.ts:/app/vite.config.ts
depends_on:
- backend
networks:
- membership_private
#frontend-prod:
# build:
@@ -77,12 +87,7 @@ services:
# networks:
# - membership_private
networks:
membership_private:
driver: bridge
internal: false # Allow outbound internet access for backend
# Database is not exposed to host - only accessible within this network
volumes:
mysql_data:
# mysql_data:
uploads_data:

View File

@@ -63,6 +63,15 @@ body {
background-color: #5a6268;
}
.btn-danger {
background-color: #dc3545;
color: white;
}
.btn-danger:hover {
background-color: #c82333;
}
.form-group {
margin-bottom: 16px;
}

View File

@@ -1,5 +1,6 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { FeatureFlagProvider } from './contexts/FeatureFlagContext';
import Register from './pages/Register';
import Login from './pages/Login';
import ForgotPassword from './pages/ForgotPassword';
@@ -12,19 +13,21 @@ import './App.css';
const App: React.FC = () => {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/login" />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/email-templates" element={<EmailTemplates />} />
<Route path="/membership-tiers" element={<MembershipTiers />} />
<Route path="/bounce-management" element={<BounceManagement />} />
</Routes>
</BrowserRouter>
<FeatureFlagProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<Navigate to="/login" />} />
<Route path="/register" element={<Register />} />
<Route path="/login" element={<Login />} />
<Route path="/forgot-password" element={<ForgotPassword />} />
<Route path="/reset-password" element={<ResetPassword />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/email-templates" element={<EmailTemplates />} />
<Route path="/membership-tiers" element={<MembershipTiers />} />
<Route path="/bounce-management" element={<BounceManagement />} />
</Routes>
</BrowserRouter>
</FeatureFlagProvider>
);
};

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
const FeatureFlagStatus: React.FC = () => {
const { flags, loading, error, reloadFlags } = useFeatureFlags();
if (loading) {
return <div style={{ fontSize: '14px', color: '#666' }}>Loading feature flags...</div>;
}
if (error) {
return <div style={{ fontSize: '14px', color: '#d32f2f' }}>Error loading feature flags</div>;
}
if (!flags) {
return null;
}
const handleReload = async () => {
try {
await reloadFlags();
console.log('Feature flags reloaded');
} catch (error) {
console.error('Failed to reload feature flags:', error);
}
};
return (
<div className="card" style={{ marginBottom: '20px' }}>
<h4 style={{ marginBottom: '16px' }}>Feature Flags Status</h4>
<div style={{ display: 'grid', gap: '8px', marginBottom: '16px' }}>
{Object.entries(flags.flags).map(([name, value]) => (
<div
key={name}
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
padding: '8px 12px',
backgroundColor: '#f5f5f5',
borderRadius: '4px',
fontSize: '14px'
}}
>
<span style={{ fontWeight: '500' }}>
{name.replace(/_/g, ' ').toLowerCase().replace(/\b\w/g, l => l.toUpperCase())}
</span>
<span
style={{
padding: '2px 8px',
borderRadius: '12px',
fontSize: '12px',
fontWeight: '500',
backgroundColor: value ? '#4CAF50' : '#f44336',
color: 'white'
}}
>
{String(value)}
</span>
</div>
))}
</div>
<button
className="btn btn-secondary"
onClick={handleReload}
style={{ fontSize: '12px', padding: '6px 12px' }}
>
Reload Flags
</button>
<p style={{ fontSize: '12px', color: '#666', marginTop: '12px', marginBottom: 0 }}>
Feature flags are loaded from environment variables. Changes require updating the .env file and reloading.
</p>
</div>
);
};
export default FeatureFlagStatus;

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect } from 'react';
import { membershipService, paymentService, MembershipTier, MembershipCreateData, PaymentCreateData } from '../services/membershipService';
import { useFeatureFlags } from '../contexts/FeatureFlagContext';
import SquarePayment from './SquarePayment';
interface MembershipSetupProps {
@@ -16,6 +17,8 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
const [error, setError] = useState('');
const [createdMembershipId, setCreatedMembershipId] = useState<number | null>(null);
const { isEnabled } = useFeatureFlags();
useEffect(() => {
loadTiers();
}, []);
@@ -202,26 +205,28 @@ const MembershipSetup: React.FC<MembershipSetupProps> = ({ onMembershipCreated,
<span></span>
</button>
<button
className="btn btn-secondary"
onClick={() => handlePaymentMethodSelect('cash')}
disabled={loading}
style={{
padding: '16px',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div>
<strong>Cash Payment</strong>
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
Pay in person or by check
{isEnabled('CASH_PAYMENT_ENABLED') && (
<button
className="btn btn-secondary"
onClick={() => handlePaymentMethodSelect('cash')}
disabled={loading}
style={{
padding: '16px',
textAlign: 'left',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center'
}}
>
<div>
<strong>Cash Payment</strong>
<div style={{ fontSize: '14px', marginTop: '4px', opacity: 0.8 }}>
Pay in person or by check
</div>
</div>
</div>
<span></span>
</button>
<span></span>
</button>
)}
</div>
<div style={{ marginTop: '20px', textAlign: 'center' }}>

View File

@@ -285,19 +285,33 @@ const ChangePasswordModal: React.FC<ChangePasswordModalProps> = ({ onClose }) =>
</div>
)}
<div className="modal-buttons">
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '16px' }}>
<button
type="button"
onClick={onClose}
disabled={loading}
className="modal-btn-cancel"
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="modal-btn-primary"
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{loading ? 'Changing...' : 'Change Password'}
</button>

View File

@@ -0,0 +1,94 @@
import React, { createContext, useContext, useState, useEffect } from 'react';
import { featureFlagService, FeatureFlags } from '../services/featureFlagService';
interface FeatureFlagContextType {
flags: FeatureFlags | null;
loading: boolean;
error: string | null;
isEnabled: (flagName: string) => boolean;
getFlagValue: (flagName: string, defaultValue?: any) => any;
reloadFlags: () => Promise<void>;
}
const FeatureFlagContext = createContext<FeatureFlagContextType | undefined>(undefined);
export const useFeatureFlags = (): FeatureFlagContextType => {
const context = useContext(FeatureFlagContext);
if (context === undefined) {
throw new Error('useFeatureFlags must be used within a FeatureFlagProvider');
}
return context;
};
interface FeatureFlagProviderProps {
children: React.ReactNode;
}
export const FeatureFlagProvider: React.FC<FeatureFlagProviderProps> = ({ children }) => {
const [flags, setFlags] = useState<FeatureFlags | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadFlags = async () => {
try {
setLoading(true);
setError(null);
const flagsData = await featureFlagService.getAllFlags();
setFlags(flagsData);
} catch (err: any) {
console.error('Failed to load feature flags:', err);
setError('Failed to load feature flags');
// Set default flags on error
setFlags({
flags: {
CASH_PAYMENT_ENABLED: true,
EMAIL_NOTIFICATIONS_ENABLED: true,
EVENT_MANAGEMENT_ENABLED: true,
AUTO_RENEWAL_ENABLED: false,
MEMBERSHIP_TRANSFERS_ENABLED: false,
BULK_OPERATIONS_ENABLED: false,
ADVANCED_REPORTING_ENABLED: false,
API_RATE_LIMITING_ENABLED: true,
},
enabled_flags: ['CASH_PAYMENT_ENABLED', 'EMAIL_NOTIFICATIONS_ENABLED', 'EVENT_MANAGEMENT_ENABLED', 'API_RATE_LIMITING_ENABLED']
});
} finally {
setLoading(false);
}
};
const reloadFlags = async () => {
await loadFlags();
};
const isEnabled = (flagName: string): boolean => {
if (!flags) return false;
const upperFlagName = flagName.toUpperCase();
return Boolean(flags.flags[upperFlagName]);
};
const getFlagValue = (flagName: string, defaultValue: any = null): any => {
if (!flags) return defaultValue;
const upperFlagName = flagName.toUpperCase();
return flags.flags[upperFlagName] ?? defaultValue;
};
useEffect(() => {
loadFlags();
}, []);
const contextValue: FeatureFlagContextType = {
flags,
loading,
error,
isEnabled,
getFlagValue,
reloadFlags,
};
return (
<FeatureFlagContext.Provider value={contextValue}>
{children}
</FeatureFlagContext.Provider>
);
};

View File

@@ -4,6 +4,7 @@ import { authService, userService, membershipService, paymentService, eventServi
import MembershipSetup from '../components/MembershipSetup';
import ProfileMenu from '../components/ProfileMenu';
import ProfileEdit from '../components/ProfileEdit';
import FeatureFlagStatus from '../components/FeatureFlagStatus';
const Dashboard: React.FC = () => {
const navigate = useNavigate();
@@ -23,7 +24,6 @@ const Dashboard: React.FC = () => {
const [editFormData, setEditFormData] = useState<Partial<User>>({});
const [upcomingEvents, setUpcomingEvents] = useState<Event[]>([]);
const [allEvents, setAllEvents] = useState<Event[]>([]);
const [eventRSVPs, setEventRSVPs] = useState<EventRSVP[]>([]);
const [eventRSVPCounts, setEventRSVPCounts] = useState<{[eventId: number]: {attending: number, maybe: number, not_attending: number}}>({});
const [rsvpLoading, setRsvpLoading] = useState<{[eventId: number]: boolean}>({});
const [showEventModal, setShowEventModal] = useState(false);
@@ -39,6 +39,8 @@ const Dashboard: React.FC = () => {
const [showRSVPModal, setShowRSVPModal] = useState(false);
const [selectedEventForRSVP, setSelectedEventForRSVP] = useState<Event | null>(null);
const [eventRSVPList, setEventRSVPList] = useState<EventRSVP[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [userToDelete, setUserToDelete] = useState<User | null>(null);
useEffect(() => {
if (!authService.isAuthenticated()) {
@@ -184,6 +186,26 @@ const Dashboard: React.FC = () => {
}
};
const handleDeleteUser = async () => {
if (!userToDelete) return;
try {
await userService.deleteUser(userToDelete.id);
await loadData(); // Reload data to reflect changes
setShowDeleteConfirm(false);
setUserToDelete(null);
alert('User deleted successfully');
} catch (error: any) {
console.error('Failed to delete user:', error);
alert(`Failed to delete user: ${error.response?.data?.detail || error.message}`);
}
};
const confirmDeleteUser = (u: User) => {
setUserToDelete(u);
setShowDeleteConfirm(true);
};
const getStatusClass = (status: string) => {
switch (status.toLowerCase()) {
case 'active':
@@ -695,6 +717,11 @@ const Dashboard: React.FC = () => {
</div>
)}
{/* Feature Flag Management - Super Admin Only */}
{user?.role === 'super_admin' && (
<FeatureFlagStatus />
)}
{/* User Management Section */}
{(user?.role === 'admin' || user?.role === 'super_admin') && (
<div className="card" style={{ marginTop: '20px' }}>
@@ -789,6 +816,18 @@ const Dashboard: React.FC = () => {
{u.role === 'super_admin' && (
<span style={{ fontSize: '12px', color: '#666' }}>Super Admin</span>
)}
{user?.role === 'super_admin' && u.id !== user?.id && (
<button
className="btn btn-danger"
onClick={(e) => {
e.stopPropagation(); // Prevent row click
confirmDeleteUser(u);
}}
style={{ fontSize: '12px', padding: '4px 8px', marginLeft: '4px' }}
>
Delete
</button>
)}
</td>
</tr>
);
@@ -1364,6 +1403,53 @@ const Dashboard: React.FC = () => {
</div>
</div>
)}
{/* Delete User Confirmation Modal */}
{showDeleteConfirm && userToDelete && (
<div className="modal-overlay">
<div className="modal-content">
<h3 style={{ color: '#dc3545' }}>Delete User</h3>
<p>Are you sure you want to delete the user <strong>{userToDelete.first_name} {userToDelete.last_name}</strong> ({userToDelete.email})?</p>
<p style={{ color: '#dc3545', fontSize: '14px' }}>
This action cannot be undone. All associated memberships and payments will also be deleted.
</p>
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: '10px', marginTop: '20px' }}>
<button
type="button"
onClick={() => {
setShowDeleteConfirm(false);
setUserToDelete(null);
}}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Cancel
</button>
<button
type="button"
onClick={handleDeleteUser}
style={{
padding: '10px 20px',
backgroundColor: '#dc3545',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: 'pointer'
}}
>
Delete User
</button>
</div>
</div>
</div>
)}
</>
);
};

View File

@@ -0,0 +1,29 @@
import api from './api';
export interface FeatureFlag {
name: string;
enabled: boolean;
value: any;
}
export interface FeatureFlags {
flags: { [key: string]: any };
enabled_flags: string[];
}
export const featureFlagService = {
async getAllFlags(): Promise<FeatureFlags> {
const response = await api.get('/feature-flags/flags');
return response.data;
},
async getFlag(flagName: string): Promise<FeatureFlag> {
const response = await api.get(`/feature-flags/flags/${flagName}`);
return response.data;
},
async reloadFlags(): Promise<{ message: string }> {
const response = await api.post('/feature-flags/flags/reload');
return response.data;
}
};

View File

@@ -225,6 +225,11 @@ export const userService = {
const response = await api.put(`/users/${userId}`, data);
return response.data;
},
async deleteUser(userId: number): Promise<{ message: string }> {
const response = await api.delete(`/users/${userId}`);
return response.data;
},
};
export const membershipService = {