Merge pull request #3 from RideAware/refactor/username
feat(api): add full signup/login flow with email & profile, ENV support, and port fix
This commit is contained in:
9
.dockerignore
Normal file
9
.dockerignore
Normal file
@@ -0,0 +1,9 @@
|
||||
.git
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.log
|
||||
!.env
|
||||
venv/
|
||||
.venv/
|
||||
dist/
|
||||
build/
|
||||
17
.gitignore
vendored
17
.gitignore
vendored
@@ -171,4 +171,19 @@ cython_debug/
|
||||
.ruff_cache/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
.pypirc
|
||||
|
||||
# Flask-Migrate / Alembic
|
||||
# Keep migrations in Git, but ignore cache/compiled files
|
||||
migrations/__pycache__/
|
||||
migrations/*.pyc
|
||||
|
||||
# Docker
|
||||
*.pid
|
||||
*.log
|
||||
docker-compose.override.yml
|
||||
.docker/
|
||||
.wheels/
|
||||
|
||||
# VSCode / Editor configs
|
||||
.vscode/
|
||||
51
Dockerfile
51
Dockerfile
@@ -1,14 +1,53 @@
|
||||
FROM python:3.10-slim
|
||||
FROM python:3.10-slim AS builder
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY requirements.txt .
|
||||
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
COPY . .
|
||||
RUN python -m pip install --upgrade pip && \
|
||||
pip wheel --no-deps -r requirements.txt -w /wheels && \
|
||||
pip wheel --no-deps gunicorn -w /wheels
|
||||
|
||||
FROM python:3.10-slim AS runtime
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
PIP_NO_CACHE_DIR=1 \
|
||||
PORT=5000 \
|
||||
WSGI_MODULE=server:app \
|
||||
GUNICORN_WORKERS=2 \
|
||||
GUNICORN_THREADS=4 \
|
||||
GUNICORN_TIMEOUT=60 \
|
||||
FLASK_APP=server.py
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN groupadd -g 10001 app && useradd -m -u 10001 -g app app
|
||||
|
||||
COPY --from=builder /wheels /wheels
|
||||
RUN pip install --no-cache-dir /wheels/* && rm -rf /wheels
|
||||
|
||||
# Install python-dotenv if not already in requirements.txt
|
||||
RUN pip install python-dotenv
|
||||
|
||||
USER app
|
||||
|
||||
COPY --chown=app:app . .
|
||||
|
||||
# Copy .env file specifically
|
||||
COPY --chown=app:app .env .env
|
||||
|
||||
EXPOSE 5000
|
||||
|
||||
ENV FLASK_APP=server.py
|
||||
ENV FLASK_RUN_HOST=0.0.0.0
|
||||
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
|
||||
CMD python -c "import os,socket; s=socket.socket(); s.settimeout(2); s.connect(('127.0.0.1', int(os.getenv('PORT', '5000')))); s.close()"
|
||||
|
||||
CMD ["flask", "run"]
|
||||
CMD ["sh", "-c", "exec gunicorn $WSGI_MODULE --bind=0.0.0.0:$PORT --workers=$GUNICORN_WORKERS --threads=$GUNICORN_THREADS --timeout=$GUNICORN_TIMEOUT --access-logfile=- --error-logfile=- --keep-alive=5"]
|
||||
116
TODO.md
116
TODO.md
@@ -1,46 +1,98 @@
|
||||
# TODO Features
|
||||
|
||||
## User Management
|
||||
- [ ] **User Registration**: Allow users to create an account and log in to access their data.
|
||||
- [ ] **User Profile**: Provide a user profile page to display user information and progress.
|
||||
- [ ] **Password Recovery**: Allow users to recover their password if they forget it.
|
||||
- [ ] **User Registration & Login**: Email, OAuth (Google, Apple, Strava, Garmin).
|
||||
- [ ] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP history, weight.
|
||||
- [ ] **Password Recovery**: Email-based reset and magic-link login.
|
||||
- [ ] **Onboarding & Baselines**: Guided setup, baseline tests, auto zone calc.
|
||||
- [ ] **Account Roles**: Athlete, Coach, Admin; team/org workspaces.
|
||||
- [ ] **Multi-device Sessions**: Seamless handoff across web/mobile.
|
||||
|
||||
## Workout Planning
|
||||
- [ ] **Customizable Training Plans**: Allow users to create customized training plans based on their goals and fitness level.
|
||||
- [ ] **Workout Scheduling**: Provide a feature to schedule workouts and set reminders.
|
||||
- [ ] **Goal Setting**: Allow users to set and track their fitness goals.
|
||||
- [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level.
|
||||
- [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather.
|
||||
- [ ] **Workout Scheduling**: Calendar view, drag-drop, ICS sync (Google/Apple/Outlook).
|
||||
- [ ] **Goal Setting & Tracking**: SMART goals with real-time progress bars.
|
||||
- [ ] **Templates Library**: Plan & session templates (endurance, threshold, VO2, strength).
|
||||
- [ ] **Export Structured Workouts**: .zwo (Zwift), Garmin FIT/Workout, Wahoo, TrainerRoad.
|
||||
- [ ] **Race/Event Planner**: Target events, taper builder, gear checklist.
|
||||
|
||||
## Workout Tracking
|
||||
- [ ] **Workout Logging**: Allow users to log their workouts, including exercises, sets, reps, and weight.
|
||||
- [ ] **Data Analysis**: Provide tools to analyze user data, including charts, graphs, and statistics.
|
||||
- [ ] **Progress Tracking**: Allow users to track their progress over time.
|
||||
- [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS.
|
||||
- [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX).
|
||||
- [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used.
|
||||
- [ ] **Equipment Tracking**: Bike/components mileage, service reminders.
|
||||
|
||||
## Training and Coaching
|
||||
- [ ] **Coaching and Guidance**: Provide coaching and guidance to help users achieve their fitness goals.
|
||||
- [ ] **Virtual Training Rides**: Offer immersive virtual training rides to boost users' cycling performance.
|
||||
- [ ] **Structured Workouts**: Offer structured workouts to help users improve their fitness and performance.
|
||||
## Advanced Analytics
|
||||
- [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends.
|
||||
- [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts.
|
||||
- [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons.
|
||||
- [ ] **Custom Reports**: Export CSV/PDF; shareable report links.
|
||||
|
||||
## Nutrition and Recovery
|
||||
- [ ] **Nutrition Planning**: Provide tools to help users plan and track their nutrition.
|
||||
- [ ] **Recovery Planning**: Offer resources and tools to help users plan and track their recovery.
|
||||
- [ ] **Injury Prevention and Management**: Provide resources and tools to help users prevent and manage injuries.
|
||||
## Training & Coaching
|
||||
- [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews.
|
||||
- [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes.
|
||||
- [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE).
|
||||
- [ ] **Messaging**: Coach–athlete chat, comments on sessions, file attachments.
|
||||
|
||||
## Social and Community
|
||||
- [ ] **Social Sharing**: Allow users to share their workouts and progress on social media.
|
||||
- [ ] **Community Forum**: Create a community forum where users can connect with each other and share their experiences.
|
||||
- [ ] **Leaderboards**: Provide leaderboards to encourage competition and motivation.
|
||||
## Nutrition & Recovery
|
||||
- [ ] **Nutrition Planning**: Meal plans, macros, carb periodization.
|
||||
- [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking.
|
||||
- [ ] **Recovery Optimization**: Sleep/HRV import, readiness score, rest day prompts.
|
||||
- [ ] **Injury Prevention & Management**: Screeners, red-flag alerts, return-to-ride flow.
|
||||
- [ ] **Supplement & Allergy Flags**: Notes and reminders in plan builder.
|
||||
|
||||
## Integration and Data
|
||||
- [ ] **Integration with Wearable Devices**: Integrate with wearable devices to track user activity and health metrics.
|
||||
- [ ] **Integration with Music Services**: Integrate with music services to provide a more engaging workout experience.
|
||||
- [ ] **Data Import/Export**: Allow users to import and export their data to other platforms.
|
||||
## Community & Social
|
||||
- [ ] **Social Sharing**: One-click share to Strava/social with privacy controls.
|
||||
- [ ] **Community Forum**: Topics, groups/clubs, moderation tools.
|
||||
- [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards.
|
||||
- [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection.
|
||||
|
||||
## Gamification and Engagement
|
||||
- [ ] **Gamification**: Incorporate gamification elements to make the workout experience more engaging and fun.
|
||||
- [ ] **Personalized Recommendations**: Provide personalized recommendations based on user data and goals.
|
||||
- [ ] **Rewards and Incentives**: Offer rewards and incentives to motivate users to reach their fitness goals.
|
||||
## Gamification & Engagement
|
||||
- [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks).
|
||||
- [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles.
|
||||
- [ ] **Rewards & Incentives**: Points store, partner discounts, raffles.
|
||||
|
||||
## Integrations & Data
|
||||
- [ ] **Wearable Sync**: Garmin, Wahoo, COROS, Apple Health, Google Fit.
|
||||
- [ ] **Platform Sync**: Strava, TrainingPeaks, Intervals.icu (calendar + workout push).
|
||||
- [ ] **Music Integration**: Spotify/Apple Music workout-matched playlists.
|
||||
- [ ] **Data Import/Export**: Bulk FIT/TCX/GPX import; CSV/JSON export; takeout ZIP.
|
||||
- [ ] **Public API & Webhooks**: For partners, coaches, clubs.
|
||||
|
||||
## Notifications & Comms
|
||||
- [ ] **Reminders**: Email, push, SMS; smart timing.
|
||||
- [ ] **Digest Emails**: Weekly plan, monthly progress.
|
||||
- [ ] **Real-time Alerts**: Overtraining risk, missed session, weather hazard.
|
||||
|
||||
## Accessibility & Internationalization
|
||||
- [ ] **A11y**: WCAG 2.2 AA, keyboard nav, screen reader labels.
|
||||
- [ ] **Localization**: i18n framework, units (imperial/metric), timezones.
|
||||
- [ ] **Color-blind Safe Palettes**: Analytics & maps.
|
||||
|
||||
## Mobile & Apps
|
||||
- [ ] **PWA Offline Mode**: Log workouts offline; sync when online.
|
||||
- [ ] **Native App Shell**: Background sync, notifications, wearables bridge.
|
||||
|
||||
## Security, Privacy & Compliance
|
||||
- [ ] **Privacy Controls**: Public/private by item, club privacy, anonymized leaderboards.
|
||||
- [ ] **Data Protection**: Encryption at rest/in transit, secrets rotation.
|
||||
- [ ] **Compliance**: GDPR/CCPA requests (export/delete), age gating, COPPA checks.
|
||||
- [ ] **Audit Logs**: Admin and coach actions.
|
||||
|
||||
## Admin, Billing & Ops
|
||||
- [ ] **Admin Console**: User management, feature flags, content moderation.
|
||||
- [ ] **Subscriptions**: Free/Pro/Coach tiers, trials, coupons, taxes (Stripe).
|
||||
- [ ] **Telemetry & Observability**: Metrics, tracing, error reporting, uptime SLOs.
|
||||
- [ ] **Scalability**: Queueing for imports/exports, background jobs.
|
||||
- [ ] **Backups & DR**: Automated backups, restore drills, RTO/RPO defined.
|
||||
|
||||
## Content & Library
|
||||
- [ ] **Exercise Library**: Strength/mobility videos with cues and progressions.
|
||||
- [ ] **Knowledge Base**: Articles on training, nutrition, recovery.
|
||||
- [ ] **Route Library**: GPX planner/import, elevation profiles, weather overlays.
|
||||
|
||||
## Possible Future Features
|
||||
- [ ] **Virtual Reality (VR) Integration**: Integrate with VR technology to provide a more immersive workout experience.
|
||||
- [ ] **Augmented Reality (AR) Integration**: Integrate with AR technology to provide a more interactive and engaging workout experience.
|
||||
- [ ] **Machine Learning (ML) Integration**: Integrate with ML to provide more accurate and personalized recommendations.
|
||||
- [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics.
|
||||
- [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides.
|
||||
- [ ] **Machine Learning (ML) Integration**: Injury risk models, plan optimization, weather-aware ETA and fueling estimates.
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal file
@@ -0,0 +1,50 @@
|
||||
# A generic, single database configuration.
|
||||
|
||||
[alembic]
|
||||
# template used to generate migration files
|
||||
# file_template = %%(rev)s_%%(slug)s
|
||||
|
||||
# set to 'true' to run the environment during
|
||||
# the 'revision' command, regardless of autogenerate
|
||||
# revision_environment = false
|
||||
|
||||
|
||||
# Logging configuration
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic,flask_migrate
|
||||
|
||||
[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
|
||||
|
||||
[logger_flask_migrate]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = flask_migrate
|
||||
|
||||
[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
|
||||
113
migrations/env.py
Normal file
113
migrations/env.py
Normal file
@@ -0,0 +1,113 @@
|
||||
import logging
|
||||
from logging.config import fileConfig
|
||||
|
||||
from flask import current_app
|
||||
|
||||
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.
|
||||
fileConfig(config.config_file_name)
|
||||
logger = logging.getLogger('alembic.env')
|
||||
|
||||
|
||||
def get_engine():
|
||||
try:
|
||||
# this works with Flask-SQLAlchemy<3 and Alchemical
|
||||
return current_app.extensions['migrate'].db.get_engine()
|
||||
except (TypeError, AttributeError):
|
||||
# this works with Flask-SQLAlchemy>=3
|
||||
return current_app.extensions['migrate'].db.engine
|
||||
|
||||
|
||||
def get_engine_url():
|
||||
try:
|
||||
return get_engine().url.render_as_string(hide_password=False).replace(
|
||||
'%', '%%')
|
||||
except AttributeError:
|
||||
return str(get_engine().url).replace('%', '%%')
|
||||
|
||||
|
||||
# add your model's MetaData object here
|
||||
# for 'autogenerate' support
|
||||
# from myapp import mymodel
|
||||
# target_metadata = mymodel.Base.metadata
|
||||
config.set_main_option('sqlalchemy.url', get_engine_url())
|
||||
target_db = current_app.extensions['migrate'].db
|
||||
|
||||
# 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 get_metadata():
|
||||
if hasattr(target_db, 'metadatas'):
|
||||
return target_db.metadatas[None]
|
||||
return target_db.metadata
|
||||
|
||||
|
||||
def run_migrations_offline():
|
||||
"""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 = config.get_main_option("sqlalchemy.url")
|
||||
context.configure(
|
||||
url=url, target_metadata=get_metadata(), literal_binds=True
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online():
|
||||
"""Run migrations in 'online' mode.
|
||||
|
||||
In this scenario we need to create an Engine
|
||||
and associate a connection with the context.
|
||||
|
||||
"""
|
||||
|
||||
# this callback is used to prevent an auto-migration from being generated
|
||||
# when there are no changes to the schema
|
||||
# reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html
|
||||
def process_revision_directives(context, revision, directives):
|
||||
if getattr(config.cmd_opts, 'autogenerate', False):
|
||||
script = directives[0]
|
||||
if script.upgrade_ops.is_empty():
|
||||
directives[:] = []
|
||||
logger.info('No changes in schema detected.')
|
||||
|
||||
conf_args = current_app.extensions['migrate'].configure_args
|
||||
if conf_args.get("process_revision_directives") is None:
|
||||
conf_args["process_revision_directives"] = process_revision_directives
|
||||
|
||||
connectable = get_engine()
|
||||
|
||||
with connectable.connect() as connection:
|
||||
context.configure(
|
||||
connection=connection,
|
||||
target_metadata=get_metadata(),
|
||||
**conf_args
|
||||
)
|
||||
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
24
migrations/script.py.mako
Normal file
24
migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
99
migrations/versions/0e07095d2961_initial_migration.py
Normal file
99
migrations/versions/0e07095d2961_initial_migration.py
Normal file
@@ -0,0 +1,99 @@
|
||||
"""Initial migration
|
||||
|
||||
Revision ID: 0e07095d2961
|
||||
Revises:
|
||||
Create Date: 2025-08-29 01:28:57.822103
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '0e07095d2961'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_table('admins')
|
||||
with op.batch_alter_table('subscribers', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('idx_subscribers_created_at'))
|
||||
batch_op.drop_index(batch_op.f('idx_subscribers_email'))
|
||||
batch_op.drop_index(batch_op.f('idx_subscribers_status'))
|
||||
|
||||
op.drop_table('subscribers')
|
||||
op.drop_table('admin_users')
|
||||
op.drop_table('email_deliveries')
|
||||
with op.batch_alter_table('newsletters', schema=None) as batch_op:
|
||||
batch_op.drop_index(batch_op.f('idx_newsletters_sent_at'))
|
||||
|
||||
op.drop_table('newsletters')
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade():
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.create_table('newsletters',
|
||||
sa.Column('id', sa.INTEGER(), server_default=sa.text("nextval('newsletters_id_seq'::regclass)"), autoincrement=True, nullable=False),
|
||||
sa.Column('subject', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('body', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('sent_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('sent_by', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('recipient_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('success_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.Column('failure_count', sa.INTEGER(), server_default=sa.text('0'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name='newsletters_pkey'),
|
||||
postgresql_ignore_search_path=False
|
||||
)
|
||||
with op.batch_alter_table('newsletters', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('idx_newsletters_sent_at'), [sa.literal_column('sent_at DESC')], unique=False)
|
||||
|
||||
op.create_table('email_deliveries',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('newsletter_id', sa.INTEGER(), autoincrement=False, nullable=True),
|
||||
sa.Column('email', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('status', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.Column('sent_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('error_message', sa.TEXT(), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("status = ANY (ARRAY['sent'::text, 'failed'::text, 'bounced'::text])", name=op.f('email_deliveries_status_check')),
|
||||
sa.ForeignKeyConstraint(['newsletter_id'], ['newsletters.id'], name=op.f('email_deliveries_newsletter_id_fkey')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('email_deliveries_pkey'))
|
||||
)
|
||||
op.create_table('admin_users',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('username', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('password', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('last_login', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=True),
|
||||
sa.Column('is_active', sa.BOOLEAN(), server_default=sa.text('true'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('admin_users_pkey')),
|
||||
sa.UniqueConstraint('username', name=op.f('admin_users_username_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_table('subscribers',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('email', sa.TEXT(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('subscribed_at', postgresql.TIMESTAMP(timezone=True), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.Column('status', sa.TEXT(), server_default=sa.text("'active'::text"), autoincrement=False, nullable=True),
|
||||
sa.Column('source', sa.TEXT(), server_default=sa.text("'manual'::text"), autoincrement=False, nullable=True),
|
||||
sa.CheckConstraint("status = ANY (ARRAY['active'::text, 'unsubscribed'::text])", name=op.f('subscribers_status_check')),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('subscribers_pkey')),
|
||||
sa.UniqueConstraint('email', name=op.f('subscribers_email_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
with op.batch_alter_table('subscribers', schema=None) as batch_op:
|
||||
batch_op.create_index(batch_op.f('idx_subscribers_status'), ['status'], unique=False)
|
||||
batch_op.create_index(batch_op.f('idx_subscribers_email'), ['email'], unique=False)
|
||||
batch_op.create_index(batch_op.f('idx_subscribers_created_at'), [sa.literal_column('created_at DESC')], unique=False)
|
||||
|
||||
op.create_table('admins',
|
||||
sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False),
|
||||
sa.Column('username', sa.VARCHAR(length=100), autoincrement=False, nullable=False),
|
||||
sa.Column('password_hash', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(), server_default=sa.text('CURRENT_TIMESTAMP'), autoincrement=False, nullable=True),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('admins_pkey')),
|
||||
sa.UniqueConstraint('username', name=op.f('admins_username_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
# ### end Alembic commands ###
|
||||
@@ -8,6 +8,7 @@ class User(db.Model):
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(80), unique=True, nullable=False)
|
||||
email = db.Column(db.String(120), unique=True, nullable=False) # Add email field
|
||||
_password = db.Column("password", db.String(255), nullable=False)
|
||||
|
||||
profile = db.relationship('UserProfile', back_populates='user', uselist=False, cascade="all, delete-orphan")
|
||||
@@ -29,11 +30,11 @@ class User(db.Model):
|
||||
@event.listens_for(User, 'after_insert')
|
||||
def create_user_profile(mapper, connection, target):
|
||||
connection.execute(
|
||||
UserProfile.__table__.insert().values (
|
||||
user_id = target.id,
|
||||
first_name = "",
|
||||
last_name = "",
|
||||
bio = "",
|
||||
profile_picture = ""
|
||||
UserProfile.__table__.insert().values(
|
||||
user_id=target.id,
|
||||
first_name="",
|
||||
last_name="",
|
||||
bio="",
|
||||
profile_picture=""
|
||||
)
|
||||
)
|
||||
@@ -1,14 +1,13 @@
|
||||
from models import db
|
||||
|
||||
class UserProfile(db.Model):
|
||||
__tablename__ = 'user_profile'
|
||||
__tablename__ = 'user_profiles'
|
||||
|
||||
id = db.Column(db.Integer, primary_key = True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable = False)
|
||||
first_name = db.Column(db.String(80), nullable = False)
|
||||
last_name = db.Column(db.String(80), nullable = False)
|
||||
bio = db.Column(db.Text, nullable = True)
|
||||
profile_picture = db.Column(db.String(255), nullable = True)
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
first_name = db.Column(db.String(50), nullable=False, default="")
|
||||
last_name = db.Column(db.String(50), nullable=False, default="")
|
||||
bio = db.Column(db.Text, default="")
|
||||
profile_picture = db.Column(db.String(255), default="")
|
||||
|
||||
user = db.relationship('User', back_populates='profile')
|
||||
|
||||
user = db.relationship('User', back_populates='profile')
|
||||
@@ -5,11 +5,11 @@ from urllib.parse import quote_plus
|
||||
|
||||
load_dotenv()
|
||||
|
||||
PG_USER = quote_plus(os.getenv('PG_USER'))
|
||||
PG_PASSWORD = quote_plus(os.getenv('PG_PASSWORD'))
|
||||
PG_HOST = os.getenv('PG_HOST')
|
||||
PG_PORT = os.getenv('PG_PORT')
|
||||
PG_DATABASE = os.getenv('PG_DATABASE')
|
||||
PG_USER = quote_plus(os.getenv("PG_USER", "postgres"))
|
||||
PG_PASSWORD = quote_plus(os.getenv("PG_PASSWORD", "postgres"))
|
||||
PG_HOST = os.getenv("PG_HOST", "localhost")
|
||||
PG_PORT = os.getenv("PG_PORT", "5432")
|
||||
PG_DATABASE = os.getenv("PG_DATABASE", "rideaware")
|
||||
|
||||
DATABASE_URI = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DATABASE}"
|
||||
|
||||
|
||||
@@ -4,4 +4,5 @@ flask_cors
|
||||
flask_sqlalchemy
|
||||
python-dotenv
|
||||
werkzeug
|
||||
psycopg2-binary
|
||||
psycopg2-binary
|
||||
Flask-Migrate
|
||||
@@ -1,19 +1,35 @@
|
||||
from flask import Blueprint, request, jsonify, session
|
||||
from services.UserService.user import UserService
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api")
|
||||
user_service = UserService()
|
||||
|
||||
|
||||
@auth_bp.route("/signup", methods=["POST"])
|
||||
def signup():
|
||||
data = request.get_json()
|
||||
if not data:
|
||||
return jsonify({"message": "No data provided"}), 400
|
||||
|
||||
required_fields = ['username', 'password']
|
||||
for field in required_fields:
|
||||
if not data.get(field):
|
||||
return jsonify({"message": f"{field} is required"}), 400
|
||||
|
||||
try:
|
||||
new_user = user_service.create_user(data["username"], data["password"])
|
||||
return (
|
||||
jsonify({"message": "User created successfully", "username": new_user.username}),
|
||||
201,
|
||||
new_user = user_service.create_user(
|
||||
username=data["username"],
|
||||
password=data["password"],
|
||||
email=data.get("email"),
|
||||
first_name=data.get("first_name"),
|
||||
last_name=data.get("last_name")
|
||||
)
|
||||
|
||||
return jsonify({
|
||||
"message": "User created successfully",
|
||||
"username": new_user.username,
|
||||
"user_id": new_user.id
|
||||
}), 201
|
||||
|
||||
except ValueError as e:
|
||||
return jsonify({"message": str(e)}), 400
|
||||
except Exception as e:
|
||||
@@ -21,19 +37,12 @@ def signup():
|
||||
print(f"Signup error: {e}")
|
||||
return jsonify({"message": "Internal server error"}), 500
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
data = request.get_json()
|
||||
<<<<<<< HEAD
|
||||
username = data.get("username")
|
||||
password = data.get("password")
|
||||
|
||||
print(f"Login attempt: username={username}, password={password}")
|
||||
|
||||
=======
|
||||
|
||||
>>>>>>> 3ab162d8b88a23ad1d0ef5f72a3162bdd7f75ca8
|
||||
try:
|
||||
user = user_service.verify_user(username, password)
|
||||
session["user_id"] = user.id
|
||||
@@ -45,8 +54,7 @@ def login():
|
||||
print(f"Login error: {e}")
|
||||
return jsonify({"error": "Internal server error"}), 500
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
session.clear()
|
||||
return jsonify({"message": "Logout successful"}), 200
|
||||
return jsonify({"message": "Logout successful"}), 200
|
||||
8
scripts/migrate.sh
Normal file
8
scripts/migrate.sh
Normal file
@@ -0,0 +1,8 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
|
||||
echo "Running database migrations..."
|
||||
flask db upgrade
|
||||
|
||||
echo "Starting application..."
|
||||
exec "$@"
|
||||
11
server.py
11
server.py
@@ -2,6 +2,8 @@ import os
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from dotenv import load_dotenv
|
||||
from flask_migrate import Migrate
|
||||
from flask.cli import FlaskGroup
|
||||
|
||||
from models import db, init_db
|
||||
from routes.user_auth import auth
|
||||
@@ -13,9 +15,10 @@ app.config["SECRET_KEY"] = os.getenv("SECRET_KEY")
|
||||
app.config["SQLALCHEMY_DATABASE_URI"] = os.getenv("DATABASE")
|
||||
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
|
||||
|
||||
CORS(app) # Consider specific origins in production
|
||||
CORS(app)
|
||||
|
||||
init_db(app)
|
||||
migrate = Migrate(app, db)
|
||||
app.register_blueprint(auth.auth_bp)
|
||||
|
||||
|
||||
@@ -24,9 +27,7 @@ def health_check():
|
||||
"""Health check endpoint."""
|
||||
return "OK", 200
|
||||
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
cli = FlaskGroup(app)
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(debug=True)
|
||||
cli()
|
||||
@@ -1,42 +1,60 @@
|
||||
from models.User.user import User, db
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from models.User.user import User
|
||||
from models.UserProfile.user_profile import UserProfile
|
||||
from models import db
|
||||
import re
|
||||
|
||||
class UserService:
|
||||
def create_user(self, username, password):
|
||||
def create_user(self, username, password, email=None, first_name=None, last_name=None):
|
||||
if not username or not password:
|
||||
raise ValueError("Username and password are required")
|
||||
|
||||
if len(username) < 3 or len(password) < 8:
|
||||
raise ValueError(
|
||||
"Username must be at least 3 characters and password must be at least 8 characters."
|
||||
)
|
||||
|
||||
existing_user = User.query.filter_by(username=username).first()
|
||||
|
||||
if email:
|
||||
email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
|
||||
if not re.match(email_regex, email):
|
||||
raise ValueError("Invalid email format")
|
||||
|
||||
existing_user = User.query.filter(
|
||||
(User.username == username) | (User.email == email)
|
||||
).first()
|
||||
|
||||
if existing_user:
|
||||
raise ValueError("User already exists")
|
||||
|
||||
new_user = User(username=username, password=password)
|
||||
db.session.add(new_user)
|
||||
if existing_user.username == username:
|
||||
raise ValueError("Username already exists")
|
||||
else:
|
||||
raise ValueError("Email already exists")
|
||||
|
||||
if len(password) < 8:
|
||||
raise ValueError("Password must be at least 8 characters long")
|
||||
|
||||
try:
|
||||
new_user = User(
|
||||
username=username,
|
||||
email=email or "",
|
||||
password=password
|
||||
)
|
||||
|
||||
db.session.add(new_user)
|
||||
db.session.flush()
|
||||
|
||||
user_profile = UserProfile(
|
||||
user_id=new_user.id,
|
||||
first_name=first_name or "",
|
||||
last_name=last_name or "",
|
||||
bio="",
|
||||
profile_picture=""
|
||||
)
|
||||
|
||||
db.session.add(user_profile)
|
||||
db.session.commit()
|
||||
|
||||
return new_user
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
logger.error(f"Error creating user: {e}")
|
||||
raise ValueError("Could not create user") from e
|
||||
return new_user
|
||||
|
||||
raise Exception(f"Error creating user: {str(e)}")
|
||||
|
||||
def verify_user(self, username, password):
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if not user:
|
||||
logger.warning(f"User not found: {username}")
|
||||
if not user or not user.check_password(password):
|
||||
raise ValueError("Invalid username or password")
|
||||
|
||||
if not user.check_password(password):
|
||||
logger.warning(f"Invalid password for user: {username}")
|
||||
raise ValueError("Invalid username or password")
|
||||
|
||||
logger.info(f"User verified: {username}")
|
||||
return user
|
||||
return user
|
||||
Reference in New Issue
Block a user