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:
Cipher Vance
2025-09-09 08:35:29 -05:00
committed by GitHub
17 changed files with 548 additions and 110 deletions

9
.dockerignore Normal file
View File

@@ -0,0 +1,9 @@
.git
__pycache__/
*.py[cod]
*.log
!.env
venv/
.venv/
dist/
build/

15
.gitignore vendored
View File

@@ -172,3 +172,18 @@ cython_debug/
# PyPI configuration file # 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/

View File

@@ -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 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 . COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt RUN python -m pip install --upgrade pip && \
COPY . . 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 EXPOSE 5000
ENV FLASK_APP=server.py HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
ENV FLASK_RUN_HOST=0.0.0.0 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
View File

@@ -1,46 +1,98 @@
# TODO Features # TODO Features
## User Management ## User Management
- [ ] **User Registration**: Allow users to create an account and log in to access their data. - [ ] **User Registration & Login**: Email, OAuth (Google, Apple, Strava, Garmin).
- [ ] **User Profile**: Provide a user profile page to display user information and progress. - [ ] **User Profile**: Bio, stats, zones (HR/Power), equipment, FTP history, weight.
- [ ] **Password Recovery**: Allow users to recover their password if they forget it. - [ ] **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 ## Workout Planning
- [ ] **Customizable Training Plans**: Allow users to create customized training plans based on their goals and fitness level. - [ ] **AI-Powered Planning**: Generate plans by goal, time, fitness level.
- [ ] **Workout Scheduling**: Provide a feature to schedule workouts and set reminders. - [ ] **Adaptive Scheduling**: Auto-reschedule based on missed sessions, fatigue, weather.
- [ ] **Goal Setting**: Allow users to set and track their fitness goals. - [ ] **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 Tracking
- [ ] **Workout Logging**: Allow users to log their workouts, including exercises, sets, reps, and weight. - [ ] **Workout Logging**: Exercises, sets/reps/weight; power, HR, cadence, GPS.
- [ ] **Data Analysis**: Provide tools to analyze user data, including charts, graphs, and statistics. - [ ] **Device Capture**: Live recording (Bluetooth/ANT+ when supported), file upload (FIT/TCX/GPX).
- [ ] **Progress Tracking**: Allow users to track their progress over time. - [ ] **Tags & Notes**: RPE, mood, conditions, injuries, equipment used.
- [ ] **Equipment Tracking**: Bike/components mileage, service reminders.
## Training and Coaching ## Advanced Analytics
- [ ] **Coaching and Guidance**: Provide coaching and guidance to help users achieve their fitness goals. - [ ] **Interactive Dashboards**: Charts for load (CTL/ATL/TSB), power curves, trends.
- [ ] **Virtual Training Rides**: Offer immersive virtual training rides to boost users' cycling performance. - [ ] **Progress Insights (AI)**: Automatic highlights, plateau detection, anomaly alerts.
- [ ] **Structured Workouts**: Offer structured workouts to help users improve their fitness and performance. - [ ] **Comparisons**: Before/after, season-over-season, segment/time comparisons.
- [ ] **Custom Reports**: Export CSV/PDF; shareable report links.
## Nutrition and Recovery ## Training & Coaching
- [ ] **Nutrition Planning**: Provide tools to help users plan and track their nutrition. - [ ] **Coaching & Guidance**: Coach portal, athlete assignments, plan reviews.
- [ ] **Recovery Planning**: Offer resources and tools to help users plan and track their recovery. - [ ] **Virtual Training Rides**: Integrations with Zwift/Rouvy/RGT; video routes.
- [ ] **Injury Prevention and Management**: Provide resources and tools to help users prevent and manage injuries. - [ ] **Structured Workouts**: Interval builder with targets (%FTP, %HRR, RPE).
- [ ] **Messaging**: Coachathlete chat, comments on sessions, file attachments.
## Social and Community ## Nutrition & Recovery
- [ ] **Social Sharing**: Allow users to share their workouts and progress on social media. - [ ] **Nutrition Planning**: Meal plans, macros, carb periodization.
- [ ] **Community Forum**: Create a community forum where users can connect with each other and share their experiences. - [ ] **Nutrition Tracking**: Food log, barcode/manual entry, hydration tracking.
- [ ] **Leaderboards**: Provide leaderboards to encourage competition and motivation. - [ ] **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 ## Community & Social
- [ ] **Integration with Wearable Devices**: Integrate with wearable devices to track user activity and health metrics. - [ ] **Social Sharing**: One-click share to Strava/social with privacy controls.
- [ ] **Integration with Music Services**: Integrate with music services to provide a more engaging workout experience. - [ ] **Community Forum**: Topics, groups/clubs, moderation tools.
- [ ] **Data Import/Export**: Allow users to import and export their data to other platforms. - [ ] **Leaderboards**: Global, club, event, and route/segment leaderboards.
- [ ] **Challenges & Streaks**: Time-boxed events, badges, streak protection.
## Gamification and Engagement ## Gamification & Engagement
- [ ] **Gamification**: Incorporate gamification elements to make the workout experience more engaging and fun. - [ ] **Achievements & Badges**: Milestones (consistency, PRs, climbing, streaks).
- [ ] **Personalized Recommendations**: Provide personalized recommendations based on user data and goals. - [ ] **Personalized Recommendations (AI)**: Next best workout, videos, articles.
- [ ] **Rewards and Incentives**: Offer rewards and incentives to motivate users to reach their fitness goals. - [ ] **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 ## Possible Future Features
- [ ] **Virtual Reality (VR) Integration**: Integrate with VR technology to provide a more immersive workout experience. - [ ] **Virtual Reality (VR) Integration**: Immersive rides with real-time metrics.
- [ ] **Augmented Reality (AR) Integration**: Integrate with AR technology to provide a more interactive and engaging workout experience. - [ ] **Augmented Reality (AR) Integration**: HUD overlays during rides.
- [ ] **Machine Learning (ML) Integration**: Integrate with ML to provide more accurate and personalized recommendations. - [ ] **Machine Learning (ML) Integration**: Injury risk models, plan optimization, weather-aware ETA and fueling estimates.

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View 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
View 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
View 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"}

View 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 ###

View File

@@ -8,6 +8,7 @@ class User(db.Model):
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False) 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) _password = db.Column("password", db.String(255), nullable=False)
profile = db.relationship('UserProfile', back_populates='user', uselist=False, cascade="all, delete-orphan") profile = db.relationship('UserProfile', back_populates='user', uselist=False, cascade="all, delete-orphan")

View File

@@ -1,14 +1,13 @@
from models import db from models import db
class UserProfile(db.Model): class UserProfile(db.Model):
__tablename__ = 'user_profile' __tablename__ = 'user_profiles'
id = db.Column(db.Integer, primary_key=True) id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
first_name = db.Column(db.String(80), nullable = False) first_name = db.Column(db.String(50), nullable=False, default="")
last_name = db.Column(db.String(80), nullable = False) last_name = db.Column(db.String(50), nullable=False, default="")
bio = db.Column(db.Text, nullable = True) bio = db.Column(db.Text, default="")
profile_picture = db.Column(db.String(255), nullable = True) profile_picture = db.Column(db.String(255), default="")
user = db.relationship('User', back_populates='profile') user = db.relationship('User', back_populates='profile')

View File

@@ -5,11 +5,11 @@ from urllib.parse import quote_plus
load_dotenv() load_dotenv()
PG_USER = quote_plus(os.getenv('PG_USER')) PG_USER = quote_plus(os.getenv("PG_USER", "postgres"))
PG_PASSWORD = quote_plus(os.getenv('PG_PASSWORD')) PG_PASSWORD = quote_plus(os.getenv("PG_PASSWORD", "postgres"))
PG_HOST = os.getenv('PG_HOST') PG_HOST = os.getenv("PG_HOST", "localhost")
PG_PORT = os.getenv('PG_PORT') PG_PORT = os.getenv("PG_PORT", "5432")
PG_DATABASE = os.getenv('PG_DATABASE') PG_DATABASE = os.getenv("PG_DATABASE", "rideaware")
DATABASE_URI = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DATABASE}" DATABASE_URI = f"postgresql+psycopg2://{PG_USER}:{PG_PASSWORD}@{PG_HOST}:{PG_PORT}/{PG_DATABASE}"

View File

@@ -5,3 +5,4 @@ flask_sqlalchemy
python-dotenv python-dotenv
werkzeug werkzeug
psycopg2-binary psycopg2-binary
Flask-Migrate

View File

@@ -1,19 +1,35 @@
from flask import Blueprint, request, jsonify, session from flask import Blueprint, request, jsonify, session
from services.UserService.user import UserService 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() user_service = UserService()
@auth_bp.route("/signup", methods=["POST"]) @auth_bp.route("/signup", methods=["POST"])
def signup(): def signup():
data = request.get_json() 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: try:
new_user = user_service.create_user(data["username"], data["password"]) new_user = user_service.create_user(
return ( username=data["username"],
jsonify({"message": "User created successfully", "username": new_user.username}), password=data["password"],
201, 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: except ValueError as e:
return jsonify({"message": str(e)}), 400 return jsonify({"message": str(e)}), 400
except Exception as e: except Exception as e:
@@ -21,19 +37,12 @@ def signup():
print(f"Signup error: {e}") print(f"Signup error: {e}")
return jsonify({"message": "Internal server error"}), 500 return jsonify({"message": "Internal server error"}), 500
@auth_bp.route("/login", methods=["POST"]) @auth_bp.route("/login", methods=["POST"])
def login(): def login():
data = request.get_json() data = request.get_json()
<<<<<<< HEAD
username = data.get("username") username = data.get("username")
password = data.get("password") password = data.get("password")
print(f"Login attempt: username={username}, password={password}") print(f"Login attempt: username={username}, password={password}")
=======
>>>>>>> 3ab162d8b88a23ad1d0ef5f72a3162bdd7f75ca8
try: try:
user = user_service.verify_user(username, password) user = user_service.verify_user(username, password)
session["user_id"] = user.id session["user_id"] = user.id
@@ -45,7 +54,6 @@ def login():
print(f"Login error: {e}") print(f"Login error: {e}")
return jsonify({"error": "Internal server error"}), 500 return jsonify({"error": "Internal server error"}), 500
@auth_bp.route("/logout", methods=["POST"]) @auth_bp.route("/logout", methods=["POST"])
def logout(): def logout():
session.clear() session.clear()

8
scripts/migrate.sh Normal file
View File

@@ -0,0 +1,8 @@
#!/bin/bash
set -e
echo "Running database migrations..."
flask db upgrade
echo "Starting application..."
exec "$@"

View File

@@ -2,6 +2,8 @@ import os
from flask import Flask from flask import Flask
from flask_cors import CORS from flask_cors import CORS
from dotenv import load_dotenv from dotenv import load_dotenv
from flask_migrate import Migrate
from flask.cli import FlaskGroup
from models import db, init_db from models import db, init_db
from routes.user_auth import auth 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_DATABASE_URI"] = os.getenv("DATABASE")
app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False
CORS(app) # Consider specific origins in production CORS(app)
init_db(app) init_db(app)
migrate = Migrate(app, db)
app.register_blueprint(auth.auth_bp) app.register_blueprint(auth.auth_bp)
@@ -24,9 +27,7 @@ def health_check():
"""Health check endpoint.""" """Health check endpoint."""
return "OK", 200 return "OK", 200
cli = FlaskGroup(app)
with app.app_context():
db.create_all()
if __name__ == "__main__": if __name__ == "__main__":
app.run(debug=True) cli()

View File

@@ -1,42 +1,60 @@
from models.User.user import User, db from models.User.user import User
import logging from models.UserProfile.user_profile import UserProfile
from models import db
logger = logging.getLogger(__name__) import re
class UserService: 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: if not username or not password:
raise ValueError("Username and password are required") raise ValueError("Username and password are required")
if len(username) < 3 or len(password) < 8: if email:
raise ValueError( email_regex = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$'
"Username must be at least 3 characters and password must be at least 8 characters." 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:
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
) )
existing_user = User.query.filter_by(username=username).first()
if existing_user:
raise ValueError("User already exists")
new_user = User(username=username, password=password)
db.session.add(new_user) db.session.add(new_user)
try: 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() db.session.commit()
return new_user
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
logger.error(f"Error creating user: {e}") raise Exception(f"Error creating user: {str(e)}")
raise ValueError("Could not create user") from e
return new_user
def verify_user(self, username, password): def verify_user(self, username, password):
user = User.query.filter_by(username=username).first() user = User.query.filter_by(username=username).first()
if not user: if not user or not user.check_password(password):
logger.warning(f"User not found: {username}")
raise ValueError("Invalid username or 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