From d733e345735722149ca90a4c39389dc8e83edec5 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sun, 20 Oct 2024 17:58:21 -0400 Subject: [PATCH 1/6] inital changes --- app.py | 17 +++++ schema.graphql | 23 +++++- src/models/capacity_reminder.py | 13 ++++ src/models/enums.py | 22 ++++++ src/models/gym.py | 1 + src/models/user.py | 17 ++--- src/models/workout_reminder.py | 13 ++++ src/schema.py | 27 +++++-- src/scrapers/capacities_scraper.py | 46 ++++++++++++ src/tests/test_messaging.py | 109 +++++++++++++++++++++++++++++ src/utils/messaging.py | 74 ++++++++++++++++++++ 11 files changed, 342 insertions(+), 20 deletions(-) create mode 100644 src/models/capacity_reminder.py create mode 100644 src/models/enums.py create mode 100644 src/models/workout_reminder.py create mode 100644 src/tests/test_messaging.py create mode 100644 src/utils/messaging.py diff --git a/app.py b/app.py index 9ec62c9..05433b1 100644 --- a/app.py +++ b/app.py @@ -1,4 +1,5 @@ import logging +from src.utils.constants import SERVICE_ACCOUNT_PATH import sentry_sdk from flask import Flask, render_template from flask_apscheduler import APScheduler @@ -14,10 +15,18 @@ from src.scrapers.equipment_scraper import scrape_equipment from src.scrapers.class_scraper import fetch_classes from src.scrapers.activities_scraper import fetch_activity +from src.utils.messaging import send_workout_reminders from src.utils.utils import create_gym_table from src.models.openhours import OpenHours from flasgger import Swagger +import firebase_admin +from firebase_admin import credentials +if SERVICE_ACCOUNT_PATH: + cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) + firebase_admin.initialize_app(cred) +else: + raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") sentry_sdk.init( dsn="https://2a96f65cca45d8a7c3ffc3b878d4346b@o4507365244010496.ingest.us.sentry.io/4507850536386560", @@ -87,6 +96,14 @@ def scrape_classes(): fetch_classes(10) +# Send workout reminders +@scheduler.task("interval", id="scrape_classes", seconds=60) +def scrape_classes(): + logging.info("Sending workout reminders...") + + send_workout_reminders() + + # Create database and fill it with data init_db() create_gym_table() diff --git a/schema.graphql b/schema.graphql index 7acedc0..e4ca940 100644 --- a/schema.graphql +++ b/schema.graphql @@ -38,6 +38,14 @@ type Capacity { updated: Int! } +type CapacityReminder { + id: ID! + userId: Int! + gymId: Int! + capacityThreshold: Int! + daysOfWeek: [DayOfWeekEnum]! +} + type Class { id: ID! name: String! @@ -77,6 +85,16 @@ enum DayOfWeekEnum { SUNDAY } +enum DayOfWeekGraphQLEnum { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + type Equipment { id: ID! name: String! @@ -139,6 +157,7 @@ type Gym { facilities: [Facility] hours: [OpenHours] classes: [ClassInstance] + capacityReminders: [CapacityReminder] } type Mutation { @@ -188,8 +207,10 @@ type User { email: String! netId: String! name: String! - workoutGoal: [DayOfWeekEnum] + workoutGoal: [DayOfWeekGraphQLEnum] + fcmToken: String giveaways: [Giveaway] + capacityReminders: [CapacityReminder] } type Workout { diff --git a/src/models/capacity_reminder.py b/src/models/capacity_reminder.py new file mode 100644 index 0000000..d1e7657 --- /dev/null +++ b/src/models/capacity_reminder.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, ForeignKey, ARRAY +from sqlalchemy import Enum as SQLAEnum +from src.models.enums import DayOfWeekEnum +from src.database import Base + +class CapacityReminder(Base): + __tablename__ = "capacity_reminder" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + gym_id = Column(Integer, ForeignKey("gym.id"), nullable=False) + capacity_threshold = Column(Integer, nullable=False) + days_of_week = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=False) \ No newline at end of file diff --git a/src/models/enums.py b/src/models/enums.py new file mode 100644 index 0000000..727efc1 --- /dev/null +++ b/src/models/enums.py @@ -0,0 +1,22 @@ +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" diff --git a/src/models/gym.py b/src/models/gym.py index 4ff56e8..9198848 100644 --- a/src/models/gym.py +++ b/src/models/gym.py @@ -35,6 +35,7 @@ class Gym(Base): latitude = Column(Float, nullable=False) longitude = Column(Float, nullable=False) name = Column(String, nullable=False) + capacity_reminders = relationship("CapacityReminder") def __init__(self, **kwargs): self.id = kwargs.get("id") diff --git a/src/models/user.py b/src/models/user.py index 2de3fed..6e50836 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 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): @@ -36,4 +26,5 @@ class User(Base): net_id = Column(String, nullable=False) name = Column(String, nullable=False) workout_goal = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=True) - # instagram = Column(String, nullable=True) + fcm_token = Column(String, nullable=True) + capacity_reminders = relationship("CapacityReminder") \ 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..4f24302 --- /dev/null +++ b/src/models/workout_reminder.py @@ -0,0 +1,13 @@ +from sqlalchemy import Column, Integer, ForeignKey, TIME +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): + __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) diff --git a/src/schema.py b/src/schema.py index d449225..b58272e 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,8 +1,11 @@ import graphene +from src.models.enums import DayOfWeekGraphQLEnum from datetime import datetime, timedelta from graphene_sqlalchemy import SQLAlchemyObjectType from graphql import GraphQLError 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,7 +15,6 @@ 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 @@ -114,6 +116,22 @@ class Meta: model = CapacityModel +# MARK: - Capacity Reminder + + +class CapacityReminder(SQLAlchemyObjectType): + class Meta: + model = CapacityReminderModel + + +# MARK: - Capacity Reminder + + +class WorkoutReminder(SQLAlchemyObjectType): + class Meta: + model = WorkoutReminderModel + + # MARK: - Price @@ -176,10 +194,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 @@ -355,7 +370,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}") diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index bc269ff..7d4b00b 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -4,6 +4,7 @@ from datetime import datetime from src.database import db_session from src.models.capacity import Capacity +from src.utils.messaging import send_capacity_reminder from src.utils.constants import ( C2C_URL, CAPACITY_MARKER_COUNTS, @@ -46,10 +47,55 @@ def fetch_capacities(): else float(capacity_data.percent.replace(CAPACITY_MARKER_PERCENT, "")) / 100 ) + target_gyms = [ + "HELENNEWMANFITNESSCENTER", + "NOYESFITNESSCENTER", + "TEAGLEDOWNFITNESSCENTER", + "TEAGLEUPFITNESSCENTER", + "TONIMORRISONFITNESSCENTER" + ] + + 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() + + if topic_name in target_gyms: + check_and_send_capacity_reminders(topic_name, percent, last_percent) + # Add to sheets add_single_capacity(count, facility_id, percent, updated) +def check_and_send_capacity_reminders(facility_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() + + # Check if the current percent crosses below any threshold from the last percent + for percent in range(last_percent_int, current_percent_int - 1, -1): + print("last percent") + print(last_percent_int) + print("current percent") + print(current_percent_int) + topic_name = f"{facility_name}_{current_day_name}_{percent}" + print(topic_name) + send_capacity_reminder(topic_name, facility_name, current_percent) + + def add_single_capacity(count, facility_id, percent, updated): """ Add a single capacity to the database. diff --git a/src/tests/test_messaging.py b/src/tests/test_messaging.py new file mode 100644 index 0000000..47e486d --- /dev/null +++ b/src/tests/test_messaging.py @@ -0,0 +1,109 @@ +import unittest +from datetime import datetime, time +from unittest.mock import patch, MagicMock +from firebase_admin import messaging +from src.utils.messaging import send_capacity_reminder +from src.utils.messaging import send_workout_reminders + + +class TestCapacityReminderService(unittest.TestCase): + """ + Test suite for the messaging service functions. + """ + + @patch("firebase_admin.messaging.send") + def test_send_capacity_reminder(self, mock_send): + """ + Test procedure for `send_capacity_reminder()`. + """ + + # Mocking the messaging send function + mock_send.return_value = "mock_message_id" + + # Test data + facility_id = "Teagle Up" + current_percent = 0.45 # 45% capacity + topic_name = f"{facility_id}_50" # Expected topic + + # Call the send_reminder function + send_capacity_reminder(topic_name, facility_id, current_percent) + + # Check that the messaging.send function was called with correct parameters + mock_send.assert_called_once() + message = mock_send.call_args[0][0] # Extracting the first positional argument + + # Check message content + self.assertEqual( + message.notification.title, + "Gym Capacity Update" + ) + self.assertIn( + f"capacity for gym {facility_id} is now at {current_percent * 100:.1f}%", + message.notification.body + ) + self.assertEqual(message.topic, topic_name) + + +class TestWorkoutReminderService(unittest.TestCase): + """ + Test suite for the workout reminder sending functionality. + """ + + @patch("src.utils.messaging.db_session") + @patch("firebase_admin.messaging.send") + def test_send_workout_reminders(self, mock_send, mock_db_session): + """ + Test procedure for `send_workout_reminders()`. + """ + + # Mocking the current time to match the reminder time + reminder_time = time(10, 0) # 10:00 AM + current_time = reminder_time # Simulate current time equals reminder time + current_day = datetime.now().strftime("%A") + + # Mock reminder data + mock_reminder = MagicMock() + mock_reminder.user.fcm_token = "mock_fcm_token" + mock_reminder.reminder_time = reminder_time + mock_reminder.days_of_week = [current_day] + + # Setup mock query return value + mock_db_session.query.return_value.join.return_value.filter.return_value.all.return_value = [mock_reminder] + + # Call the function + send_workout_reminders() + + # Check that the messaging.send function was called + mock_send.assert_called_once() + message = mock_send.call_args[0][0] # Extracting the first positional argument + + # Check message content + self.assertEqual( + message.notification.title, + "Workout Reminder" + ) + self.assertEqual( + message.notification.body, + "It's time to workout! Don't forget to hit the gym today!" + ) + self.assertEqual(message.token, "mock_fcm_token") + + @patch("src.utils.messaging.db_session") + @patch("firebase_admin.messaging.send") + def test_no_reminders_sent_when_no_reminders(self, mock_send, mock_db_session): + """ + Test procedure to ensure no reminders are sent if there are no matching reminders. + """ + + # Setup mock query to return an empty list + mock_db_session.query.return_value.join.return_value.filter.return_value.all.return_value = [] + + # Call the function + send_workout_reminders() + + # Check that the messaging.send function was not called + mock_send.assert_not_called() + + +if __name__ == "__main__": + unittest.main() diff --git a/src/utils/messaging.py b/src/utils/messaging.py new file mode 100644 index 0000000..4a51db8 --- /dev/null +++ b/src/utils/messaging.py @@ -0,0 +1,74 @@ +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_id, current_percent): + """ + Send a reminder notification 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 gym {facility_id} is now at {current_percent * 100:.1f}%.", + ), + 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 and time. + """ + current_time = datetime.now().time() + + # Get the current weekday name + current_day_name = datetime.now().strftime("%A") + + # Query for workout reminders that match the current day + reminders = ( + db_session.query(WorkoutReminder) + .join(User) # Fetch both the reminder and the user in a single query + .filter( + WorkoutReminder.reminder_time == current_time, + WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name.upper()]]) + ) + .all() + ) + + for reminder in reminders: + user = reminder.user + + if user and user.fcm_token: + # Prepare the notification message + message = messaging.Message( + notification=messaging.Notification( + title="Workout Reminder", + body="It's time to workout! Don't forget to hit the gym today!" + ), + token=user.fcm_token # Use the FCM token for the user + ) + + try: + # Send the message + response = messaging.send(message) + logging.info(f'Successfully sent message to user ID {user.id}: {response}') + except Exception as e: + logging.error(f'Error sending message to user ID {user.id}: {e}') + else: + logging.warning(f'No FCM token found for user ID {user.id}. Reminder not sent.') From 8a3f4e0551e35f9b3c77cf6c1acf8954b4f7efc0 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sun, 20 Oct 2024 19:24:30 -0400 Subject: [PATCH 2/6] add authentication --- app.py | 9 ++++++++- requirements.txt | 2 +- schema.graphql | 5 +++++ src/schema.py | 32 +++++++++++++++++++++++++++++++- src/utils/constants.py | 2 ++ 5 files changed, 47 insertions(+), 3 deletions(-) diff --git a/app.py b/app.py index 05433b1..7c07837 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,11 @@ import logging -from src.utils.constants import SERVICE_ACCOUNT_PATH +from src.utils.constants import SERVICE_ACCOUNT_PATH, JWT_SECRET_KEY import sentry_sdk from flask import Flask, render_template from flask_apscheduler import APScheduler from flask_graphql import GraphQLView +from flask_bcrypt import Bcrypt +from flask_jwt_extended import JWTManager from graphene import Schema from graphql.utils import schema_printer from src.database import db_session, init_db @@ -22,6 +24,7 @@ import firebase_admin from firebase_admin import credentials + if SERVICE_ACCOUNT_PATH: cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) firebase_admin.initialize_app(cred) @@ -41,9 +44,13 @@ app = Flask(__name__) app.debug = True +bcrypt = Bcrypt() schema = Schema(query=Query, mutation=Mutation) swagger = Swagger(app) +app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY +jwt = JWTManager(app) + # Scheduler scheduler = APScheduler() scheduler.init_app(app) diff --git a/requirements.txt b/requirements.txt index a389a06..8b161e4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -43,7 +43,7 @@ MarkupSafe==2.1.1 marshmallow==3.0.0rc4 marshmallow-sqlalchemy==0.16.2 nodeenv==1.8.0 -pandas==2.1.4 +pandas==2.2.3 parso==0.8.3 platformdirs==3.10.0 pre-commit==1.18.3 diff --git a/schema.graphql b/schema.graphql index e4ca940..382dfdc 100644 --- a/schema.graphql +++ b/schema.graphql @@ -160,12 +160,17 @@ type Gym { capacityReminders: [CapacityReminder] } +type LoginUser { + token: String +} + type Mutation { createGiveaway(name: String!): Giveaway createUser(email: String!, name: String!, netId: String!): User enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance setWorkoutGoals(userId: Int!, workoutGoal: [String]!): User logWorkout(userId: Int!, workoutTime: DateTime!): Workout + loginUser(netId: String!): LoginUser } type OpenHours { diff --git a/src/schema.py b/src/schema.py index b58272e..f6678a8 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,8 +1,10 @@ import graphene -from src.models.enums import DayOfWeekGraphQLEnum +from flask_jwt_extended import create_access_token, get_jwt_identity, 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 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 @@ -20,6 +22,12 @@ from src.models.workout import Workout as WorkoutModel from src.database import db_session +def jwt_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + verify_jwt_in_request() + return f(*args, **kwargs) + return decorated_function # MARK: - Gym @@ -246,6 +254,7 @@ def resolve_get_users_by_giveaway_id(self, info, id): users = [User.get_query(info).filter(UserModel.id == entry.user_id).first() for entry in entries] return users + @jwt_required def resolve_get_workouts_by_id(self, info, id): user = User.get_query(info).filter(UserModel.id == id).first() if not user: @@ -253,6 +262,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: @@ -300,6 +310,22 @@ def mutate(self, info, name, net_id, email): 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) @@ -307,6 +333,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() @@ -360,6 +387,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: @@ -388,6 +416,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: @@ -406,6 +435,7 @@ class Mutation(graphene.ObjectType): enter_giveaway = EnterGiveaway.Field(description="Enters a user into a giveaway.") set_workout_goals = SetWorkoutGoals.Field(description="Set a user's workout goals.") log_workout = logWorkout.Field(description="Log a user's workout.") + login_user = LoginUser.Field(description="Login an existing user.") schema = graphene.Schema(query=Query, mutation=Mutation) 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" From 7d5a438b02129186a6dd97e2f9295cd69939ad81 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Tue, 26 Nov 2024 12:22:40 -0500 Subject: [PATCH 3/6] finish schema.py --- app.py | 94 +++++++-- requirements.txt | 54 +++++- schema.graphql | 44 ++++- src/models/capacity_reminder.py | 10 +- src/models/enums.py | 14 ++ src/models/gym.py | 1 - src/models/hourly_average_capacity.py | 48 +++++ src/models/user.py | 6 +- src/models/workout_reminder.py | 3 +- src/schema.py | 268 ++++++++++++++++++++++++-- src/scrapers/capacities_scraper.py | 45 ++++- src/tests/test_notification.py | 89 +++++++++ src/utils/firebase_config.py | 50 +++++ src/utils/messaging.py | 86 ++++++--- 14 files changed, 725 insertions(+), 87 deletions(-) create mode 100644 src/models/hourly_average_capacity.py create mode 100644 src/tests/test_notification.py create mode 100644 src/utils/firebase_config.py diff --git a/app.py b/app.py index 7c07837..48e91c0 100644 --- a/app.py +++ b/app.py @@ -1,16 +1,17 @@ import logging +from datetime import datetime from src.utils.constants import SERVICE_ACCOUNT_PATH, JWT_SECRET_KEY import sentry_sdk from flask import Flask, render_template from flask_apscheduler import APScheduler from flask_graphql import GraphQLView -from flask_bcrypt import Bcrypt +# from flask_bcrypt import Bcrypt from flask_jwt_extended import JWTManager from graphene import Schema from graphql.utils import schema_printer from src.database import db_session, init_db from src.schema import Query, Mutation -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 @@ -21,15 +22,64 @@ from src.utils.utils import create_gym_table from src.models.openhours import OpenHours from flasgger import Swagger +# from src.utils.firebase_config import initialize_firebase import firebase_admin -from firebase_admin import credentials - - -if SERVICE_ACCOUNT_PATH: - cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) - firebase_admin.initialize_app(cred) -else: - raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") +from firebase_admin import credentials, messaging + + +# if SERVICE_ACCOUNT_PATH: +# cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) +# firebase_admin.initialize_app(cred) +# else: +# raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") + +# logging.info("Firebase app initialized successfully:", firebase_app.name) +# except Exception as e: +# print("Error initializing Firebase:", e) +# raise + + +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 + +def send_notification(): + # Replace with your iOS device's FCM registration token + registration_token = 'fUMWE_YmMU1IryBkt4gXdC:APA91bHlZIlLXOixsPMTu2_8F1u0FqzOzS_GxhvrcOLeNn7DFg-5qaIGEJ2zCpwrJxTk1Jo6_gaGC7LyjrBgfIB3Q6PjcrQqdB7j4rN28TkDKi9DTPneACU' + + # Define the notification payload + message = messaging.Message( + notification=messaging.Notification( + title="Hello, iOS!", + body="This is a test notification sent from Python backend.", + ), + token=registration_token, + data={ # Optional custom data + 'customKey': 'customValue' + }, + apns=messaging.APNSConfig( + payload=messaging.APNSPayload( + aps=messaging.Aps( + sound="default" # Play the default sound + ) + ) + ) + ) + + try: + # Send the message and get the response + response = messaging.send(message) + print("Successfully sent message:", response) + except Exception as e: + print("Error sending message:", e) sentry_sdk.init( dsn="https://2a96f65cca45d8a7c3ffc3b878d4346b@o4507365244010496.ingest.us.sentry.io/4507850536386560", @@ -44,7 +94,7 @@ app = Flask(__name__) app.debug = True -bcrypt = Bcrypt() +# bcrypt = Bcrypt() schema = Schema(query=Query, mutation=Mutation) swagger = Swagger(app) @@ -59,6 +109,8 @@ # Logging logging.basicConfig(format="%(asctime)s %(levelname)-8s %(message)s", level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S") +firebase_app = initialize_firebase() +# send_notification() @app.route("/") def index(): @@ -101,15 +153,23 @@ def scrape_classes(): logging.info("Scraping classes from group-fitness-classes...") fetch_classes(10) - - -# Send workout reminders -@scheduler.task("interval", id="scrape_classes", seconds=60) -def scrape_classes(): - logging.info("Sending workout reminders...") +#Send workout reminders every morning at 9:00 AM +@scheduler.task('cron', id='send_reminders', hour=9, 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', minute="*") +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 and fill it with data init_db() diff --git a/requirements.txt b/requirements.txt index 8b161e4..690e0fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,66 +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 -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.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 +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 @@ -69,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 diff --git a/schema.graphql b/schema.graphql index 382dfdc..5369627 100644 --- a/schema.graphql +++ b/schema.graphql @@ -41,9 +41,18 @@ type Capacity { type CapacityReminder { id: ID! userId: Int! - gymId: Int! + gyms: [CapacityReminderGym]! capacityThreshold: Int! daysOfWeek: [DayOfWeekEnum]! + isActive: Boolean +} + +enum CapacityReminderGym { + TEAGLEUP + TEAGLEDOWN + HELENNEWMAN + TONIMORRISON + NOYES } type Class { @@ -157,7 +166,16 @@ type Gym { facilities: [Facility] hours: [OpenHours] classes: [ClassInstance] - capacityReminders: [CapacityReminder] +} + +type HourlyAverageCapacity { + id: ID! + count: Int! + facilityId: Int! + averagePercent: Float! + hourOfDay: Int! + dayOfWeek: [DayOfWeekGraphQLEnum] + history: [Float]! } type LoginUser { @@ -166,11 +184,17 @@ type LoginUser { 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 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 { @@ -202,18 +226,22 @@ enum PriceType { type Query { getAllGyms: [Gym] getUsersByGiveawayId(id: Int): [User] + getUserByNetId(netId: String): [User] getWeeklyWorkoutDays(id: Int): [String] getWorkoutsById(id: Int): [Workout] + getAverageHourlyCapacities: [HourlyAverageCapacity] activities: [Activity] } +scalar Time + type User { id: ID! email: String! netId: String! name: String! workoutGoal: [DayOfWeekGraphQLEnum] - fcmToken: String + fcmToken: String! giveaways: [Giveaway] capacityReminders: [CapacityReminder] } @@ -223,3 +251,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 index d1e7657..7b8266d 100644 --- a/src/models/capacity_reminder.py +++ b/src/models/capacity_reminder.py @@ -1,6 +1,7 @@ -from sqlalchemy import Column, Integer, ForeignKey, ARRAY +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 +from src.models.enums import DayOfWeekEnum, CapacityReminderGym from src.database import Base class CapacityReminder(Base): @@ -8,6 +9,7 @@ class CapacityReminder(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id"), nullable=False) - gym_id = Column(Integer, ForeignKey("gym.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) \ No newline at end of file + 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 index 727efc1..f16a8cb 100644 --- a/src/models/enums.py +++ b/src/models/enums.py @@ -20,3 +20,17 @@ class DayOfWeekGraphQLEnum(GrapheneEnum): 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/gym.py b/src/models/gym.py index 9198848..4ff56e8 100644 --- a/src/models/gym.py +++ b/src/models/gym.py @@ -35,7 +35,6 @@ class Gym(Base): latitude = Column(Float, nullable=False) longitude = Column(Float, nullable=False) name = Column(String, nullable=False) - capacity_reminders = relationship("CapacityReminder") def __init__(self, **kwargs): self.id = kwargs.get("id") diff --git a/src/models/hourly_average_capacity.py b/src/models/hourly_average_capacity.py new file mode 100644 index 0000000..0219c96 --- /dev/null +++ b/src/models/hourly_average_capacity.py @@ -0,0 +1,48 @@ +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. + - `count` The number of days used to calculate average. + - `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` (nullable) Stores previous capacity data for this hour from the past 30 days. + """ + + __tablename__ = "hourly_average_capacity" + + id = Column(Integer, primary_key=True) + count = Column(Integer, nullable=False) + 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[-30:] #Drop the oldest record + self.count = 30 + else: + self.count += 1 + + print(self.count) + print(self.average_percent) + print(current_percent) + print((self.average_percent * (self.count -1)+ current_percent) / (self.count)) + + self.average_percent = (self.average_percent * (self.count-1) + current_percent) / (self.count) + + self.history = self.history + [new_capacity] if self.history else [new_capacity] \ No newline at end of file diff --git a/src/models/user.py b/src/models/user.py index 6e50836..fa46b18 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, String, ARRAY, ForeignKey +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 @@ -25,6 +25,6 @@ class User(Base): giveaways = relationship("Giveaway", secondary="giveaway_instance", back_populates="users") net_id = Column(String, nullable=False) name = Column(String, nullable=False) - workout_goal = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=True) - fcm_token = Column(String, nullable=True) + workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) + fcm_token = Column(String, nullable=False) capacity_reminders = relationship("CapacityReminder") \ No newline at end of file diff --git a/src/models/workout_reminder.py b/src/models/workout_reminder.py index 4f24302..892bef7 100644 --- a/src/models/workout_reminder.py +++ b/src/models/workout_reminder.py @@ -1,4 +1,4 @@ -from sqlalchemy import Column, Integer, ForeignKey, TIME +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 @@ -11,3 +11,4 @@ class WorkoutReminder(Base): 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 f6678a8..3440d37 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,10 +1,10 @@ import graphene -from flask_jwt_extended import create_access_token, get_jwt_identity, verify_jwt_in_request +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 +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 @@ -20,15 +20,20 @@ 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.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 @@ -124,15 +129,7 @@ class Meta: model = CapacityModel -# MARK: - Capacity Reminder - - -class CapacityReminder(SQLAlchemyObjectType): - class Meta: - model = CapacityReminderModel - - -# MARK: - Capacity Reminder +# MARK: - Workout Reminder class WorkoutReminder(SQLAlchemyObjectType): @@ -229,16 +226,48 @@ class Meta: model = WorkoutModel +# MARK: - Hourly Average Capacity + + +class HourlyAverageCapacity(SQLAlchemyObjectType): + class Meta: + model = HourlyAverageCapacityModel + + day_of_week = graphene.List(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, description="Get all facility hourly average capacities." + ) activities = graphene.List(Activity) def resolve_get_all_gyms(self, info): @@ -249,11 +278,21 @@ def resolve_activities(self, info): query = Activity.get_query(info) return query.all() + def resolve_get_average_hourly_capacities(self, info): + query = HourlyAverageCapacity.get_query(info) + 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() @@ -262,7 +301,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 + # @jwt_required def resolve_get_weekly_workout_days(self, info, id): user = User.get_query(info).filter(UserModel.id == id).first() if not user: @@ -294,16 +333,17 @@ 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() @@ -324,7 +364,7 @@ def mutate(self, info, net_id): # Generate JWT token token = create_access_token(identity=user.id) return LoginUser(token=token) - + class EnterGiveaway(graphene.Mutation): class Arguments: @@ -333,7 +373,7 @@ class Arguments: Output = GiveawayInstance - @jwt_required + # @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() @@ -387,7 +427,7 @@ class Arguments: Output = User - @jwt_required + # @jwt_required def mutate(self, info, user_id, workout_goal): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -416,7 +456,7 @@ class Arguments: Output = Workout - @jwt_required() + # @jwt_required() def mutate(self, info, workout_time, user_id): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -429,6 +469,192 @@ def mutate(self, info, workout_time, user_id): return workout +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 # Set the return type to 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}") + + # Create a new workout reminder + 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 the created reminder + 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.") + + # 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}") + + 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.") @@ -436,6 +662,12 @@ 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.") 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 7d4b00b..fb69f60 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -4,6 +4,8 @@ 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 from src.utils.messaging import send_capacity_reminder from src.utils.constants import ( C2C_URL, @@ -20,6 +22,7 @@ def fetch_capacities(): """ Fetch capacities for all facilities from Connect2Concepts. """ + global scrape_count, scrape_hour headers = {"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"} html = requests.get(C2C_URL, headers=headers) soup = BeautifulSoup(html.text, "html.parser") @@ -68,6 +71,7 @@ def fetch_capacities(): # Add to sheets add_single_capacity(count, facility_id, percent, updated) + def check_and_send_capacity_reminders(facility_name, current_percent, last_percent): @@ -87,15 +91,46 @@ def check_and_send_capacity_reminders(facility_name, current_percent, last_perce # Check if the current percent crosses below any threshold from the last percent for percent in range(last_percent_int, current_percent_int - 1, -1): - print("last percent") - print(last_percent_int) - print("current percent") - print(current_percent_int) + # print("last percent") + # print(last_percent_int) + # print("current percent") + # print(current_percent_int) topic_name = f"{facility_name}_{current_day_name}_{percent}" - print(topic_name) + # print(topic_name) send_capacity_reminder(topic_name, facility_name, current_percent) +# 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( + count=1, + 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): """ Add a single capacity to the database. diff --git a/src/tests/test_notification.py b/src/tests/test_notification.py new file mode 100644 index 0000000..5751f29 --- /dev/null +++ b/src/tests/test_notification.py @@ -0,0 +1,89 @@ +from app import firebase_app +from firebase_admin import messaging + +def send_notification(): + # This registration token comes from the client FCM SDKs. + registration_token = 'ccaJP_JdMkNEspPbl1fYvx:APA91bGN-8BytPPNXeq5Yq_1wmmEHkMh-eO1RfCfZx15ac-LKvTlQCE8sW-B3Q1KPu1S3W9TYsVfPt42e7L_TPHd3Ul6FGdfnZivbmmENAXaf3OEJqiLdvBNfYWMxP9jOsbfF_aOkDUA' + + # See documentation on defining a message payload. + message = messaging.Message( + data={ + 'score': '850', + 'time': '2:45', + }, + token=registration_token, + ) + + # Send a message to the device corresponding to the provided + # registration token. + response = messaging.send(message) + # Response is a message ID string. + print('Successfully sent message:', response) + +send_notification() + + +# import requests +# import json +# from src.utils.constants import SERVICE_ACCOUNT_PATH + +# # Replace with the provided FCM registration token +# FCM_TOKEN = "ccaJP_JdMkNEspPbl1fYvx:APA91bGN-8BytPPNXeq5Yq_1wmmEHkMh-eO1RfCfZx15ac-LKvTlQCE8sW-B3Q1KPu1S3W9TYsVfPt42e7L_TPHd3Ul6FGdfnZivbmmENAXaf3OEJqiLdvBNfYWMxP9jOsbfF_aOkDUA" + +# # Prepare the message payload +# message = { +# "to": FCM_TOKEN, +# "notification": { +# "title": "Test Notification", +# "body": "This is a test message from your server!", +# }, +# "data": { +# "extra_data": "This can contain additional data." +# } +# } + +# # Send the request to FCM +# headers = { +# 'Content-Type': 'application/json', +# 'Authorization': f'key={SERVICE_ACCOUNT_PATH}' +# } + +# # Make the request to send the notification +# response = requests.post('https://fcm.googleapis.com/fcm/send', headers=headers, data=json.dumps(message)) + +# # Print the response for debugging +# print("Response Status Code:", response.status_code) +# print("Response JSON:", response.json()) + +# # Check for successful sending +# if response.status_code == 200: +# print("Notification sent successfully!") +# else: +# print("Failed to send notification:", response.json()) + + +# # Subscribe to topic +# def subscribe_to_topic(token, topic): +# url = f"https://fcm.googleapis.com/fcm/subscribe" +# payload = { +# "to": f"/topics/{topic}", +# "registration_tokens": [token], +# } + +# headers = { +# 'Content-Type': 'application/json', +# 'Authorization': f'key={SERVER_KEY}' +# } + +# response = requests.post(url, headers=headers, data=json.dumps(payload)) + +# print("Subscribe Response Status Code:", response.status_code) +# print("Subscribe Response JSON:", response.json()) + +# if response.status_code == 200: +# print(f"Successfully subscribed to topic: {topic}") +# else: +# print(f"Failed to subscribe to topic: {response.json()}") + +# # Call the subscription function +# subscribe_to_topic(FCM_TOKEN, TOPIC) \ No newline at end of file diff --git a/src/utils/firebase_config.py b/src/utils/firebase_config.py new file mode 100644 index 0000000..00f41ab --- /dev/null +++ b/src/utils/firebase_config.py @@ -0,0 +1,50 @@ +# /Users/sophie/Desktop/appdev/uplift-backend/src/utils/firebase_config.py + +from src.utils.constants import SERVICE_ACCOUNT_PATH +import logging +import firebase_admin +from firebase_admin import credentials + +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: + logging.info("Importing") + firebase_app = firebase_admin.get_app() + return firebase_app + +# if __name__ == "__main__": +# firebase_app = initialize_firebase() +# print("Firebase app initialized:", firebase_app.name) + + + +# from src.utils.constants import SERVICE_ACCOUNT_PATH +# import firebase_admin +# from firebase_admin import credentials, messaging + +# 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() + +# registration_token = 'YOUR_REGISTRATION_TOKEN' + +# message = messaging.Message( +# notification=messaging.Notification( +# title='Python Notification', +# body='Hello from Python!' +# ), +# token=registration_token +# ) + +# response = messaging.send(message) +# print('Successfully sent message:', response) \ No newline at end of file diff --git a/src/utils/messaging.py b/src/utils/messaging.py index 4a51db8..79e9da2 100644 --- a/src/utils/messaging.py +++ b/src/utils/messaging.py @@ -8,7 +8,7 @@ def send_capacity_reminder(topic_name, facility_id, current_percent): """ - Send a reminder notification to the user. + Send a capacity reminder to the user. Parameters: - `topic_name`: The topic to send the notification to. @@ -32,43 +32,75 @@ def send_capacity_reminder(topic_name, facility_id, current_percent): def send_workout_reminders(): """ - Check for scheduled workout reminders and send notifications to users - whose reminders match the current day and time. + Check for scheduled workout reminders and send notifications to users + whose reminders match the current day. """ - current_time = datetime.now().time() - - # Get the current weekday name - current_day_name = datetime.now().strftime("%A") + current_date = datetime.now().date() + current_day_name = datetime.now().strftime("%A").upper() - # Query for workout reminders that match the current day reminders = ( db_session.query(WorkoutReminder) - .join(User) # Fetch both the reminder and the user in a single query .filter( - WorkoutReminder.reminder_time == current_time, - WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name.upper()]]) + WorkoutReminder.is_active == True, + WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name]]) ) .all() ) for reminder in reminders: - user = reminder.user - - if user and user.fcm_token: - # Prepare the notification message - message = messaging.Message( - notification=messaging.Notification( - title="Workout Reminder", - body="It's time to workout! Don't forget to hit the gym today!" - ), - token=user.fcm_token # Use the FCM token for the user + if reminder.user and reminder.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=reminder.user.fcm_token # Use the user's FCM token directly ) try: - # Send the message - response = messaging.send(message) - logging.info(f'Successfully sent message to user ID {user.id}: {response}') + response = messaging.send(payload) + print(f'Successfully sent notification for reminder {reminder.id}, response: {response}') except Exception as e: - logging.error(f'Error sending message to user ID {user.id}: {e}') - else: - logging.warning(f'No FCM token found for user ID {user.id}. Reminder not sent.') + print(f'Error sending notification for reminder {reminder.id}: {e}') + print(f'Invalid user or no FCM token.') + + # current_time = datetime.now().time() + + # # Get the current weekday name + # current_day_name = datetime.now().strftime("%A") + + # # Query for workout reminders that match the current day + # reminders = ( + # db_session.query(WorkoutReminder) + # .join(User) # Fetch both the reminder and the user in a single query + # .filter( + # WorkoutReminder.reminder_time == current_time, + # WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name.upper()]]) + # ) + # .all() + # ) + + # for reminder in reminders: + # user = reminder.user + + # if user and user.fcm_token: + # # Prepare the notification message + # message = messaging.Message( + # notification=messaging.Notification( + # title="Workout Reminder", + # body="It's time to workout! Don't forget to hit the gym today!" + # ), + # token=user.fcm_token # Use the FCM token for the user + # ) + + # try: + # # Send the message + # response = messaging.send(message) + # logging.info(f'Successfully sent message to user ID {user.id}: {response}') + # except Exception as e: + # logging.error(f'Error sending message to user ID {user.id}: {e}') + # else: + # logging.warning(f'No FCM token found for user ID {user.id}. Reminder not sent.') From 9239f2f5e40583377bc99dfa16f2ab8bcbe78056 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Mon, 30 Dec 2024 22:13:50 -0500 Subject: [PATCH 4/6] update hourly avg capacity model --- app.py | 76 +++++++++--------- schema.graphql | 5 +- src/models/enums.py | 10 +-- src/models/hourly_average_capacity.py | 14 +--- src/schema.py | 47 ++++++----- src/scrapers/capacities_scraper.py | 48 +++++++----- src/scrapers/class_scraper.py | 109 +++++++++++++------------- src/utils/messaging.py | 4 +- 8 files changed, 161 insertions(+), 152 deletions(-) diff --git a/app.py b/app.py index 48e91c0..c9fbfa6 100644 --- a/app.py +++ b/app.py @@ -21,24 +21,14 @@ from src.utils.messaging import send_workout_reminders from src.utils.utils import create_gym_table from src.models.openhours import OpenHours +from src.models.workout_reminder import WorkoutReminder +from src.models.user import User as UserModel +from src.models.enums import DayOfWeekEnum from flasgger import Swagger -# from src.utils.firebase_config import initialize_firebase import firebase_admin from firebase_admin import credentials, messaging -# if SERVICE_ACCOUNT_PATH: -# cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) -# firebase_admin.initialize_app(cred) -# else: -# raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") - -# logging.info("Firebase app initialized successfully:", firebase_app.name) -# except Exception as e: -# print("Error initializing Firebase:", e) -# raise - - def initialize_firebase(): if not firebase_admin._apps: if SERVICE_ACCOUNT_PATH: @@ -55,31 +45,45 @@ def send_notification(): # Replace with your iOS device's FCM registration token registration_token = 'fUMWE_YmMU1IryBkt4gXdC:APA91bHlZIlLXOixsPMTu2_8F1u0FqzOzS_GxhvrcOLeNn7DFg-5qaIGEJ2zCpwrJxTk1Jo6_gaGC7LyjrBgfIB3Q6PjcrQqdB7j4rN28TkDKi9DTPneACU' - # Define the notification payload - message = messaging.Message( - notification=messaging.Notification( - title="Hello, iOS!", - body="This is a test notification sent from Python backend.", - ), - token=registration_token, - data={ # Optional custom data - 'customKey': 'customValue' - }, - apns=messaging.APNSConfig( - payload=messaging.APNSPayload( - aps=messaging.Aps( - sound="default" # Play the default sound - ) - ) + 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() ) - try: - # Send the message and get the response - response = messaging.send(message) - print("Successfully sent message:", response) - except Exception as e: - print("Error sending message:", e) + print("HELLOOO!!!") + print(reminders) + + for reminder in reminders: + user = db_session.query(UserModel).filter_by(id=reminder.user_id).first() + print("HELLOOO") + print(user.id) + 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("HELLOOO!!!") + 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}') sentry_sdk.init( dsn="https://2a96f65cca45d8a7c3ffc3b878d4346b@o4507365244010496.ingest.us.sentry.io/4507850536386560", @@ -110,7 +114,7 @@ def send_notification(): logging.basicConfig(format="%(asctime)s %(levelname)-8s %(message)s", level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S") firebase_app = initialize_firebase() -# send_notification() +send_notification() @app.route("/") def index(): diff --git a/schema.graphql b/schema.graphql index 5369627..33628aa 100644 --- a/schema.graphql +++ b/schema.graphql @@ -170,11 +170,10 @@ type Gym { type HourlyAverageCapacity { id: ID! - count: Int! facilityId: Int! averagePercent: Float! hourOfDay: Int! - dayOfWeek: [DayOfWeekGraphQLEnum] + dayOfWeek: DayOfWeekGraphQLEnum history: [Float]! } @@ -229,7 +228,7 @@ type Query { getUserByNetId(netId: String): [User] getWeeklyWorkoutDays(id: Int): [String] getWorkoutsById(id: Int): [Workout] - getAverageHourlyCapacities: [HourlyAverageCapacity] + getAverageHourlyCapacities(facilityId: Int): [HourlyAverageCapacity] activities: [Activity] } diff --git a/src/models/enums.py b/src/models/enums.py index f16a8cb..8ffd745 100644 --- a/src/models/enums.py +++ b/src/models/enums.py @@ -22,11 +22,11 @@ class DayOfWeekGraphQLEnum(GrapheneEnum): SUNDAY = "SUNDAY" class CapacityReminderGym(enum.Enum): - TEAGLEUP = "TEAGLE UP" - TEAGLEDOWN = "TEAGLE DOWN" - HELENNEWMAN = "HELEN NEWMAN" - TONIMORRISON = "TONI MORRISON" - NOYES = "NOYES" + TEAGLEUP = "Teagle Up" + TEAGLEDOWN = "Teagle Down" + HELENNEWMAN = "Helen Newman" + TONIMORRISON = "Toni Morrison" + NOYES = "Noyes" class CapacityReminderGymGraphQLEnum(GrapheneEnum): TEAGLEUP = "TEAGLEUP" diff --git a/src/models/hourly_average_capacity.py b/src/models/hourly_average_capacity.py index 0219c96..8f65837 100644 --- a/src/models/hourly_average_capacity.py +++ b/src/models/hourly_average_capacity.py @@ -11,7 +11,6 @@ class HourlyAverageCapacity(Base): Attributes: - `id` The ID of the hourly capacity record. - - `count` The number of days used to calculate average. - `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. @@ -22,7 +21,6 @@ class HourlyAverageCapacity(Base): __tablename__ = "hourly_average_capacity" id = Column(Integer, primary_key=True) - count = Column(Integer, nullable=False) facility_id = Column(Integer, ForeignKey("facility.id"), nullable=False) average_percent = Column(Float, nullable=False) hour_of_day = Column(Integer, nullable=False) @@ -33,16 +31,8 @@ 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[-30:] #Drop the oldest record - self.count = 30 - else: - self.count += 1 - - print(self.count) - print(self.average_percent) - print(current_percent) - print((self.average_percent * (self.count -1)+ current_percent) / (self.count)) + self.history = self.history[-30:] # Keep 30 newest records - self.average_percent = (self.average_percent * (self.count-1) + current_percent) / (self.count) + self.average_percent = (self.average_percent * len(self.history)-1 + current_percent) / len(self.history) self.history = self.history + [new_capacity] if self.history else [new_capacity] \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index 3440d37..1d94ee8 100644 --- a/src/schema.py +++ b/src/schema.py @@ -233,7 +233,7 @@ class HourlyAverageCapacity(SQLAlchemyObjectType): class Meta: model = HourlyAverageCapacityModel - day_of_week = graphene.List(DayOfWeekGraphQLEnum) + day_of_week = graphene.Field(DayOfWeekGraphQLEnum) # MARK: - Capacity Reminder @@ -266,7 +266,7 @@ class Query(graphene.ObjectType): ) 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, description="Get all facility hourly average capacities." + HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." ) activities = graphene.List(Activity) @@ -278,8 +278,8 @@ def resolve_activities(self, info): query = Activity.get_query(info) return query.all() - def resolve_get_average_hourly_capacities(self, info): - query = HourlyAverageCapacity.get_query(info) + def resolve_get_average_hourly_capacities(self, info, 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): @@ -301,7 +301,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 + @jwt_required def resolve_get_weekly_workout_days(self, info, id): user = User.get_query(info).filter(UserModel.id == id).first() if not user: @@ -373,7 +373,7 @@ class Arguments: Output = GiveawayInstance - # @jwt_required + @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() @@ -427,7 +427,7 @@ class Arguments: Output = User - # @jwt_required + @jwt_required def mutate(self, info, user_id, workout_goal): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -456,7 +456,7 @@ class Arguments: Output = Workout - # @jwt_required() + @jwt_required def mutate(self, info, workout_time, user_id): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: @@ -475,7 +475,7 @@ class Arguments: reminder_time = graphene.Time(required=True) days_of_week = graphene.List(graphene.String, required=True) - Output = WorkoutReminder # Set the return type to WorkoutReminder + Output = WorkoutReminder def mutate(self, info, user_id, reminder_time, days_of_week): # Validate user existence @@ -491,7 +491,6 @@ def mutate(self, info, user_id, reminder_time, days_of_week): except KeyError: raise GraphQLError(f"Invalid day of the week: {day}") - # Create a new workout reminder try: reminder = WorkoutReminderModel( user_id=user_id, reminder_time=reminder_time, days_of_week=validated_workout_days @@ -502,7 +501,6 @@ def mutate(self, info, user_id, reminder_time, days_of_week): db_session.rollback() raise GraphQLError(f"Error creating workout reminder: {str(e)}") - # Return the created reminder return reminder @@ -569,16 +567,25 @@ def mutate(self, info, user_id, days_of_week, gyms, capacity_percent): 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) + 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 diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index fb69f60..3a22d6d 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -5,7 +5,7 @@ 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 +from src.models.enums import DayOfWeekEnum, CapacityReminderGym from src.utils.messaging import send_capacity_reminder from src.utils.constants import ( C2C_URL, @@ -22,7 +22,6 @@ def fetch_capacities(): """ Fetch capacities for all facilities from Connect2Concepts. """ - global scrape_count, scrape_hour headers = {"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"} html = requests.get(C2C_URL, headers=headers) soup = BeautifulSoup(html.text, "html.parser") @@ -50,13 +49,13 @@ def fetch_capacities(): else float(capacity_data.percent.replace(CAPACITY_MARKER_PERCENT, "")) / 100 ) - target_gyms = [ - "HELENNEWMANFITNESSCENTER", - "NOYESFITNESSCENTER", - "TEAGLEDOWNFITNESSCENTER", - "TEAGLEUPFITNESSCENTER", - "TONIMORRISONFITNESSCENTER" - ] + 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: @@ -65,16 +64,19 @@ def fetch_capacities(): last_percent = 0 topic_name = capacity_data.name.replace(" ", "").upper() + # topic_name = topic_name[:-13] - if topic_name in target_gyms: - check_and_send_capacity_reminders(topic_name, percent, last_percent) + 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, current_percent, last_percent): +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. @@ -88,16 +90,21 @@ def check_and_send_capacity_reminders(facility_name, current_percent, last_perce last_percent_int = int(last_percent * 100) current_day_name = datetime.now().strftime("%A").upper() + print(f"{facility_name}_{current_day_name}") + + print(current_percent_int) + print(last_percent_int) # Check if the current percent crosses below any threshold from the last percent - for percent in range(last_percent_int, current_percent_int - 1, -1): - # print("last percent") - # print(last_percent_int) - # print("current percent") - # print(current_percent_int) - topic_name = f"{facility_name}_{current_day_name}_{percent}" - # print(topic_name) - send_capacity_reminder(topic_name, facility_name, current_percent) + if last_percent_int > current_percent_int: + for percent in range(last_percent_int, current_percent_int - 1, -1): + # print("last percent") + print(last_percent_int) + # print("current percent") + print(current_percent_int) + topic_name = f"{facility_name}_{current_day_name}_{percent}" + # print(topic_name) + send_capacity_reminder(topic_name, readable_name, facility_name, current_percent_int) # This function will run every hour to update hourly capacity @@ -116,7 +123,6 @@ def update_hourly_capacity(curDay, curHour): else: print("No hourly capacity, creating new entry") hourly_average_capacity = HourlyAverageCapacity( - count=1, facility_id=capacity.facility_id, average_percent=capacity.percent, hour_of_day=curHour, diff --git a/src/scrapers/class_scraper.py b/src/scrapers/class_scraper.py index 6406bb7..6f90fc3 100644 --- a/src/scrapers/class_scraper.py +++ b/src/scrapers/class_scraper.py @@ -54,61 +54,64 @@ 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"] - try: - gym_class = db_session.query(Class).filter(Class.name == class_name).first() - assert gym_class is not None - except AssertionError: - gym_class = create_group_class(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") + 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"] + try: + gym_class = db_session.query(Class).filter(Class.name == class_name).first() + assert gym_class is not None + except AssertionError: + gym_class = create_group_class(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") - # 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: - 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 + 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 \ No newline at end of file diff --git a/src/utils/messaging.py b/src/utils/messaging.py index 79e9da2..eb8b061 100644 --- a/src/utils/messaging.py +++ b/src/utils/messaging.py @@ -6,7 +6,7 @@ from src.models.user import User from src.models.user import DayOfWeekEnum # Ensure the DayOfWeekEnum is imported -def send_capacity_reminder(topic_name, facility_id, current_percent): +def send_capacity_reminder(topic_name, facility_name, readable_name, current_percent): """ Send a capacity reminder to the user. @@ -18,7 +18,7 @@ def send_capacity_reminder(topic_name, facility_id, current_percent): message = messaging.Message( notification=messaging.Notification( title="Gym Capacity Update", - body=f"The capacity for gym {facility_id} is now at {current_percent * 100:.1f}%.", + body=f"The capacity for {facility_name} is now below {current_percent}%.", ), topic=topic_name, ) From 4fb2ca8c06ed1e9bfd438add51f682dcbec33e46 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sun, 26 Jan 2025 16:15:00 -0500 Subject: [PATCH 5/6] format files --- alembic.ini | 25 ++++ app.py | 92 ++++---------- migrations/alembic.ini | 45 ------- migrations/env.py | 93 +++++--------- ...a0f_update_equipment_table_with_muscle_.py | 63 ++++------ ...reate_reminder_average_hourly_capacity_.py | 83 +++++++++++++ requirements.txt | 7 +- schema.graphql | 18 +-- src/models/capacity_reminder.py | 12 ++ src/models/hourly_average_capacity.py | 13 +- src/models/user.py | 3 +- src/models/workout_reminder.py | 11 ++ src/schema.py | 6 + src/scrapers/capacities_scraper.py | 7 -- src/scrapers/class_scraper.py | 113 +++++++++--------- src/utils/messaging.py | 60 ++-------- 16 files changed, 299 insertions(+), 352 deletions(-) create mode 100644 alembic.ini delete mode 100644 migrations/alembic.ini create mode 100644 migrations/versions/d8d0bd048cd6_create_reminder_average_hourly_capacity_.py 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 e5fb92f..3a86516 100644 --- a/app.py +++ b/app.py @@ -6,8 +6,8 @@ from flask import Flask, render_template from flask_apscheduler import APScheduler from flask_graphql import GraphQLView -# from flask_bcrypt import Bcrypt 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 @@ -21,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 @@ -37,10 +37,10 @@ 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.utils.messaging import send_workout_reminders + 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.workout_reminder import WorkoutReminder -from src.models.user import User as UserModel from src.models.enums import DayOfWeekEnum import firebase_admin from firebase_admin import credentials, messaging @@ -58,50 +58,6 @@ def initialize_firebase(): logging.info("Firebase app created...") return firebase_app -def send_notification(): - # Replace with your iOS device's FCM registration token - registration_token = 'fUMWE_YmMU1IryBkt4gXdC:APA91bHlZIlLXOixsPMTu2_8F1u0FqzOzS_GxhvrcOLeNn7DFg-5qaIGEJ2zCpwrJxTk1Jo6_gaGC7LyjrBgfIB3Q6PjcrQqdB7j4rN28TkDKi9DTPneACU' - - 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() - ) - - print("HELLOOO!!!") - print(reminders) - - for reminder in reminders: - user = db_session.query(UserModel).filter_by(id=reminder.user_id).first() - print("HELLOOO") - print(user.id) - 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("HELLOOO!!!") - 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}') - sentry_sdk.init( dsn="https://2a96f65cca45d8a7c3ffc3b878d4346b@o4507365244010496.ingest.us.sentry.io/4507850536386560", traces_sample_rate=1.0, @@ -110,23 +66,24 @@ def send_notification(): 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 +app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY jwt = JWTManager(app) + def should_run_initial_scrape(): """ Check if we should run initial scraping: @@ -137,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: @@ -152,19 +110,19 @@ def should_run_initial_scrape(): # Logging logging.basicConfig(format="%(asctime)s %(levelname)-8s %(message)s", level=logging.INFO, datefmt="%Y-%m-%d %H:%M:%S") -firebase_app = initialize_firebase() -send_notification() - @app.route("/") 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 @@ -190,30 +148,32 @@ 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(): logging.info("Scraping classes from group-fitness-classes...") + fetch_classes(10) - fetch_classes(10) - -#Send workout reminders every morning at 9:00 AM -@scheduler.task('cron', id='send_reminders', hour=9, minute=0) +# 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', minute="*") + +# 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 92b2a20..690e0fb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -42,9 +42,6 @@ google-cloud-storage==2.18.2 google-crc32c==1.6.0 google-resumable-media==2.7.2 googleapis-common-protos==1.66.0 -Flask-RESTful==0.3.10 -flasgger==0.9.7.1 -google-auth==1.12.0 graphene==2.1.3 graphene-sqlalchemy==2.3.0 graphql-core==2.1 @@ -65,7 +62,7 @@ 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 @@ -121,5 +118,3 @@ 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 191a0fd..d4b8fe9 100644 --- a/schema.graphql +++ b/schema.graphql @@ -192,21 +192,6 @@ enum MuscleGroup { CARDIO } -enum MuscleGroup { - ABDOMINALS - CHEST - BACK - SHOULDERS - BICEPS - TRICEPS - HAMSTRINGS - QUADS - GLUTES - CALVES - MISCELLANEOUS - CARDIO -} - type Mutation { createGiveaway(name: String!): Giveaway createUser(email: String!, fcmToken: String!, name: String!, netId: String!): User @@ -289,8 +274,9 @@ type User { workoutGoal: [DayOfWeekGraphQLEnum] fcmToken: String! giveaways: [Giveaway] - capacityReminders: [CapacityReminder] reports: [Report] + capacityReminders: [CapacityReminder] + workoutReminders: [WorkoutReminder] } type Workout { diff --git a/src/models/capacity_reminder.py b/src/models/capacity_reminder.py index 7b8266d..88a2ce8 100644 --- a/src/models/capacity_reminder.py +++ b/src/models/capacity_reminder.py @@ -5,6 +5,18 @@ 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) diff --git a/src/models/hourly_average_capacity.py b/src/models/hourly_average_capacity.py index 8f65837..a4f90c8 100644 --- a/src/models/hourly_average_capacity.py +++ b/src/models/hourly_average_capacity.py @@ -15,7 +15,7 @@ class HourlyAverageCapacity(Base): - `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` (nullable) Stores previous capacity data for this hour from the past 30 days. + - `history` Stores previous capacity data for this hour from (up to) the past 30 days. """ __tablename__ = "hourly_average_capacity" @@ -31,8 +31,11 @@ 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[-30:] # Keep 30 newest records - - self.average_percent = (self.average_percent * len(self.history)-1 + current_percent) / len(self.history) + self.history = self.history[-29:] # Keep 29 newest records - self.history = self.history + [new_capacity] if self.history else [new_capacity] \ No newline at end of file + 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 d23f0e8..4c6897e 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -29,4 +29,5 @@ class User(Base): name = Column(String, nullable=False) workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) fcm_token = Column(String, nullable=False) - capacity_reminders = relationship("CapacityReminder") \ No newline at end of file + 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 index 892bef7..0107892 100644 --- a/src/models/workout_reminder.py +++ b/src/models/workout_reminder.py @@ -5,6 +5,17 @@ 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) diff --git a/src/schema.py b/src/schema.py index 8da0503..4fa8662 100644 --- a/src/schema.py +++ b/src/schema.py @@ -297,6 +297,9 @@ def resolve_activities(self, 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() @@ -601,6 +604,9 @@ 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 = [] diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index 3a22d6d..12aecbe 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -92,18 +92,11 @@ def check_and_send_capacity_reminders(facility_name, readable_name, current_perc current_day_name = datetime.now().strftime("%A").upper() print(f"{facility_name}_{current_day_name}") - print(current_percent_int) - print(last_percent_int) # 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): - # print("last percent") - print(last_percent_int) - # print("current percent") - print(current_percent_int) topic_name = f"{facility_name}_{current_day_name}_{percent}" - # print(topic_name) send_capacity_reminder(topic_name, readable_name, facility_name, current_percent_int) diff --git a/src/scrapers/class_scraper.py b/src/scrapers/class_scraper.py index 378cb9a..b5a91cf 100644 --- a/src/scrapers/class_scraper.py +++ b/src/scrapers/class_scraper.py @@ -52,68 +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: - 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 + 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/messaging.py b/src/utils/messaging.py index eb8b061..917fb49 100644 --- a/src/utils/messaging.py +++ b/src/utils/messaging.py @@ -6,6 +6,7 @@ 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. @@ -17,8 +18,7 @@ def send_capacity_reminder(topic_name, facility_name, readable_name, current_per """ message = messaging.Message( notification=messaging.Notification( - title="Gym Capacity Update", - body=f"The capacity for {facility_name} is now below {current_percent}%.", + title="Gym Capacity Update", body=f"The capacity for {readable_name} is now below {current_percent}%." ), topic=topic_name, ) @@ -41,66 +41,30 @@ def send_workout_reminders(): reminders = ( db_session.query(WorkoutReminder) .filter( - WorkoutReminder.is_active == True, - WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name]]) + WorkoutReminder.is_active == True, WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name]]) ) .all() ) for reminder in reminders: - if reminder.user and reminder.user.fcm_token: # Access user directly via relationship + 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 + "scheduledTime": scheduled_time, }, - token=reminder.user.fcm_token # Use the user's FCM token directly + 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}') + 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.') - - # current_time = datetime.now().time() - - # # Get the current weekday name - # current_day_name = datetime.now().strftime("%A") - - # # Query for workout reminders that match the current day - # reminders = ( - # db_session.query(WorkoutReminder) - # .join(User) # Fetch both the reminder and the user in a single query - # .filter( - # WorkoutReminder.reminder_time == current_time, - # WorkoutReminder.days_of_week.contains([DayOfWeekEnum[current_day_name.upper()]]) - # ) - # .all() - # ) - - # for reminder in reminders: - # user = reminder.user - - # if user and user.fcm_token: - # # Prepare the notification message - # message = messaging.Message( - # notification=messaging.Notification( - # title="Workout Reminder", - # body="It's time to workout! Don't forget to hit the gym today!" - # ), - # token=user.fcm_token # Use the FCM token for the user - # ) - - # try: - # # Send the message - # response = messaging.send(message) - # logging.info(f'Successfully sent message to user ID {user.id}: {response}') - # except Exception as e: - # logging.error(f'Error sending message to user ID {user.id}: {e}') - # else: - # logging.warning(f'No FCM token found for user ID {user.id}. Reminder not sent.') + print(f"Error sending notification for reminder {reminder.id}: {e}") + print(f"Invalid user or no FCM token.") \ No newline at end of file From 61a6433742e6294c5f90644093198afe74f94fb7 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sun, 26 Jan 2025 16:22:57 -0500 Subject: [PATCH 6/6] delete unused files --- src/tests/test_messaging.py | 109 --------------------------------- src/tests/test_notification.py | 89 --------------------------- src/utils/firebase_config.py | 50 --------------- 3 files changed, 248 deletions(-) delete mode 100644 src/tests/test_messaging.py delete mode 100644 src/tests/test_notification.py delete mode 100644 src/utils/firebase_config.py diff --git a/src/tests/test_messaging.py b/src/tests/test_messaging.py deleted file mode 100644 index 47e486d..0000000 --- a/src/tests/test_messaging.py +++ /dev/null @@ -1,109 +0,0 @@ -import unittest -from datetime import datetime, time -from unittest.mock import patch, MagicMock -from firebase_admin import messaging -from src.utils.messaging import send_capacity_reminder -from src.utils.messaging import send_workout_reminders - - -class TestCapacityReminderService(unittest.TestCase): - """ - Test suite for the messaging service functions. - """ - - @patch("firebase_admin.messaging.send") - def test_send_capacity_reminder(self, mock_send): - """ - Test procedure for `send_capacity_reminder()`. - """ - - # Mocking the messaging send function - mock_send.return_value = "mock_message_id" - - # Test data - facility_id = "Teagle Up" - current_percent = 0.45 # 45% capacity - topic_name = f"{facility_id}_50" # Expected topic - - # Call the send_reminder function - send_capacity_reminder(topic_name, facility_id, current_percent) - - # Check that the messaging.send function was called with correct parameters - mock_send.assert_called_once() - message = mock_send.call_args[0][0] # Extracting the first positional argument - - # Check message content - self.assertEqual( - message.notification.title, - "Gym Capacity Update" - ) - self.assertIn( - f"capacity for gym {facility_id} is now at {current_percent * 100:.1f}%", - message.notification.body - ) - self.assertEqual(message.topic, topic_name) - - -class TestWorkoutReminderService(unittest.TestCase): - """ - Test suite for the workout reminder sending functionality. - """ - - @patch("src.utils.messaging.db_session") - @patch("firebase_admin.messaging.send") - def test_send_workout_reminders(self, mock_send, mock_db_session): - """ - Test procedure for `send_workout_reminders()`. - """ - - # Mocking the current time to match the reminder time - reminder_time = time(10, 0) # 10:00 AM - current_time = reminder_time # Simulate current time equals reminder time - current_day = datetime.now().strftime("%A") - - # Mock reminder data - mock_reminder = MagicMock() - mock_reminder.user.fcm_token = "mock_fcm_token" - mock_reminder.reminder_time = reminder_time - mock_reminder.days_of_week = [current_day] - - # Setup mock query return value - mock_db_session.query.return_value.join.return_value.filter.return_value.all.return_value = [mock_reminder] - - # Call the function - send_workout_reminders() - - # Check that the messaging.send function was called - mock_send.assert_called_once() - message = mock_send.call_args[0][0] # Extracting the first positional argument - - # Check message content - self.assertEqual( - message.notification.title, - "Workout Reminder" - ) - self.assertEqual( - message.notification.body, - "It's time to workout! Don't forget to hit the gym today!" - ) - self.assertEqual(message.token, "mock_fcm_token") - - @patch("src.utils.messaging.db_session") - @patch("firebase_admin.messaging.send") - def test_no_reminders_sent_when_no_reminders(self, mock_send, mock_db_session): - """ - Test procedure to ensure no reminders are sent if there are no matching reminders. - """ - - # Setup mock query to return an empty list - mock_db_session.query.return_value.join.return_value.filter.return_value.all.return_value = [] - - # Call the function - send_workout_reminders() - - # Check that the messaging.send function was not called - mock_send.assert_not_called() - - -if __name__ == "__main__": - unittest.main() diff --git a/src/tests/test_notification.py b/src/tests/test_notification.py deleted file mode 100644 index 5751f29..0000000 --- a/src/tests/test_notification.py +++ /dev/null @@ -1,89 +0,0 @@ -from app import firebase_app -from firebase_admin import messaging - -def send_notification(): - # This registration token comes from the client FCM SDKs. - registration_token = 'ccaJP_JdMkNEspPbl1fYvx:APA91bGN-8BytPPNXeq5Yq_1wmmEHkMh-eO1RfCfZx15ac-LKvTlQCE8sW-B3Q1KPu1S3W9TYsVfPt42e7L_TPHd3Ul6FGdfnZivbmmENAXaf3OEJqiLdvBNfYWMxP9jOsbfF_aOkDUA' - - # See documentation on defining a message payload. - message = messaging.Message( - data={ - 'score': '850', - 'time': '2:45', - }, - token=registration_token, - ) - - # Send a message to the device corresponding to the provided - # registration token. - response = messaging.send(message) - # Response is a message ID string. - print('Successfully sent message:', response) - -send_notification() - - -# import requests -# import json -# from src.utils.constants import SERVICE_ACCOUNT_PATH - -# # Replace with the provided FCM registration token -# FCM_TOKEN = "ccaJP_JdMkNEspPbl1fYvx:APA91bGN-8BytPPNXeq5Yq_1wmmEHkMh-eO1RfCfZx15ac-LKvTlQCE8sW-B3Q1KPu1S3W9TYsVfPt42e7L_TPHd3Ul6FGdfnZivbmmENAXaf3OEJqiLdvBNfYWMxP9jOsbfF_aOkDUA" - -# # Prepare the message payload -# message = { -# "to": FCM_TOKEN, -# "notification": { -# "title": "Test Notification", -# "body": "This is a test message from your server!", -# }, -# "data": { -# "extra_data": "This can contain additional data." -# } -# } - -# # Send the request to FCM -# headers = { -# 'Content-Type': 'application/json', -# 'Authorization': f'key={SERVICE_ACCOUNT_PATH}' -# } - -# # Make the request to send the notification -# response = requests.post('https://fcm.googleapis.com/fcm/send', headers=headers, data=json.dumps(message)) - -# # Print the response for debugging -# print("Response Status Code:", response.status_code) -# print("Response JSON:", response.json()) - -# # Check for successful sending -# if response.status_code == 200: -# print("Notification sent successfully!") -# else: -# print("Failed to send notification:", response.json()) - - -# # Subscribe to topic -# def subscribe_to_topic(token, topic): -# url = f"https://fcm.googleapis.com/fcm/subscribe" -# payload = { -# "to": f"/topics/{topic}", -# "registration_tokens": [token], -# } - -# headers = { -# 'Content-Type': 'application/json', -# 'Authorization': f'key={SERVER_KEY}' -# } - -# response = requests.post(url, headers=headers, data=json.dumps(payload)) - -# print("Subscribe Response Status Code:", response.status_code) -# print("Subscribe Response JSON:", response.json()) - -# if response.status_code == 200: -# print(f"Successfully subscribed to topic: {topic}") -# else: -# print(f"Failed to subscribe to topic: {response.json()}") - -# # Call the subscription function -# subscribe_to_topic(FCM_TOKEN, TOPIC) \ No newline at end of file diff --git a/src/utils/firebase_config.py b/src/utils/firebase_config.py deleted file mode 100644 index 00f41ab..0000000 --- a/src/utils/firebase_config.py +++ /dev/null @@ -1,50 +0,0 @@ -# /Users/sophie/Desktop/appdev/uplift-backend/src/utils/firebase_config.py - -from src.utils.constants import SERVICE_ACCOUNT_PATH -import logging -import firebase_admin -from firebase_admin import credentials - -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: - logging.info("Importing") - firebase_app = firebase_admin.get_app() - return firebase_app - -# if __name__ == "__main__": -# firebase_app = initialize_firebase() -# print("Firebase app initialized:", firebase_app.name) - - - -# from src.utils.constants import SERVICE_ACCOUNT_PATH -# import firebase_admin -# from firebase_admin import credentials, messaging - -# 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() - -# registration_token = 'YOUR_REGISTRATION_TOKEN' - -# message = messaging.Message( -# notification=messaging.Notification( -# title='Python Notification', -# body='Hello from Python!' -# ), -# token=registration_token -# ) - -# response = messaging.send(message) -# print('Successfully sent message:', response) \ No newline at end of file