diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..72683d6 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,25 @@ +[alembic] +script_location = migrations + +[loggers] +keys = root,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = DEBUG +handlers = console +qualname = + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = DEBUG +formatter = generic + +[formatter_generic] +format = %(levelname)s: %(message)s diff --git a/app.py b/app.py index da6790f..3a86516 100644 --- a/app.py +++ b/app.py @@ -1,7 +1,13 @@ import logging +from datetime import datetime +from src.utils.constants import SERVICE_ACCOUNT_PATH, JWT_SECRET_KEY import os import sentry_sdk from flask import Flask, render_template +from flask_apscheduler import APScheduler +from flask_graphql import GraphQLView +from flask_jwt_extended import JWTManager +import sys from graphene import Schema from graphql.utils import schema_printer from src.database import db_session, init_db @@ -15,7 +21,7 @@ # Check if we're in migration mode with error handling try: - FLASK_MIGRATE = os.getenv('FLASK_MIGRATE', 'false').lower() == 'true' + FLASK_MIGRATE = os.getenv("FLASK_MIGRATE", "false").lower() == "true" except Exception as e: logging.warning(f"Error reading FLASK_MIGRATE environment variable: {e}. Defaulting to false.") FLASK_MIGRATE = False @@ -23,7 +29,7 @@ # Only import scraping-related modules if not in migration mode if not FLASK_MIGRATE: from flask_apscheduler import APScheduler - from src.scrapers.capacities_scraper import fetch_capacities + from src.scrapers.capacities_scraper import fetch_capacities, update_hourly_capacity from src.scrapers.reg_hours_scraper import fetch_reg_building, fetch_reg_facility from src.scrapers.scraper_helpers import clean_past_hours from src.scrapers.sp_hours_scraper import fetch_sp_facility @@ -31,6 +37,26 @@ from src.scrapers.class_scraper import fetch_classes from src.scrapers.activities_scraper import fetch_activity from src.utils.utils import create_gym_table + from src.models.workout_reminder import WorkoutReminder + from src.models.user import User as UserModel + from src.utils.messaging import send_workout_reminders +from src.models.openhours import OpenHours +from src.models.enums import DayOfWeekEnum +import firebase_admin +from firebase_admin import credentials, messaging + + +def initialize_firebase(): + if not firebase_admin._apps: + if SERVICE_ACCOUNT_PATH: + cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) + firebase_app = firebase_admin.initialize_app(cred) + else: + raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") + else: + firebase_app = firebase_admin.get_app() + logging.info("Firebase app created...") + return firebase_app sentry_sdk.init( dsn="https://2a96f65cca45d8a7c3ffc3b878d4346b@o4507365244010496.ingest.us.sentry.io/4507850536386560", @@ -40,20 +66,24 @@ app = Flask(__name__) app.debug = True +initialize_firebase() # Verify all required variables are present if not all([db_user, db_password, db_name, db_host, db_port]): raise ValueError( - "Missing required database configuration. " - "Please ensure all database environment variables are set." + "Missing required database configuration. " "Please ensure all database environment variables are set." ) -app.config['SQLALCHEMY_DATABASE_URI'] = db_url -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config["SQLALCHEMY_DATABASE_URI"] = db_url +app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False migrate = Migrate(app, db) schema = Schema(query=Query, mutation=Mutation) swagger = Swagger(app) +app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY +jwt = JWTManager(app) + + def should_run_initial_scrape(): """ Check if we should run initial scraping: @@ -64,10 +94,11 @@ def should_run_initial_scrape(): if FLASK_MIGRATE: return False # Check if we're in the main process - werkzeug_var = os.environ.get('WERKZEUG_RUN_MAIN') + werkzeug_var = os.environ.get("WERKZEUG_RUN_MAIN") # Logic: if in local, then werkzeug_var exists: so only run when true to prevent double running # If in Gunicorn, then werkzeug_var is None, so then it will also run - return werkzeug_var is None or werkzeug_var == 'true' + return werkzeug_var is None or werkzeug_var == "true" + # Initialize scheduler only if not in migration mode if not FLASK_MIGRATE: @@ -83,12 +114,15 @@ def should_run_initial_scrape(): def index(): return render_template("index.html") + app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql", schema=schema, graphiql=True)) + @app.teardown_appcontext def shutdown_session(exception=None): db_session.remove() + # Only define scheduler tasks if not in migration mode if not FLASK_MIGRATE: # Scrape hours every 15 minutes @@ -114,14 +148,31 @@ def scrape_capacities(): except Exception as e: logging.error(f"Error in scrape_capacities: {e}") - # Scrape classes every hour - @scheduler.task("interval", id="scrape_classes", seconds=3600) - def scrape_classes(): - try: - logging.info("Scraping classes from group-fitness-classes...") - fetch_classes(10) - except Exception as e: - logging.error(f"Error in scrape_classes: {e}") + +# Scrape classes every hour +@scheduler.task("interval", id="scrape_classes", seconds=3600) +def scrape_classes(): + logging.info("Scraping classes from group-fitness-classes...") + fetch_classes(10) + + +# Send workout reminders every morning at 12:00 AM +@scheduler.task("cron", id="send_reminders", hour=0, minute=0) +def scheduled_job(): + logging.info("Sending workout reminders...") + send_workout_reminders() + + +# Update hourly average capacity every hour +@scheduler.task("cron", id="update_capacity", hour="*") +def scheduled_job(): + current_time = datetime.now() + current_day = current_time.strftime("%A").upper() + current_hour = current_time.hour + + logging.info(f"Updating hourly average capacity for {current_day}, hour {current_hour}...") + update_hourly_capacity(current_day, current_hour) + # Create database init_db() diff --git a/migrations/alembic.ini b/migrations/alembic.ini deleted file mode 100644 index f8ed480..0000000 --- a/migrations/alembic.ini +++ /dev/null @@ -1,45 +0,0 @@ -# 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 - -[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 diff --git a/migrations/env.py b/migrations/env.py index 6b3bfa6..0df095e 100644 --- a/migrations/env.py +++ b/migrations/env.py @@ -1,90 +1,53 @@ -from __future__ import with_statement +import sys +import os from flask import current_app - -import logging -from logging.config import fileConfig - -from sqlalchemy import engine_from_config -from sqlalchemy import pool - from alembic import context +from sqlalchemy import engine_from_config, pool +from logging.config import fileConfig +import logging -# this is the Alembic Config object, which provides -# access to the values within the .ini file in use. -config = context.config +# This sets up logging with a fallback if the config is missing or incorrect +try: + fileConfig(context.config.config_file_name) +except KeyError: + logging.basicConfig(level=logging.INFO, format="%(levelname)-5.5s [%(name)s] %(message)s") -# Interpret the config file for Python logging. -# This line sets up loggers basically. -fileConfig(config.config_file_name) -logger = logging.getLogger('alembic.env') +print(f"Using Alembic config file: {context.config.config_file_name}") -# 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', - current_app.config.get('SQLALCHEMY_DATABASE_URI')) -target_metadata = current_app.extensions['migrate'].db.metadata -# 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. +# Add your project directory to the Python path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '../'))) +from app import app +# Alembic Config object +config = context.config -def run_migrations_offline(): - """Run migrations in 'offline' mode. +# # Configure logging +# fileConfig(config.config_file_name) - 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. +# Set SQLAlchemy URL and metadata +with app.app_context(): + config.set_main_option('sqlalchemy.url', current_app.config['SQLALCHEMY_DATABASE_URI']) + target_metadata = current_app.extensions['migrate'].db.metadata - Calls to context.execute() here emit the given string to the - script output. - """ +def run_migrations_offline(): + """Run migrations in 'offline' mode.""" url = config.get_main_option("sqlalchemy.url") - context.configure( - url=url, target_metadata=target_metadata, literal_binds=True - ) - + context.configure(url=url, target_metadata=target_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.') - + """Run migrations in 'online' mode.""" 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, - process_revision_directives=process_revision_directives, - **current_app.extensions['migrate'].configure_args - ) - + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() @@ -92,4 +55,4 @@ def process_revision_directives(context, revision, directives): if context.is_offline_mode(): run_migrations_offline() else: - run_migrations_online() + run_migrations_online() \ No newline at end of file diff --git a/migrations/versions/24684343da0f_update_equipment_table_with_muscle_.py b/migrations/versions/24684343da0f_update_equipment_table_with_muscle_.py index bf9d7f2..cb65710 100644 --- a/migrations/versions/24684343da0f_update_equipment_table_with_muscle_.py +++ b/migrations/versions/24684343da0f_update_equipment_table_with_muscle_.py @@ -32,49 +32,38 @@ class MuscleGroup(PyEnum): CARDIO = 12 def upgrade(): - # Create new muscle_group enum type - muscle_group_enum = postgresql.ENUM( - 'ABDOMINALS', 'CHEST', 'BACK', 'SHOULDERS', 'BICEPS', 'TRICEPS', - 'HAMSTRINGS', 'QUADS', 'GLUTES', 'CALVES', 'MISCELLANEOUS', 'CARDIO', - name='musclegroup' - ) - muscle_group_enum.create(op.get_bind()) + # Get the connection and inspector + conn = op.get_bind() + inspector = Inspector.from_engine(conn) - # Add new columns first - op.add_column('equipment', sa.Column('clean_name', sa.String(), nullable=True)) - op.add_column('equipment', - sa.Column('muscle_groups', postgresql.ARRAY(muscle_group_enum), nullable=True) - ) + # Get the list of existing columns in the 'equipment' table + columns = [col["name"] for col in inspector.get_columns("equipment")] - # Update data: Set clean_name equal to name initially - op.execute('UPDATE equipment SET clean_name = name') + # Check if 'clean_name' exists before adding it + if "clean_name" not in columns: + op.add_column('equipment', sa.Column('clean_name', sa.String(), nullable=True)) - # Convert equipment_type to muscle_groups based on mapping - op.execute(""" - UPDATE equipment SET muscle_groups = CASE - WHEN equipment_type = 'cardio' THEN ARRAY['CARDIO']::musclegroup[] - WHEN equipment_type = 'racks_and_benches' THEN ARRAY['CHEST', 'BACK', 'SHOULDERS']::musclegroup[] - WHEN equipment_type = 'selectorized' THEN ARRAY['MISCELLANEOUS']::musclegroup[] - WHEN equipment_type = 'multi_cable' THEN ARRAY['MISCELLANEOUS']::musclegroup[] - WHEN equipment_type = 'free_weights' THEN ARRAY['MISCELLANEOUS']::musclegroup[] - WHEN equipment_type = 'plate_loaded' THEN ARRAY['MISCELLANEOUS']::musclegroup[] - ELSE ARRAY['MISCELLANEOUS']::musclegroup[] - END - """) + # Check if 'muscle_groups' exists before adding it + if "muscle_groups" not in columns: + muscle_group_enum = postgresql.ENUM( + 'ABDOMINALS', 'CHEST', 'BACK', 'SHOULDERS', 'BICEPS', 'TRICEPS', + 'HAMSTRINGS', 'QUADS', 'GLUTES', 'CALVES', 'MISCELLANEOUS', 'CARDIO', + name='musclegroup' + ) + muscle_group_enum.create(op.get_bind()) - # Make clean_name not nullable after updating data - op.alter_column('equipment', 'clean_name', - existing_type=sa.String(), - nullable=False) + op.add_column('equipment', sa.Column('muscle_groups', postgresql.ARRAY(muscle_group_enum), nullable=True)) - # Make muscle_groups not nullable after data migration - op.alter_column('equipment', 'muscle_groups', - existing_type=postgresql.ARRAY(muscle_group_enum), - nullable=False) + # Continue with other operations, ensuring they're idempotent + op.execute('UPDATE equipment SET clean_name = name') + + # Additional logic for migrating data or altering columns as needed + op.alter_column('equipment', 'clean_name', existing_type=sa.String(), nullable=False) + op.alter_column('equipment', 'muscle_groups', existing_type=postgresql.ARRAY(muscle_group_enum), nullable=False) - # Drop the old equipment_type column and enum - op.drop_column('equipment', 'equipment_type') - op.execute('DROP TYPE equipmenttype') + if "equipment_type" in columns: + op.drop_column('equipment', 'equipment_type') + op.execute('DROP TYPE equipmenttype') def downgrade(): # Create old equipment_type enum diff --git a/migrations/versions/d8d0bd048cd6_create_reminder_average_hourly_capacity_.py b/migrations/versions/d8d0bd048cd6_create_reminder_average_hourly_capacity_.py new file mode 100644 index 0000000..62220aa --- /dev/null +++ b/migrations/versions/d8d0bd048cd6_create_reminder_average_hourly_capacity_.py @@ -0,0 +1,83 @@ +"""create reminder, average hourly capacity tables + +Revision ID: d8d0bd048cd6 +Revises: 24684343da0f +Create Date: 2025-01-02 17:27:45.425737 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.engine import reflection + +from sqlalchemy.dialects.postgresql import ENUM +from src.models.enums import DayOfWeekEnum # Import your ENUM definition + + +# revision identifiers, used by Alembic. +revision = 'd8d0bd048cd6' +down_revision = '24684343da0f' +branch_labels = None +depends_on = None + + +def upgrade(): + conn = op.get_bind() + + # Create an inspector instance + inspector = reflection.Inspector.from_engine(conn) + + # Check if the table exists + existing_tables = inspector.get_table_names() + + # Use the imported ENUM directly + dayofweek_enum = ENUM(*[e.name for e in DayOfWeekEnum], name="dayofweekenum", create_type=False) + + # Create the hourly_average_capacity table + if 'hourly_average_capacity' not in existing_tables: + op.create_table( + 'hourly_average_capacity', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('facility_id', sa.Integer, sa.ForeignKey('facility.id'), nullable=False), + sa.Column('average_percent', sa.Float, nullable=False), + sa.Column('hour_of_day', sa.Integer, nullable=False), + sa.Column('day_of_week', dayofweek_enum, nullable=False), + sa.Column('history', sa.ARRAY(sa.Numeric), nullable=False, server_default='{}'), + ) + + # Create the workout_reminder table + if 'workout_reminder' not in existing_tables: + op.create_table( + 'workout_reminder', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('days_of_week', sa.ARRAY(dayofweek_enum), nullable=False), + sa.Column('reminder_time', sa.Time, nullable=False), + sa.Column('is_active', sa.Boolean, server_default=sa.sql.expression.true(), nullable=False), + ) + + # Create the capacity_reminder table + if 'capacity_reminder' not in existing_tables: + op.create_table( + 'capacity_reminder', + sa.Column('id', sa.Integer, primary_key=True, autoincrement=True), + sa.Column('user_id', sa.Integer, sa.ForeignKey('users.id'), nullable=False), + sa.Column('gyms', sa.ARRAY(sa.String), nullable=False), + sa.Column('capacity_threshold', sa.Integer, nullable=False), + sa.Column('days_of_week', sa.ARRAY(dayofweek_enum), nullable=False), + sa.Column('is_active', sa.Boolean, server_default=sa.sql.expression.true(), nullable=False), + ) + + # Add fields to the users table + columns = [column['name'] for column in inspector.get_columns('users')] + if 'fcm_token' not in columns: + op.add_column('users', sa.Column('fcm_token', sa.String, nullable=False)) + + +def downgrade(): + # Drop the tables in reverse order of creation + op.drop_table('capacity_reminder') + op.drop_table('workout_reminder') + op.drop_table('hourly_average_capacity') + + # Remove fields from the users table + op.drop_column('users', 'fcm_token') diff --git a/requirements.txt b/requirements.txt index 87f0460..690e0fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,68 +2,104 @@ alembic==1.0.9 aniso8601==3.0.2 -e git+https://github.com/cuappdev/appdev.py.git@fffe58a01ba5bc00493213e2baa00a6a9e393280#egg=appdev.py appdirs==1.4.4 +APScheduler==3.10.4 aspy.yaml==1.3.0 attrs==23.1.0 +bcrypt==4.2.0 beautifulsoup4==4.6.3 black==19.3b0 bs4==0.0.1 +CacheControl==0.14.1 cachetools==3.1.0 certifi==2018.8.24 +cffi==1.17.1 cfgv==3.3.1 chardet==3.0.4 charset-normalizer==3.2.0 -click==8.0 +click==8.0.0 +cryptography==43.0.3 distlib==0.3.7 fastdiff==0.3.0 filelock==3.12.2 +firebase-admin==6.6.0 +flasgger==0.9.7.1 Flask==2.2.5 Flask-APScheduler==1.13.1 +Flask-Bcrypt==1.0.1 Flask-GraphQL==2.0.0 +Flask-JWT-Extended==4.6.0 Flask-Migrate==2.4.0 Flask-Script==2.0.5 Flask-SQLAlchemy==2.3.1 -Flask-RESTful==0.3.10 -flasgger==0.9.7.1 -google-auth==1.12.0 +google-api-core==2.23.0 +google-api-python-client==2.153.0 +google-auth==2.36.0 +google-auth-httplib2==0.2.0 +google-auth-oauthlib==0.5.3 +google-cloud-core==2.4.1 +google-cloud-firestore==2.19.0 +google-cloud-storage==2.18.2 +google-crc32c==1.6.0 +google-resumable-media==2.7.2 +googleapis-common-protos==1.66.0 graphene==2.1.3 graphene-sqlalchemy==2.3.0 graphql-core==2.1 graphql-relay==0.4.5 graphql-server-core==1.1.1 greenlet==2.0.2 +grpcio==1.67.1 +grpcio-status==1.67.1 gspread==5.12.3 gunicorn==19.9.0 +httplib2==0.22.0 identify==2.5.24 idna==2.6 importlib-metadata==6.7.0 -itsdangerous==2.0 +itsdangerous==2.0.0 jedi==0.18.2 -Jinja2==3.0 +Jinja2==3.0.0 +jsonschema==4.23.0 +jsonschema-specifications==2024.10.1 lxml==4.9.2 -Mako==1.1.0 +Mako==1.0.9 MarkupSafe==2.1.1 marshmallow==3.0.0rc4 marshmallow-sqlalchemy==0.16.2 +mistune==3.0.2 +msgpack==1.1.0 nodeenv==1.8.0 -pandas==2.1.4 +numpy==2.0.2 +oauthlib==3.2.2 +packaging==24.2 +pandas==2.2.3 parso==0.8.3 platformdirs==3.10.0 pre-commit==1.18.3 promise==2.3 prompt-toolkit==3.0.36 +proto-plus==1.25.0 +protobuf==5.28.3 psycopg2-binary==2.9.7 ptyprocess==0.7.0 pure-eval==0.2.2 pyasn1==0.5.0 pyasn1-modules==0.3.0 +pycparser==2.22 +PyJWT==2.9.0 +pyparsing==3.2.0 python-dateutil==2.8.2 python-editor==1.0.4 pytz==2023.3 PyYAML==6.0 +referencing==0.35.1 requests==2.28.2 +requests-oauthlib==2.0.0 +rpds-py==0.21.0 rsa==4.0 Rx==1.6.1 schedule==1.1.0 +sentry-sdk==2.13.0 singledispatch==3.7.0 six==1.11.0 snapshottest==0.6.0 @@ -71,12 +107,14 @@ SQLAlchemy==1.4.0 termcolor==2.2.0 toml==0.10.2 typing==3.6.6 -typing-extensions==4.7.1 +typing_extensions==4.7.1 +tzdata==2024.2 +tzlocal==5.2 +uritemplate==4.1.1 urllib3==1.26.14 virtualenv==20.24.5 wasmer==1.1.0 -wasmer-compiler-cranelift==1.1.0 +wasmer_compiler_cranelift==1.1.0 wcwidth==0.2.6 Werkzeug==2.2.2 zipp==3.15.0 -sentry-sdk==2.13.0 \ No newline at end of file diff --git a/schema.graphql b/schema.graphql index d580ec9..d4b8fe9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -38,6 +38,23 @@ type Capacity { updated: Int! } +type CapacityReminder { + id: ID! + userId: Int! + gyms: [CapacityReminderGym]! + capacityThreshold: Int! + daysOfWeek: [DayOfWeekEnum]! + isActive: Boolean +} + +enum CapacityReminderGym { + TEAGLEUP + TEAGLEDOWN + HELENNEWMAN + TONIMORRISON + NOYES +} + type Class { id: ID! name: String! @@ -81,6 +98,16 @@ enum DayOfWeekEnum { SUNDAY } +enum DayOfWeekGraphQLEnum { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + type Equipment { id: ID! name: String! @@ -137,6 +164,19 @@ type Gym { reports: [Report] } +type HourlyAverageCapacity { + id: ID! + facilityId: Int! + averagePercent: Float! + hourOfDay: Int! + dayOfWeek: DayOfWeekGraphQLEnum + history: [Float]! +} + +type LoginUser { + token: String +} + enum MuscleGroup { ABDOMINALS CHEST @@ -154,11 +194,18 @@ enum MuscleGroup { type Mutation { createGiveaway(name: String!): Giveaway - createUser(email: String!, name: String!, netId: String!): User + createUser(email: String!, fcmToken: String!, name: String!, netId: String!): User enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance setWorkoutGoals(userId: Int!, workoutGoal: [String]!): User logWorkout(userId: Int!, workoutTime: DateTime!): Workout createReport(createdAt: DateTime!, description: String!, gymId: Int!, issue: String!, userId: Int!): CreateReport + loginUser(netId: String!): LoginUser + createWorkoutReminder(daysOfWeek: [String]!, reminderTime: Time!, userId: Int!): WorkoutReminder + toggleWorkoutReminder(reminderId: Int!): WorkoutReminder + deleteWorkoutReminder(reminderId: Int!): WorkoutReminder + createCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, gyms: [String]!, userId: Int!): CapacityReminder + toggleCapacityReminder(reminderId: Int!): CapacityReminder + deleteCapacityReminder(reminderId: Int!): CapacityReminder } type OpenHours { @@ -190,8 +237,10 @@ enum PriceType { type Query { getAllGyms: [Gym] getUsersByGiveawayId(id: Int): [User] + getUserByNetId(netId: String): [User] getWeeklyWorkoutDays(id: Int): [String] getWorkoutsById(id: Int): [Workout] + getAverageHourlyCapacities(facilityId: Int): [HourlyAverageCapacity] activities: [Activity] getAllReports: [Report] } @@ -215,14 +264,19 @@ enum ReportType { OTHER } +scalar Time + type User { id: ID! email: String netId: String! name: String! - workoutGoal: [DayOfWeekEnum] + workoutGoal: [DayOfWeekGraphQLEnum] + fcmToken: String! giveaways: [Giveaway] reports: [Report] + capacityReminders: [CapacityReminder] + workoutReminders: [WorkoutReminder] } type Workout { @@ -230,3 +284,11 @@ type Workout { workoutTime: DateTime! userId: Int! } + +type WorkoutReminder { + id: ID! + userId: Int! + daysOfWeek: [DayOfWeekGraphQLEnum] + reminderTime: String! + isActive: Boolean +} diff --git a/src/models/capacity_reminder.py b/src/models/capacity_reminder.py new file mode 100644 index 0000000..88a2ce8 --- /dev/null +++ b/src/models/capacity_reminder.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, ForeignKey, ARRAY, Boolean, Table, String +from sqlalchemy.orm import relationship +from sqlalchemy import Enum as SQLAEnum +from src.models.enums import DayOfWeekEnum, CapacityReminderGym +from src.database import Base + +class CapacityReminder(Base): + """ + A capacity reminder for an Uplift user. + + Attributes: + - `id` The ID of the capacity reminder. + - `user_id` The ID of the user who owns this reminder. + - `gyms` The list of gyms the user wants to monitor for capacity. + - `capacity_threshold` Notify user when gym capacity dips below this percentage. + - `days_of_week` The days of the week when the reminder is active. + - `is_active` Whether the reminder is currently active (default is True). + """ + + __tablename__ = "capacity_reminder" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + gyms = Column(ARRAY(SQLAEnum(CapacityReminderGym)), nullable=False) + capacity_threshold = Column(Integer, nullable=False) + days_of_week = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=False) + is_active = Column(Boolean, default=True) \ No newline at end of file diff --git a/src/models/enums.py b/src/models/enums.py new file mode 100644 index 0000000..8ffd745 --- /dev/null +++ b/src/models/enums.py @@ -0,0 +1,36 @@ +import enum +from graphene import Enum as GrapheneEnum + +# SQLAlchemy Enum +class DayOfWeekEnum(enum.Enum): + MONDAY = "MONDAY" + TUESDAY = "TUESDAY" + WEDNESDAY = "WEDNESDAY" + THURSDAY = "THURSDAY" + FRIDAY = "FRIDAY" + SATURDAY = "SATURDAY" + SUNDAY = "SUNDAY" + +# GraphQL Enum +class DayOfWeekGraphQLEnum(GrapheneEnum): + MONDAY = "MONDAY" + TUESDAY = "TUESDAY" + WEDNESDAY = "WEDNESDAY" + THURSDAY = "THURSDAY" + FRIDAY = "FRIDAY" + SATURDAY = "SATURDAY" + SUNDAY = "SUNDAY" + +class CapacityReminderGym(enum.Enum): + TEAGLEUP = "Teagle Up" + TEAGLEDOWN = "Teagle Down" + HELENNEWMAN = "Helen Newman" + TONIMORRISON = "Toni Morrison" + NOYES = "Noyes" + +class CapacityReminderGymGraphQLEnum(GrapheneEnum): + TEAGLEUP = "TEAGLEUP" + TEAGLEDOWN = "TEAGLEDOWN" + HELENNEWMAN = "HELENNEWMAN" + TONIMORRISON = "TONIMORRISON" + NOYES = "NOYES" diff --git a/src/models/hourly_average_capacity.py b/src/models/hourly_average_capacity.py new file mode 100644 index 0000000..a4f90c8 --- /dev/null +++ b/src/models/hourly_average_capacity.py @@ -0,0 +1,41 @@ +from sqlalchemy import Column, Integer, Float, ForeignKey, ARRAY, Enum +from src.models.enums import DayOfWeekEnum +from src.database import Base +from sqlalchemy.types import Numeric +from decimal import Decimal + + +class HourlyAverageCapacity(Base): + """ + Stores the average hourly capacity of a facility over the past 30 days. + + Attributes: + - `id` The ID of the hourly capacity record. + - `facility_id` The ID of the facility this capacity record belongs to. + - `average_percent` Average percent capacity of the facility, represented as a float between 0.0 and 1.0 + - `hour_of_day` The hour of the day this average is recorded for, in 24-hour format. + - `day_of_week` The day of the week this average is recorded for + - `history` Stores previous capacity data for this hour from (up to) the past 30 days. + """ + + __tablename__ = "hourly_average_capacity" + + id = Column(Integer, primary_key=True) + facility_id = Column(Integer, ForeignKey("facility.id"), nullable=False) + average_percent = Column(Float, nullable=False) + hour_of_day = Column(Integer, nullable=False) + day_of_week = Column(Enum(DayOfWeekEnum)) + history = Column(ARRAY(Numeric), nullable=False, default=[]) + + def update_hourly_average(self, current_percent): + new_capacity = Decimal(current_percent).quantize(Decimal('0.01')) + + if len(self.history) >= 30: + self.history = self.history[-29:] # Keep 29 newest records + + self.history = self.history + [new_capacity] if self.history else [new_capacity] + + total = 0 + for capacity in self.history: + total += capacity + self.average_percent = total / len(self.history) \ No newline at end of file diff --git a/src/models/user.py b/src/models/user.py index a442f30..4c6897e 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,18 +1,8 @@ -from sqlalchemy import Column, Integer, String, ARRAY +from sqlalchemy import Column, Integer, String, ARRAY, ForeignKey, Enum from sqlalchemy import Enum as SQLAEnum from sqlalchemy.orm import backref, relationship from src.database import Base -from enum import Enum - - -class DayOfWeekEnum(Enum): - MONDAY = "Monday" - TUESDAY = "Tuesday" - WEDNESDAY = "Wednesday" - THURSDAY = "Thursday" - FRIDAY = "Friday" - SATURDAY = "Saturday" - SUNDAY = "Sunday" +from src.models.enums import DayOfWeekEnum class User(Base): @@ -37,4 +27,7 @@ class User(Base): reports = relationship("Report", back_populates="user") net_id = Column(String, nullable=False) name = Column(String, nullable=False) - workout_goal = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=True) + workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) + fcm_token = Column(String, nullable=False) + capacity_reminders = relationship("CapacityReminder") + workout_reminders = relationship("WorkoutReminder") \ No newline at end of file diff --git a/src/models/workout_reminder.py b/src/models/workout_reminder.py new file mode 100644 index 0000000..0107892 --- /dev/null +++ b/src/models/workout_reminder.py @@ -0,0 +1,25 @@ +from sqlalchemy import Column, Integer, ForeignKey, TIME, Boolean +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy import Enum as SQLAEnum +from src.models.user import DayOfWeekEnum +from src.database import Base + +class WorkoutReminder(Base): + """ + A workout reminder for an Uplift user. + + Attributes: + - `id` The ID of the workout reminder. + - `user_id` The ID of the user who owns this reminder. + - `days_of_week` The days of the week when the reminder is active. + - `reminder_time` The time of day the reminder is scheduled for. + - `is_active` Whether the reminder is currently active (default is True). + """ + + __tablename__ = "workout_reminder" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + days_of_week = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=False) + reminder_time = Column(TIME, nullable=False) + is_active = Column(Boolean, default=True) diff --git a/src/schema.py b/src/schema.py index c5e860d..4fa8662 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,8 +1,13 @@ import graphene +from flask_jwt_extended import create_access_token, verify_jwt_in_request +from functools import wraps from datetime import datetime, timedelta from graphene_sqlalchemy import SQLAlchemyObjectType from graphql import GraphQLError +from src.models.enums import DayOfWeekGraphQLEnum, CapacityReminderGymGraphQLEnum from src.models.capacity import Capacity as CapacityModel +from src.models.capacity_reminder import CapacityReminder as CapacityReminderModel +from src.models.workout_reminder import WorkoutReminder as WorkoutReminderModel from src.models.facility import Facility as FacilityModel from src.models.gym import Gym as GymModel from src.models.openhours import OpenHours as OpenHoursModel @@ -12,12 +17,22 @@ from src.models.classes import Class as ClassModel from src.models.classes import ClassInstance as ClassInstanceModel from src.models.user import User as UserModel -from src.models.user import DayOfWeekEnum from src.models.giveaway import Giveaway as GiveawayModel from src.models.giveaway import GiveawayInstance as GiveawayInstanceModel from src.models.workout import Workout as WorkoutModel from src.models.report import Report as ReportModel +from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel from src.database import db_session +from firebase_admin import messaging + + +def jwt_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + verify_jwt_in_request() + return f(*args, **kwargs) + + return decorated_function # MARK: - Gym @@ -115,6 +130,14 @@ class Meta: model = CapacityModel +# MARK: - Workout Reminder + + +class WorkoutReminder(SQLAlchemyObjectType): + class Meta: + model = WorkoutReminderModel + + # MARK: - Price @@ -177,10 +200,7 @@ class User(SQLAlchemyObjectType): class Meta: model = UserModel - -class UserInput(graphene.InputObjectType): - net_id = graphene.String(required=True) - giveaway_id = graphene.Int(required=True) + workout_goal = graphene.List(DayOfWeekGraphQLEnum) # MARK: - Giveaway @@ -223,16 +243,48 @@ def resolve_user(self, info): query = User.get_query(info).filter(UserModel.id == self.user_id).first() return query +# MARK: - Hourly Average Capacity + + +class HourlyAverageCapacity(SQLAlchemyObjectType): + class Meta: + model = HourlyAverageCapacityModel + + day_of_week = graphene.Field(DayOfWeekGraphQLEnum) + + +# MARK: - Capacity Reminder + + +class CapacityReminder(SQLAlchemyObjectType): + class Meta: + model = CapacityReminderModel + + +# MARK: - Capacity Reminder + + +class WorkoutReminder(SQLAlchemyObjectType): + class Meta: + model = WorkoutReminderModel + + days_of_week = graphene.List(DayOfWeekGraphQLEnum) + + # MARK: - Query class Query(graphene.ObjectType): get_all_gyms = graphene.List(Gym, description="Get all gyms.") get_users_by_giveaway_id = graphene.List(User, id=graphene.Int(), description="Get all users given a giveaway ID.") + get_user_by_net_id = graphene.List(User, net_id=graphene.String(), description="Get user by Net ID.") get_weekly_workout_days = graphene.List( graphene.String, id=graphene.Int(), description="Get the days a user worked out for the current week." ) get_workouts_by_id = graphene.List(Workout, id=graphene.Int(), description="Get all of a user's workouts by ID.") + get_average_hourly_capacities = graphene.List( + HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." + ) activities = graphene.List(Activity) get_all_reports = graphene.List(Report, description="Get all reports.") @@ -244,11 +296,25 @@ def resolve_activities(self, info): query = Activity.get_query(info) return query.all() + def resolve_get_average_hourly_capacities(self, info, facility_id): + valid_facility_ids = [14492437, 8500985, 7169406, 10055021, 2323580, 16099753, 15446768, 12572681] + if facility_id not in valid_facility_ids: + raise GraphQLError("Invalid facility ID.") + query = HourlyAverageCapacity.get_query(info).filter(HourlyAverageCapacityModel.facility_id == facility_id) + return query.all() + def resolve_get_users_by_giveaway_id(self, info, id): entries = GiveawayInstance.get_query(info).filter(GiveawayInstanceModel.giveaway_id == id).all() users = [User.get_query(info).filter(UserModel.id == entry.user_id).first() for entry in entries] return users + def resolve_get_user_by_net_id(self, info, net_id): + user = User.get_query(info).filter(UserModel.net_id == net_id).all() + if not user: + raise GraphQLError("User with the given Net ID does not exist.") + return user + + @jwt_required def resolve_get_workouts_by_id(self, info, id): user = User.get_query(info).filter(UserModel.id == id).first() if not user: @@ -256,6 +322,7 @@ def resolve_get_workouts_by_id(self, info, id): workouts = Workout.get_query(info).filter(WorkoutModel.user_id == user.id).all() return workouts + @jwt_required def resolve_get_weekly_workout_days(self, info, id): user = User.get_query(info).filter(UserModel.id == id).first() if not user: @@ -291,22 +358,39 @@ class Arguments: name = graphene.String(required=True) net_id = graphene.String(required=True) email = graphene.String(required=True) + fcm_token = graphene.String(required=True) Output = User - def mutate(self, info, name, net_id, email): + def mutate(self, info, name, net_id, email, fcm_token): # Check if a user with the given NetID already exists existing_user = db_session.query(UserModel).filter(UserModel.net_id == net_id).first() if existing_user: raise GraphQLError("NetID already exists.") - new_user = UserModel(name=name, net_id=net_id, email=email) + new_user = UserModel(name=name, net_id=net_id, email=email, fcm_token=fcm_token) db_session.add(new_user) db_session.commit() return new_user +class LoginUser(graphene.Mutation): + class Arguments: + net_id = graphene.String(required=True) + + token = graphene.String() + + def mutate(self, info, net_id): + user = db_session.query(UserModel).filter(UserModel.net_id == net_id).first() + if not user: + return GraphQLError("No user with those credentials. Please create an account and try again.") + + # Generate JWT token + token = create_access_token(identity=user.id) + return LoginUser(token=token) + + class EnterGiveaway(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True) @@ -314,6 +398,7 @@ class Arguments: Output = GiveawayInstance + @jwt_required def mutate(self, info, user_net_id, giveaway_id): # Check if NetID and Giveaway ID exists user = User.get_query(info).filter(UserModel.net_id == user_net_id).first() @@ -367,6 +452,7 @@ class Arguments: Output = User + @jwt_required def mutate(self, info, user_id, workout_goal): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -377,7 +463,7 @@ def mutate(self, info, user_id, workout_goal): for day in workout_goal: try: # Convert string to enum - validated_workout_goal.append(DayOfWeekEnum[day.upper()]) + validated_workout_goal.append(DayOfWeekGraphQLEnum[day.upper()].value) except KeyError: raise GraphQLError(f"Invalid day of the week: {day}") @@ -395,6 +481,7 @@ class Arguments: Output = Workout + @jwt_required def mutate(self, info, workout_time, user_id): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -435,6 +522,202 @@ def mutate(self, info, description, user_id, issue, created_at, gym_id): return CreateReport(report=report) +class CreateWorkoutReminder(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + reminder_time = graphene.Time(required=True) + days_of_week = graphene.List(graphene.String, required=True) + + Output = WorkoutReminder + + def mutate(self, info, user_id, reminder_time, days_of_week): + # Validate user existence + user = db_session.query(UserModel).filter_by(id=user_id).first() + if not user: + raise GraphQLError("User not found.") + + # Validate days of the week + validated_workout_days = [] + for day in days_of_week: + try: + validated_workout_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + + try: + reminder = WorkoutReminderModel( + user_id=user_id, reminder_time=reminder_time, days_of_week=validated_workout_days + ) + db_session.add(reminder) + db_session.commit() + except Exception as e: + db_session.rollback() + raise GraphQLError(f"Error creating workout reminder: {str(e)}") + + return reminder + + +class ToggleWorkoutReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = WorkoutReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(WorkoutReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("Workout reminder not found.") + + reminder.is_active = not reminder.is_active + db_session.commit() + + return reminder + + +class DeleteWorkoutReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = WorkoutReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(WorkoutReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("Workout reminder not found.") + + db_session.delete(reminder) + db_session.commit() + + return reminder + + +class CreateCapacityReminder(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + gyms = graphene.List(graphene.String, required=True) + days_of_week = graphene.List(graphene.String, required=True) + capacity_percent = graphene.Int(required=True) + + Output = CapacityReminder # Use the renamed GraphQL type + + def mutate(self, info, user_id, days_of_week, gyms, capacity_percent): + user = db_session.query(UserModel).filter_by(id=user_id).first() + if not user: + raise GraphQLError("User not found.") + + if capacity_percent < 0: + raise GraphQLError("Capacity percent must be a non-negative integer.") + + # Validate days of the week + validated_workout_days = [] + for day in days_of_week: + try: + validated_workout_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + + # Validate gym existence + valid_gyms = [] + for gym in gyms: + try: + valid_gyms.append(CapacityReminderGymGraphQLEnum[gym].value) + except KeyError: + raise GraphQLError(f"Invalid gym: {gym}") + + # Subscribe to Firebase topics for each gym and day + for gym in valid_gyms: + for day in validated_workout_days: + topic_name = f"{gym}_{day}_{capacity_percent}" + try: + messaging.subscribe_to_topic(user.fcm_token, topic_name) + except Exception as error: + raise GraphQLError(f"Error subscribing to topic for {topic_name}: {error}") + + reminder = CapacityReminderModel( + user_id=user_id, + gyms=valid_gyms, + capacity_threshold=capacity_percent, + days_of_week=validated_workout_days, + ) + db_session.add(reminder) + db_session.commit() + print(reminder.gyms) + + return reminder + + +class ToggleCapacityReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = CapacityReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(CapacityReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("CapacityReminder not found.") + + user = db_session.query(UserModel).filter_by(id=reminder.user_id).first() + if not user: + raise GraphQLError("User not found for this reminder.") + + # Prepare topics based on reminder's gym_id and days_of_week + topics = [ + f"{gym}_{day}_{reminder.capacity_threshold}" for gym in reminder.gyms for day in reminder.days_of_week + ] + + if reminder.is_active: + # Toggle to inactive and unsubscribe + for topic in topics: + try: + messaging.unsubscribe_from_topic(user.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error unsubscribing from topic: {error}") + else: + # Toggle to active and resubscribe + for topic in topics: + try: + messaging.subscribe_to_topic(user.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error subscribing to topic: {error}") + + reminder.is_active = not reminder.is_active + db_session.commit() + + return reminder + + +class DeleteCapacityReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = CapacityReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(CapacityReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("CapacityReminder not found.") + + user = db_session.query(UserModel).filter_by(id=reminder.user_id).first() + if not user: + raise GraphQLError("User not found for this reminder.") + + topics = [ + f"{gym}_{day}_{reminder.capacity_threshold}" for gym in reminder.gyms for day in reminder.days_of_week + ] + + for topic in topics: + try: + messaging.unsubscribe_from_topic(user.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error unsubscribing from topic {topic}: {error}") + + db_session.delete(reminder) + db_session.commit() + + return reminder + + class Mutation(graphene.ObjectType): create_giveaway = CreateGiveaway.Field(description="Creates a new giveaway.") create_user = CreateUser.Field(description="Creates a new user.") @@ -442,6 +725,13 @@ class Mutation(graphene.ObjectType): set_workout_goals = SetWorkoutGoals.Field(description="Set a user's workout goals.") log_workout = logWorkout.Field(description="Log a user's workout.") create_report = CreateReport.Field(description="Creates a new report.") + login_user = LoginUser.Field(description="Login an existing user.") + create_workout_reminder = CreateWorkoutReminder.Field(description="Create a new workout reminder.") + toggle_workout_reminder = ToggleWorkoutReminder.Field(description="Toggle a workout reminder on or off.") + delete_workout_reminder = DeleteWorkoutReminder.Field(description="Delete a workout reminder.") + create_capacity_reminder = CreateCapacityReminder.Field(description="Create a new capacity reminder.") + toggle_capacity_reminder = ToggleCapacityReminder.Field(description="Toggle a capacity reminder on or off.") + delete_capacity_reminder = DeleteCapacityReminder.Field(description="Delete a capacity reminder") schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index bc269ff..12aecbe 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -4,6 +4,9 @@ from datetime import datetime from src.database import db_session from src.models.capacity import Capacity +from src.models.hourly_average_capacity import HourlyAverageCapacity +from src.models.enums import DayOfWeekEnum, CapacityReminderGym +from src.utils.messaging import send_capacity_reminder from src.utils.constants import ( C2C_URL, CAPACITY_MARKER_COUNTS, @@ -46,8 +49,85 @@ def fetch_capacities(): else float(capacity_data.percent.replace(CAPACITY_MARKER_PERCENT, "")) / 100 ) + gym_mapping = { + "HELENNEWMAN": CapacityReminderGym.HELENNEWMAN, + "NOYESFITNESSCENTER": CapacityReminderGym.NOYES, + "TEAGLEDOWNFITNESSCENTER": CapacityReminderGym.TEAGLEDOWN, + "TEAGLEUPFITNESSCENTER": CapacityReminderGym.TEAGLEUP, + "TONIMORRISONFITNESSCENTER": CapacityReminderGym.TONIMORRISON, + } + + last_percent = Capacity.query.filter_by(facility_id=facility_id).first() + if last_percent: + last_percent = last_percent.percent + else: + last_percent = 0 + + topic_name = capacity_data.name.replace(" ", "").upper() + # topic_name = topic_name[:-13] + + print(topic_name) + + if topic_name in gym_mapping: + check_and_send_capacity_reminders(gym_mapping[topic_name].name, gym_mapping[topic_name].value, percent, last_percent) + # Add to sheets add_single_capacity(count, facility_id, percent, updated) + + + +def check_and_send_capacity_reminders(facility_name, readable_name, current_percent, last_percent): + """ + Check user reminders and send notifications to topic if the current capacity + dips below the relevant thresholds. + + Parameters: + - `facility_name`: The name of the facility. + - `current_percent`: The current capacity percentage. + - `last_percent`: The capacity percentage from the last scrape. + """ + current_percent_int = int(current_percent * 100) # Convert to integer percentage + last_percent_int = int(last_percent * 100) + + current_day_name = datetime.now().strftime("%A").upper() + print(f"{facility_name}_{current_day_name}") + + + # Check if the current percent crosses below any threshold from the last percent + if last_percent_int > current_percent_int: + for percent in range(last_percent_int, current_percent_int - 1, -1): + topic_name = f"{facility_name}_{current_day_name}_{percent}" + send_capacity_reminder(topic_name, readable_name, facility_name, current_percent_int) + + +# This function will run every hour to update hourly capacity +def update_hourly_capacity(curDay, curHour): + """ + Update hourly average capacity every hour based on collected data. + """ + currentCapacities = db_session.query(Capacity).all() + + for capacity in currentCapacities: + try: + hourly_average_capacity = db_session.query(HourlyAverageCapacity).filter(HourlyAverageCapacity.facility_id == capacity.facility_id, HourlyAverageCapacity.day_of_week == DayOfWeekEnum[curDay].value, HourlyAverageCapacity.hour_of_day == curHour).first() + + if hourly_average_capacity is not None: + hourly_average_capacity.update_hourly_average(capacity.percent) + else: + print("No hourly capacity, creating new entry") + hourly_average_capacity = HourlyAverageCapacity( + facility_id=capacity.facility_id, + average_percent=capacity.percent, + hour_of_day=curHour, + day_of_week=DayOfWeekEnum[curDay].value, + history=[capacity.percent] + ) + + db_session.merge(hourly_average_capacity) + db_session.commit() + + except Exception as e: + print(f"Error updating hourly average: {e}") def add_single_capacity(count, facility_id, percent, updated): diff --git a/src/scrapers/class_scraper.py b/src/scrapers/class_scraper.py index a55a27a..b5a91cf 100644 --- a/src/scrapers/class_scraper.py +++ b/src/scrapers/class_scraper.py @@ -52,66 +52,69 @@ def fetch_classes(num_pages): db_session.query(ClassInstance).delete() db_session.commit() for i in range(num_pages): - page = requests.get(BASE_URL + CLASSES_PATH + str(i)).text - soup = BeautifulSoup(page, "lxml") - if len(soup.find_all("table")) == 1: - continue - schedule = soup.find_all("table")[1] # first table is irrelevant - data = schedule.find_all("tr")[1:] # first row is header - for row in data: - row_elems = row.find_all("td") - class_instance = ClassInstance() - class_name = row_elems[0].a.text - class_href = row_elems[0].a["href"].replace("/recreation/", "", 1) - try: - gym_class = db_session.query(Class).filter(Class.name == class_name).first() - if gym_class is None: - raise Exception("Gym class is none, creating new gym class") - except Exception: - gym_class = create_group_class(class_href) + try: + page = requests.get(BASE_URL + CLASSES_PATH + str(i)).text + soup = BeautifulSoup(page, "lxml") + if len(soup.find_all("table")) == 1: + continue + schedule = soup.find_all("table")[1] # first table is irrelevant + data = schedule.find_all("tr")[1:] # first row is header + for row in data: + row_elems = row.find_all("td") + class_instance = ClassInstance() + class_name = row_elems[0].a.text + class_href = row_elems[0].a["href"].replace("/recreation/", "", 1) + try: + gym_class = db_session.query(Class).filter(Class.name == class_name).first() + if gym_class is None: + raise Exception("Gym class is none, creating new gym class") + except Exception: + gym_class = create_group_class(class_href) - if gym_class is None or not gym_class.id: - raise Exception(f"Failed to create or retrieve gym class from {BASE_URL + class_href}") + if gym_class is None or not gym_class.id: + raise Exception(f"Failed to create or retrieve gym class from {BASE_URL + class_href}") - class_instance.class_id = gym_class.id - date_string = row_elems[1].text.strip() - if "Today" in date_string: - date_string = datetime.strftime(datetime.now(), "%m/%d/%Y") + class_instance.class_id = gym_class.id + date_string = row_elems[1].text.strip() + if "Today" in date_string: + date_string = datetime.strftime(datetime.now(), "%m/%d/%Y") - # special handling for time (cancelled) + # special handling for time (cancelled) - time_str = row_elems[3].string.replace("\n", "").strip() - if time_str != "" and time_str != "Canceled": - class_instance.is_canceled = False - time_strs = time_str.split(" - ") - start_time_string = time_strs[0].strip() - end_time_string = time_strs[1].strip() + time_str = row_elems[3].string.replace("\n", "").strip() + if time_str != "" and time_str != 'Canceled': + class_instance.is_canceled = False + time_strs = time_str.split(" - ") + start_time_string = time_strs[0].strip() + end_time_string = time_strs[1].strip() - class_instance.start_time = datetime.strptime(f"{date_string} {start_time_string}", "%m/%d/%Y %I:%M%p") - class_instance.end_time = datetime.strptime(f"{date_string} {end_time_string}", "%m/%d/%Y %I:%M%p") - if class_instance.end_time < datetime.now(): - continue - else: - class_instance.isCanceled = True + class_instance.start_time = datetime.strptime(f"{date_string} {start_time_string}", "%m/%d/%Y %I:%M%p") + class_instance.end_time = datetime.strptime(f"{date_string} {end_time_string}", "%m/%d/%Y %I:%M%p") + if class_instance.end_time < datetime.now(): + continue + else: + class_instance.isCanceled = True - try: - class_instance.instructor = row_elems[4].a.string - except Exception: - class_instance.instructor = "" - try: - location = row_elems[5].a.string - class_instance.location = location - for gym in GYMS: - if gym in location: - if gym == "Virtual": - class_instance.isVirtual = True - else: - gym_id = get_gym_id(gym) - class_instance.gym_id = gym_id - break - except Exception: - gym_class.location = "" - db_session.add(class_instance) - db_session.commit() - classes[class_instance.id] = class_instance + try: + class_instance.instructor = row_elems[4].a.string + except: + class_instance.instructor = "" + try: + location = row_elems[5].a.string + class_instance.location = location + for gym in GYMS: + if gym in location: + if gym == "Virtual": + class_instance.isVirtual = True + else: + gym_id = get_gym_id(gym) + class_instance.gym_id = gym_id + break + except: + gym_class.location = "" + db_session.add(class_instance) + db_session.commit() + classes[class_instance.id] = class_instance + except: + print("Page is none.") return classes diff --git a/src/utils/constants.py b/src/utils/constants.py index 58dc13d..c38239e 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -108,6 +108,8 @@ # Path to service account key for scraping sheets SERVICE_ACCOUNT_PATH = os.environ["GOOGLE_SERVICE_ACCOUNT_PATH"] +JWT_SECRET_KEY = os.environ["JWT_SECRET_KEY"] + # Worksheet name for capacities SHEET_CAPACITIES = "Capacities" diff --git a/src/utils/messaging.py b/src/utils/messaging.py new file mode 100644 index 0000000..917fb49 --- /dev/null +++ b/src/utils/messaging.py @@ -0,0 +1,70 @@ +import logging +from datetime import datetime +from firebase_admin import messaging +from src.database import db_session +from src.models.workout_reminder import WorkoutReminder +from src.models.user import User +from src.models.user import DayOfWeekEnum # Ensure the DayOfWeekEnum is imported + + +def send_capacity_reminder(topic_name, facility_name, readable_name, current_percent): + """ + Send a capacity reminder to the user. + + Parameters: + - `topic_name`: The topic to send the notification to. + - `facility_id`: The gym facility ID. + - `current_percent`: The current capacity percentage. + """ + message = messaging.Message( + notification=messaging.Notification( + title="Gym Capacity Update", body=f"The capacity for {readable_name} is now below {current_percent}%." + ), + topic=topic_name, + ) + + try: + response = messaging.send(message) + logging.info(f"Message sent to {topic_name}: {response}") + except Exception as e: + logging.error(f"Error sending message to {topic_name}: {e}") + + +def send_workout_reminders(): + """ + Check for scheduled workout reminders and send notifications to users + whose reminders match the current day. + """ + current_date = datetime.now().date() + current_day_name = datetime.now().strftime("%A").upper() + + reminders = ( + db_session.query(WorkoutReminder) + .filter( + WorkoutReminder.is_active == True, WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name]]) + ) + .all() + ) + + for reminder in reminders: + user = db_session.query(User).filter_by(id=reminder.user_id).first() + if user and user.fcm_token: # Access user directly via relationship + # Format scheduled time to send in the payload + scheduled_time = f"{current_date} {reminder.reminder_time}" + payload = messaging.Message( + data={ + "title": "Workout Reminder", + "message": "Don't forget to hit the gym today!", + "scheduledTime": scheduled_time, + }, + token=user.fcm_token, # Use the user's FCM token directly + ) + + print(payload.data) + + try: + response = messaging.send(payload) + print(f"Successfully sent notification for reminder {reminder.id}, response: {response}") + except Exception as e: + print(f"Error sending notification for reminder {reminder.id}: {e}") + print(f"Invalid user or no FCM token.") \ No newline at end of file