From da82339cee459ae6b80316a1049190dfca5043f9 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Tue, 27 Jan 2026 06:43:21 -0500 Subject: [PATCH 01/55] Minor bug fixes --- .dockerignore | 4 +++- src/schema.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.dockerignore b/.dockerignore index b6ab4af..b78c59e 100644 --- a/.dockerignore +++ b/.dockerignore @@ -17,4 +17,6 @@ build/ .env -Archive \ No newline at end of file +Archive + +service-account-key.json \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index d17ca30..e13d195 100644 --- a/src/schema.py +++ b/src/schema.py @@ -775,7 +775,9 @@ class Arguments: Output = Workout @jwt_required() - def mutate(self, info, workout_time, user_id): + def mutate(self, info, workout_time, user_id, facility_id): + if not workout_time: + raise GraphQLError("Workout time is required.") user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: raise GraphQLError("User with given ID does not exist.") From a9da9a6ab2602fe26d76bd567dd43aff39b10aeb Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 18 Feb 2026 14:30:45 -0500 Subject: [PATCH 02/55] Modify workout and user tables enable streaks and goal changes --- ...f371489_make_streaks_non_null_default_0.py | 48 +++++++++++++++++++ ...b6a_change_workout_goal_type_to_integer.py | 24 ++++++++++ ...1201_include_last_streak_and_last_goal_.py | 30 ++++++++++++ 3 files changed, 102 insertions(+) create mode 100644 migrations/versions/30b29f371489_make_streaks_non_null_default_0.py create mode 100644 migrations/versions/6ec7ce03bb6a_change_workout_goal_type_to_integer.py create mode 100644 migrations/versions/6fb4a21a1201_include_last_streak_and_last_goal_.py diff --git a/migrations/versions/30b29f371489_make_streaks_non_null_default_0.py b/migrations/versions/30b29f371489_make_streaks_non_null_default_0.py new file mode 100644 index 0000000..81177ce --- /dev/null +++ b/migrations/versions/30b29f371489_make_streaks_non_null_default_0.py @@ -0,0 +1,48 @@ +"""Make streaks non-null default 0 + +Revision ID: 30b29f371489 +Revises: 6ec7ce03bb6a +Create Date: 2026-02-10 18:12:25.251531 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '30b29f371489' +down_revision = '6ec7ce03bb6a' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + + # First complete backfill: set all streaks (active/max) to 0 if they are NULL + op.execute("UPDATE users SET active_streak = 0 WHERE active_streak IS NULL") + op.execute("UPDATE users SET max_streak = 0 WHERE max_streak IS NULL") + + op.alter_column('users', 'active_streak', + existing_type=sa.INTEGER(), + nullable=False, + server_default=sa.text('0') + ) + + op.alter_column('users', 'max_streak', + existing_type=sa.INTEGER(), + nullable=False, + server_default=sa.text('0'), + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.alter_column('users', 'max_streak', + existing_type=sa.INTEGER(), + nullable=True) + op.alter_column('users', 'active_streak', + existing_type=sa.INTEGER(), + nullable=True) + # ### end Alembic commands ### diff --git a/migrations/versions/6ec7ce03bb6a_change_workout_goal_type_to_integer.py b/migrations/versions/6ec7ce03bb6a_change_workout_goal_type_to_integer.py new file mode 100644 index 0000000..8f6e7f8 --- /dev/null +++ b/migrations/versions/6ec7ce03bb6a_change_workout_goal_type_to_integer.py @@ -0,0 +1,24 @@ +"""Change workout_goal type to integer + +Revision ID: 6ec7ce03bb6a +Revises: add_friends_table +Create Date: 2026-02-09 22:56:02.894228 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6ec7ce03bb6a' +down_revision = 'add_friends_table' +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column("users", "workout_goal", type_=sa.Integer, postgresql_using="cardinality(workout_goal)") + +# NOTE: Lossy migration — cannot convert integer back to array of specific days of the week +def downgrade(): + raise NotImplementedError("Downgrade is possible: cannot convert integer back to array of specific days of the week") diff --git a/migrations/versions/6fb4a21a1201_include_last_streak_and_last_goal_.py b/migrations/versions/6fb4a21a1201_include_last_streak_and_last_goal_.py new file mode 100644 index 0000000..6b99799 --- /dev/null +++ b/migrations/versions/6fb4a21a1201_include_last_streak_and_last_goal_.py @@ -0,0 +1,30 @@ +"""Include last_streak and last_goal_change to User model + +Revision ID: 6fb4a21a1201 +Revises: 30b29f371489 +Create Date: 2026-02-17 10:06:17.931547 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '6fb4a21a1201' +down_revision = '30b29f371489' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('last_goal_change', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('last_streak', sa.Integer(), nullable=True)) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_column('users', 'last_streak') + op.drop_column('users', 'last_goal_change') + # ### end Alembic commands ### From cb6560d0e6ce3e7f84db30f697effe3cf4010f48 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 18 Feb 2026 14:32:53 -0500 Subject: [PATCH 03/55] Refactor GraphQL schema --- schema.graphql | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/schema.graphql b/schema.graphql index a161ce6..59a6ae1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -187,8 +187,6 @@ type HourlyAverageCapacity { history: [Float]! } -scalar JSONString - type LoginUser { accessToken: String refreshToken: String @@ -218,7 +216,7 @@ type Mutation { createUser(email: String!, encodedImage: String, name: String!, netId: String!): User editUser(email: String, encodedImage: String, name: String, netId: String!): User enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance - setWorkoutGoals(userId: Int!, workoutGoal: [String]!): User + setWorkoutGoals(changeTime: DateTime, userId: Int!, workoutGoal: Int!): User logWorkout(facilityId: Int!, userId: Int!, workoutTime: DateTime!): Workout loginUser(netId: String!): LoginUser logoutUser: LogoutUser @@ -269,8 +267,6 @@ type Query { getWorkoutsById(id: Int): [Workout] activities: [Activity] getAllReports: [Report] - getWorkoutGoals(id: Int!): [String] - getUserStreak(id: Int!): JSONString getHourlyAverageCapacitiesByFacilityId(facilityId: Int): [HourlyAverageCapacity] getUserFriends(userId: Int!): [User] getCapacityReminderById(id: Int!): CapacityReminder @@ -308,14 +304,17 @@ type User { netId: String! name: String! activeStreak: Int - maxStreak: Int - workoutGoal: [DayOfWeekGraphQLEnum] + maxStreak: Int! + workoutGoal: Int + lastGoalChange: DateTime + lastStreak: Int! encodedImage: String giveaways: [Giveaway] friendRequestsSent: [Friendship] friendRequestsReceived: [Friendship] friendships: [Friendship] friends: [User] + totalGymDays: Int! } type Workout { @@ -323,4 +322,5 @@ type Workout { workoutTime: DateTime! userId: Int! facilityId: Int! + gymName: String! } From dabf2f9984e736b3fe1769e52a7a1f8891829c84 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 18 Feb 2026 14:35:15 -0500 Subject: [PATCH 04/55] Implement active streaks for users --- src/models/user.py | 15 ++-- src/models/workout.py | 2 +- src/schema.py | 186 ++++++++++++++++++++++++++---------------- 3 files changed, 126 insertions(+), 77 deletions(-) diff --git a/src/models/user.py b/src/models/user.py index 98ffb39..d477af5 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,8 +1,9 @@ -from sqlalchemy import Column, Integer, String, ARRAY, Enum, ForeignKey +from sqlalchemy import Column, Integer, String, ARRAY, Enum, ForeignKey, DateTime from sqlalchemy.orm import relationship from src.database import Base from src.models.enums import DayOfWeekEnum from src.models.friends import Friendship +import sqlalchemy as sa class User(Base): """ @@ -14,10 +15,12 @@ class User(Base): - `giveaways` (nullable) The list of giveaways a user is entered into. - `net_id` The user's Net ID. - `name` The user's name. - - `workout_goal` The days of the week the user has set as their personal goal. + - `workout_goal` The number of days the user has set as their personal goal. - `active_streak` The number of consecutive weeks the user has met their personal goal. - `max_streak` The maximum number of consecutive weeks the user has met their personal goal. - `workout_goal` The max number of weeks the user has met their personal goal. + - `last_goal_change` The date and time the user last changed their personal goal. + - `last_streak` The number of consecutive weeks the user has met their personal goal before the last goal change. - `encoded_image` The profile picture URL of the user. """ @@ -28,9 +31,11 @@ class User(Base): giveaways = relationship("Giveaway", secondary="giveaway_instance", back_populates="users") net_id = Column(String, nullable=False) name = Column(String, nullable=False) - active_streak = Column(Integer, nullable=True) - max_streak = Column(Integer, nullable=True) - workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) + active_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0')) + max_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0')) + workout_goal = Column(Integer, nullable=True) + last_goal_change = Column(DateTime, nullable=True) + last_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0')) encoded_image = Column(String, nullable=True) friend_requests_sent = relationship("Friendship", diff --git a/src/models/workout.py b/src/models/workout.py index 946b6f8..50cd48b 100644 --- a/src/models/workout.py +++ b/src/models/workout.py @@ -8,7 +8,7 @@ class Workout(Base): A workout logged by a user. Attributes: - - `id` The ID of user. + - `id` The ID of the workout. - `workout_time` The date and time of the workout. - `user_id` The ID of the user who completed the workout. - `facility_id` The ID of the facility visited diff --git a/src/schema.py b/src/schema.py index e13d195..91e6328 100644 --- a/src/schema.py +++ b/src/schema.py @@ -24,12 +24,14 @@ from src.models.workout import Workout as WorkoutModel from src.models.report import Report as ReportModel from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel +# from src.models.user_workout_goal_history import UserWorkoutGoalHistory as UserWorkoutGoalHistoryModel from src.database import db_session import requests import json import os from firebase_admin import messaging import logging +from sqlalchemy import func, cast, Date def resolve_enum_value(entry): @@ -201,9 +203,95 @@ class User(SQLAlchemyObjectType): class Meta: model = UserModel - workout_goal = graphene.List(DayOfWeekGraphQLEnum) + active_streak = graphene.Int( + description="Get the current workout streak of a user, in terms of number of consecutive weeks." + ) friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) + total_gym_days = graphene.Int( + required=True, + description="Get the total number of gym days (unique workout days) for user." + ) + + # NOTE: Current implementation counts total number of workouts with unqiue days, whereas more efficient implementation persists said value in User model + # Persistence currently considered unnecessary/overkill (would require syncing + extra logic, and workouts are unlikely to exceed 800 for a single user — a low number by any standard) + def resolve_total_gym_days(self, info): + return ( + Workout.get_query(info) + .filter(WorkoutModel.user_id == self.id) + .with_entities(func.count(func.distinct(cast(WorkoutModel.workout_time, Date)))) # We cast the datetiem object as a Date object to get the unique days + .scalar() + ) + + def resolve_active_streak(self, info): + user = User.get_query(info).filter(UserModel.id == self.id).first() + + if not user: + raise GraphQLError("User with the given ID does not exist.") + + # Pull DISTINCT workout dates (not timestamps) in descending order + # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] + workout_date_rows = ( + Workout.get_query(info) + .filter(WorkoutModel.user_id == user.id) + .with_entities(cast(WorkoutModel.workout_time, Date).label("workout_date")) + .distinct() + .order_by(cast(WorkoutModel.workout_time, Date).desc()) + .all() + ) + + # Gets us the user's current workout goal + workout_goal = user.workout_goal + + # If the user has no logged workouts or no workout goal, return 0 + if not workout_date_rows or not workout_goal: + return 0 + + # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending + workout_dates = [row[0] for row in workout_date_rows] # already date objects + + # TODO: Revisit date logic, make UTC universal in codebase + today = datetime.utcnow().date() + streak = 0 + + # Walk through the workout_dates list once using an index pointer + day_pointer = 0 # Dates are sorted in descending order, so we start at the most recent date + total_workout_dates = len(workout_dates) + + # Set the end of the current week to today, will look 6 days back from today to check for streaks + window_end = today + + while True: + # Definition of a given week + window_start = window_end - timedelta(days=6) + + # Count how many workout days fall within [window_start, window_end] + day_iterator = day_pointer + count_in_window = 0 + while day_iterator < total_workout_dates and workout_dates[day_iterator] >= window_start: + count_in_window += 1 + day_iterator += 1 + + # User completed no workouts in the current 7-day window, streak ends + if count_in_window == 0: + break + elif count_in_window >= workout_goal: # Window hits workout goal, streak increases + streak += 1 + else: + # User does not hit workout goal, but completes at least one workout in the current window, so streak continues + pass + + # Move to the previous rolling 7-day window + window_end = window_end - timedelta(days=7) + + # day_iterator should have past the end of the current window and entered the next with the loops break condition, so we can set it as the new day_pointer + day_pointer = day_iterator + + # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop + if day_pointer >= total_workout_dates: + break + + return streak def resolve_friendships(self, info): # Return all friendship relationships for this user @@ -277,6 +365,17 @@ class Workout(SQLAlchemyObjectType): class Meta: model = WorkoutModel + gym_name = graphene.String(required=True) + + def resolve_gym_name(self, info): + facility = Facility.get_query(info).filter(FacilityModel.id == self.facility_id).first() + if not facility: + raise GraphQLError("Facility for workout not found.") + gym = Gym.get_query(info).filter(GymModel.id == facility.gym_id).first() + if not gym: + raise GraphQLError("Gym for workout not found.") + return gym.name + # MARK: - Report @@ -315,14 +414,6 @@ class Query(graphene.ObjectType): get_workouts_by_id = graphene.List(Workout, id=graphene.Int(), description="Get all of a user's workouts by ID.") activities = graphene.List(Activity) get_all_reports = graphene.List(Report, description="Get all reports.") - get_workout_goals = graphene.List( - graphene.String, id=graphene.Int(required=True), description="Get the workout goals of a user by ID." - ) - get_user_streak = graphene.Field( - graphene.JSONString, - id=graphene.Int(required=True), - description="Get the current and max workout streak of a user.", - ) get_hourly_average_capacities_by_facility_id = graphene.List( HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." ) @@ -400,55 +491,7 @@ def resolve_get_weekly_workout_days(self, info, id): def resolve_get_all_reports(self, info): query = ReportModel.query.all() - return query - - @jwt_required() - def resolve_get_workout_goals(self, info, id): - user = User.get_query(info).filter(UserModel.id == id).first() - if not user: - raise GraphQLError("User with the given ID does not exist.") - - return [day.value for day in user.workout_goal] if user.workout_goal else [] - - @jwt_required() - def resolve_get_user_streak(self, info, id): - user = User.get_query(info).filter(UserModel.id == id).first() - if not user: - raise GraphQLError("User with the given ID does not exist.") - - workouts = ( - Workout.get_query(info) - .filter(WorkoutModel.user_id == user.id) - .order_by(WorkoutModel.workout_time.desc()) - .all() - ) - - if not workouts: - return {"active_streak": 0, "max_streak": 0} - - workout_dates = {workout.workout_time.date() for workout in workouts} - sorted_dates = sorted(workout_dates, reverse=True) - - today = datetime.utcnow().date() - active_streak = 0 - max_streak = 0 - streak = 0 - prev_date = None - - for date in sorted_dates: - if prev_date and (prev_date - date).days > 1: - max_streak = max(max_streak, streak) - streak = 0 - - streak += 1 - prev_date = date - - if date == today or (date == today - timedelta(days=1) and active_streak == 0): - active_streak = streak - - max_streak = max(max_streak, streak) - - return {"active_streak": active_streak, "max_streak": max_streak} + return query def resolve_get_hourly_average_capacities_by_facility_id(self, info, facility_id): valid_facility_ids = [14492437, 8500985, 7169406, 10055021, 2323580, 16099753, 15446768, 12572681] @@ -736,30 +779,31 @@ def mutate(self, info, user_net_id, friend_net_id): class SetWorkoutGoals(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True, description="The ID of the user.") - workout_goal = graphene.List( - graphene.String, + workout_goal = graphene.Int( required=True, - description="The new workout goal for the user in terms of days of the week.", + description="The new workout goal for the user in terms of number of days per week.", ) + change_time = graphene.DateTime(required=False, description="The date and time the user changed their goal.") Output = User @jwt_required() - def mutate(self, info, user_id, workout_goal): + def mutate(self, info, user_id, workout_goal, change_time=None): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: raise GraphQLError("User with given ID does not exist.") - # Validate that all workout days are valid - validated_workout_goal = [] - for day in workout_goal: - try: - # Convert string to enum - validated_workout_goal.append(DayOfWeekGraphQLEnum[day.upper()].value) - except KeyError: - raise GraphQLError(f"Invalid day of the week: {day}") + # Update the last time the user changed their goal + if change_time: + user.last_goal_change = change_time + else: + user.last_goal_change = datetime.now() + + # Update the user's last streak + user.last_streak = user.active_streak - user.workout_goal = validated_workout_goal + # Update the user's workout goal + user.workout_goal = workout_goal db_session.commit() From 872bc07f09151f6fd3edd6c47f62e1759569eb0e Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Thu, 19 Feb 2026 23:25:09 -0500 Subject: [PATCH 05/55] initial commit --- src/schema.py | 120 ++++++++++++++++++++++++----------------- src/utils/constants.py | 3 ++ 2 files changed, 74 insertions(+), 49 deletions(-) diff --git a/src/schema.py b/src/schema.py index d17ca30..5c7b07b 100644 --- a/src/schema.py +++ b/src/schema.py @@ -24,18 +24,24 @@ from src.models.workout import Workout as WorkoutModel from src.models.report import Report as ReportModel from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel +from src.utils.constants import SHEET_KEY, SHEET_REPORTS, SERVICE_ACCOUNT_PATH from src.database import db_session import requests -import json -import os from firebase_admin import messaging import logging +import gspread + + +# Configure client and sheet +gc = gspread.service_account(filename=SERVICE_ACCOUNT_PATH) +sh = gc.open_by_key(SHEET_KEY) def resolve_enum_value(entry): """Return the raw value for Enum objects while leaving plain strings untouched.""" return getattr(entry, "value", entry) + # MARK: - Gym @@ -239,6 +245,7 @@ class UserInput(graphene.InputObjectType): # MARK: - Friendship + class Friendship(SQLAlchemyObjectType): class Meta: model = FriendshipModel @@ -254,6 +261,7 @@ def resolve_friend(self, info): query = User.get_query(info).filter(UserModel.id == self.friend_id).first() return query + # MARK: - Giveaway @@ -327,19 +335,12 @@ class Query(graphene.ObjectType): HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." ) get_user_friends = graphene.List( - User, - user_id=graphene.Int(required=True), - description="Get all friends for a user." + User, user_id=graphene.Int(required=True), description="Get all friends for a user." ) get_capacity_reminder_by_id = graphene.Field( - CapacityReminder, - id=graphene.Int(required=True), - description="Get a specific capacity reminder by its ID." - ) - get_all_capacity_reminders = graphene.List( - CapacityReminder, - description="Get all capacity reminders." + CapacityReminder, id=graphene.Int(required=True), description="Get a specific capacity reminder by its ID." ) + get_all_capacity_reminders = graphene.List(CapacityReminder, description="Get all capacity reminders.") def resolve_get_all_gyms(self, info): query = Gym.get_query(info) @@ -464,14 +465,18 @@ def resolve_get_user_friends(self, info, user_id): raise GraphQLError("User with the given ID does not exist.") # Direct friendships where user is the initiator - direct_friendships = Friendship.get_query(info).filter( - (FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True) - ).all() + direct_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) # Reverse friendships where user is the recipient - reverse_friendships = Friendship.get_query(info).filter( - (FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True) - ).all() + reverse_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) friend_ids = set() for friendship in direct_friendships: @@ -482,7 +487,7 @@ def resolve_get_user_friends(self, info, user_id): # Query for all friends at once return User.get_query(info).filter(UserModel.id.in_(friend_ids)).all() - + @jwt_required() def resolve_get_capacity_reminder_by_id(self, info, id): reminder = CapacityReminder.get_query(info).filter(CapacityReminderModel.id == id).first() @@ -491,7 +496,7 @@ def resolve_get_capacity_reminder_by_id(self, info, id): raise GraphQLError("Capacity reminder with the given ID does not exist.") return reminder - + @jwt_required() def resolve_get_all_capacity_reminders(self, info): query = CapacityReminder.get_query(info) @@ -687,6 +692,7 @@ def mutate(self, info, name): db_session.commit() return giveaway + class AddFriend(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True, description="The Net ID of the user.") @@ -710,6 +716,7 @@ def mutate(self, info, user_net_id, friend_net_id): db_session.commit() return user + class RemoveFriend(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True, description="The Net ID of the user.") @@ -733,6 +740,7 @@ def mutate(self, info, user_net_id, friend_net_id): db_session.commit() return user + class SetWorkoutGoals(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True, description="The ID of the user.") @@ -816,6 +824,18 @@ def mutate(self, info, description, issue, created_at, gym_id): report = ReportModel(description=description, issue=issue, created_at=created_at, gym_id=gym_id) db_session.add(report) db_session.commit() + + try: + sh.worksheet(SHEET_REPORTS).append_row([ + report.id, + issue, + gym.name, + description, + created_at.isoformat(), + ]) + except Exception as e: + print(f"Error logging report to sheet: {e}") + return CreateReport(report=report) @@ -927,11 +947,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho for topic in topics: try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) - logging.info( - "Unsubscribe %s from %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) for error in response.errors: logging.warning( "Error unsubscribing %s from %s -> reason: %s", reminder.fcm_token[:12], topic, error.reason @@ -947,11 +963,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho for topic in topics: try: response = messaging.subscribe_to_topic(reminder.fcm_token, topic) - logging.info( - "Resubscribing %s to %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Resubscribing %s to %s", reminder.fcm_token[:12], topic) if response.success_count == 0: raise Exception(response.errors[0].reason) except Exception as error: @@ -985,13 +997,9 @@ def mutate(self, info, reminder_id): for topic in topics: try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) - logging.info( - "Unsubscribe %s from %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) if response.success_count == 0: - raise Exception(response.errors[0].reason) + raise Exception(response.errors[0].reason) except Exception as error: raise GraphQLError(f"Error unsubscribing from topic {topic}: {error}") @@ -1000,6 +1008,7 @@ def mutate(self, info, reminder_id): return reminder + class AddFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1019,10 +1028,14 @@ def mutate(self, info, user_id, friend_id): raise GraphQLError("Friend with given ID does not exist.") # Check if friendship already exists - existing = Friendship.get_query(info).filter( - ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) | - ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) - ).first() + existing = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if existing: raise GraphQLError("Friendship already exists.") @@ -1034,6 +1047,7 @@ def mutate(self, info, user_id, friend_id): return friendship + class AcceptFriendRequest(graphene.Mutation): class Arguments: friendship_id = graphene.Int(required=True) @@ -1058,6 +1072,7 @@ def mutate(self, info, friendship_id): return friendship + class RemoveFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1068,10 +1083,14 @@ class Arguments: @jwt_required() def mutate(self, info, user_id, friend_id): # Find the friendship - friendship = Friendship.get_query(info).filter( - ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) | - ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) - ).first() + friendship = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if not friendship: raise GraphQLError("Friendship not found.") @@ -1082,6 +1101,7 @@ def mutate(self, info, user_id, friend_id): return RemoveFriend(success=True) + class GetPendingFriendRequests(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1096,10 +1116,11 @@ def mutate(self, info, user_id): raise GraphQLError("User with given ID does not exist.") # Get pending friend requests (where this user is the friend) - pending = Friendship.get_query(info).filter( - (FriendshipModel.friend_id == user_id) & - (FriendshipModel.is_accepted == False) - ).all() + pending = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == False)) + .all() + ) return GetPendingFriendRequests(pending_requests=pending) @@ -1125,7 +1146,8 @@ class Mutation(graphene.ObjectType): accept_friend_request = AcceptFriendRequest.Field(description="Accept a friend request.") remove_friend = RemoveFriend.Field(description="Remove a friendship.") get_pending_friend_requests = GetPendingFriendRequests.Field( - description="Get all pending friend requests for a user.") + description="Get all pending friend requests for a user." + ) schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/src/utils/constants.py b/src/utils/constants.py index 3d0ed63..1cf8664 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -131,6 +131,9 @@ # Worksheet name for regular facility hours SHEET_REG_FACILITY = "[REG] Facility Hours" +# Worksheet name for reports +SHEET_REPORTS = "Reports" + # Worksheet name for special facility hours SHEET_SP_FACILITY = "[SP] Facility Hours" From fdeb0e1d8279feb74dbd16a56695b5dc07b1b1ea Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Sat, 21 Feb 2026 20:02:40 -0500 Subject: [PATCH 06/55] Modify streak resolvers to account for changes in user workout goals --- schema.graphql | 2 +- src/models/user.py | 2 + src/models/user_workout_goal_history.py | 24 +++++ src/schema.py | 116 ++++++++++++++++++++++-- 4 files changed, 136 insertions(+), 8 deletions(-) create mode 100644 src/models/user_workout_goal_history.py diff --git a/schema.graphql b/schema.graphql index 59a6ae1..7e3e7a1 100644 --- a/schema.graphql +++ b/schema.graphql @@ -304,7 +304,7 @@ type User { netId: String! name: String! activeStreak: Int - maxStreak: Int! + maxStreak: Int workoutGoal: Int lastGoalChange: DateTime lastStreak: Int! diff --git a/src/models/user.py b/src/models/user.py index d477af5..7cce2cf 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -38,6 +38,8 @@ class User(Base): last_streak = Column(Integer, nullable=False, default=0, server_default=sa.text('0')) encoded_image = Column(String, nullable=True) + goal_history = relationship("UserWorkoutGoalHistory", back_populates="user", cascade="all, delete-orphan", order_by="UserWorkoutGoalHistory.effective_at.desc()") + friend_requests_sent = relationship("Friendship", foreign_keys="Friendship.user_id", back_populates="user") diff --git a/src/models/user_workout_goal_history.py b/src/models/user_workout_goal_history.py new file mode 100644 index 0000000..17c2f8a --- /dev/null +++ b/src/models/user_workout_goal_history.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, Float, ForeignKey, String, DateTime, text +from sqlalchemy.orm import backref, relationship +from src.database import Base +from datetime import datetime + +class UserWorkoutGoalHistory(Base): + """ + A history of a user's workout goals. + + Attributes: + - `id` The ID of the user workout goal history. + - `user_id` The ID of the user who owns the goal history. + - `workout_goal` The workout goal. + - `effective_at` The date and time the goal was set. + """ + + __tablename__ = "user_workout_goal_history" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + workout_goal = Column(Integer, nullable=False) + effective_at = Column(DateTime, nullable=False, default=datetime.utcnow, server_default=text("CURRENT_TIMESTAMP")) + + user = relationship("User", back_populates='goal_history') \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index 91e6328..3580563 100644 --- a/src/schema.py +++ b/src/schema.py @@ -24,7 +24,7 @@ from src.models.workout import Workout as WorkoutModel from src.models.report import Report as ReportModel from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel -# from src.models.user_workout_goal_history import UserWorkoutGoalHistory as UserWorkoutGoalHistoryModel +from src.models.user_workout_goal_history import UserWorkoutGoalHistory as UserWorkoutGoalHistoryModel from src.database import db_session import requests import json @@ -204,7 +204,10 @@ class Meta: model = UserModel active_streak = graphene.Int( - description="Get the current workout streak of a user, in terms of number of consecutive weeks." + description="Get the current workout streak of a user, in terms of number of consecutive weeks, and the start date of the streak." + ) + max_streak = graphene.Int( + description="Get the maximum number of consecutive weeks the user has met their goal." ) friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) @@ -262,15 +265,24 @@ def resolve_active_streak(self, info): window_end = today while True: + # Never index past the list (defensive guard for edge cases) + if day_pointer >= total_workout_dates: + break + # Definition of a given week window_start = window_end - timedelta(days=6) - # Count how many workout days fall within [window_start, window_end] - day_iterator = day_pointer + # Count how many workout days fall within [window_start, window_end]; use bounded iteration to avoid index errors count_in_window = 0 - while day_iterator < total_workout_dates and workout_dates[day_iterator] >= window_start: + day_iterator = day_pointer + for i in range(day_pointer, total_workout_dates): + if workout_dates[i] < window_start: + day_iterator = i + break count_in_window += 1 - day_iterator += 1 + day_iterator = i + 1 + else: + day_iterator = total_workout_dates # User completed no workouts in the current 7-day window, streak ends if count_in_window == 0: @@ -284,7 +296,7 @@ def resolve_active_streak(self, info): # Move to the previous rolling 7-day window window_end = window_end - timedelta(days=7) - # day_iterator should have past the end of the current window and entered the next with the loops break condition, so we can set it as the new day_pointer + # day_iterator is the first index not in the current window (or past end); use as next day_pointer day_pointer = day_iterator # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop @@ -293,6 +305,96 @@ def resolve_active_streak(self, info): return streak + def resolve_max_streak(self, info): + user = User.get_query(info).filter(UserModel.id == self.id).first() + + if not user: + raise GraphQLError("User with the given ID does not exist.") + + # Pull DISTINCT workout dates (not timestamps) in descending order (newest to oldest) + # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] + workout_date_rows = ( + Workout.get_query(info) + .filter(WorkoutModel.user_id == user.id) + .with_entities(cast(WorkoutModel.workout_time, Date).label("workout_date")) + .distinct() + .order_by(cast(WorkoutModel.workout_time, Date).desc()) + .all() + ) + + # If the user has no logged workouts, return 0 + if not workout_date_rows: + return 0 + + # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending + workout_dates = [row[0] for row in workout_date_rows] + + # Pull the user's workout goal history in descending order (newest to oldest) + # Gets us a list of tuples [(workout_goal, effective_at), (workout_goal, effective_at), ...] + goal_hist = ( + db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) + .filter(UserWorkoutGoalHistoryModel.user_id == self.id) + .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) + .all() + ) + + # If the user has no workout goal history (meaning they have never set nor changed their workout goal), return 0 + if not goal_hist: + if not self.workout_goal: + return 0 + goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history + + def goal_at(window_end_date): + window_end_dt = datetime.combine(window_end_date, datetime.max.time()) + for goal_days, effective_at in goal_hist: + if effective_at <= window_end_dt: # Find the most recent goal that was effective at or before the given date + return goal_days + return goal_hist[-1][0] + + today = datetime.utcnow().date() + day_pointer, total_workout_dates = 0, len(workout_dates) + window_end = today + + run_met_goal = 0 + max_met_goal = 0 + + while True: + # Definition of a given week + window_start = window_end - timedelta(days=6) + + day_iterator = day_pointer + count_in_window = 0 + + # Count how many workout days fall within [window_start, window_end] + while day_iterator < total_workout_dates and workout_dates[day_iterator] >= window_start: + count_in_window += 1 + day_iterator += 1 + + goal_days = goal_at(window_end) # Get the user's workout goal for the current window + + # If the user did not complete any workouts in the current window, the streak ends + if count_in_window == 0: + max_met_goal = max(max_met_goal, run_met_goal) + run_met_goal = 0 + elif goal_days and count_in_window >= goal_days: # Window hits workout goal, streak increases + run_met_goal += 1 + else: # User does not hit workout goal, but completes at least one workout in the current window, so streak continues + pass + + # Move to the previous rolling 7-day window + window_end = window_end - timedelta(days=7) + + # day_iterator should have past the end of the current window and entered the next with the loops break condition, so we can set it as the new day_pointer + day_pointer = day_iterator + + # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop + if day_pointer >= total_workout_dates: + break + + # Return the maximum number of consecutive weeks the user has met their goal + max_met_goal = max(max_met_goal, run_met_goal) + return max_met_goal + def resolve_friendships(self, info): # Return all friendship relationships for this user query = Friendship.get_query(info).filter( From 1d4ec9727feeaad8fb9aff0539b0044e0a6f9749 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 23 Feb 2026 21:22:15 -0500 Subject: [PATCH 07/55] Add lastStreakStart field and resolver, ensure streaks consider workout goal history --- schema.graphql | 1 + src/schema.py | 207 +++++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 176 insertions(+), 32 deletions(-) diff --git a/schema.graphql b/schema.graphql index 7e3e7a1..3a40b5a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -308,6 +308,7 @@ type User { workoutGoal: Int lastGoalChange: DateTime lastStreak: Int! + lastStreakStart: DateTime encodedImage: String giveaways: [Giveaway] friendRequestsSent: [Friendship] diff --git a/src/schema.py b/src/schema.py index 3580563..901e826 100644 --- a/src/schema.py +++ b/src/schema.py @@ -215,6 +215,9 @@ class Meta: required=True, description="Get the total number of gym days (unique workout days) for user." ) + last_streak_start = graphene.DateTime( + description="The start date of the most recent active streak." + ) # NOTE: Current implementation counts total number of workouts with unqiue days, whereas more efficient implementation persists said value in User model # Persistence currently considered unnecessary/overkill (would require syncing + extra logic, and workouts are unlikely to exceed 800 for a single user — a low number by any standard) @@ -228,6 +231,111 @@ def resolve_total_gym_days(self, info): def resolve_active_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() + if not user: + raise GraphQLError("User with the given ID does not exist.") + + # 1) Pull workout timestamps (assumed stored as UTC; may be naive UTC) + workout_date_rows = ( + Workout.get_query(info) + .filter(WorkoutModel.user_id == user.id) + .with_entities(cast(WorkoutModel.workout_time, Date).label("workout_date")) + .distinct() + .order_by(cast(WorkoutModel.workout_time, Date).desc()) + .all() + ) + + if not workout_date_rows: + return 0 + + workout_dates = [row[0] for row in workout_date_rows] # already date objects + + if not workout_dates: + return 0 + + # 2) Pull goal history (newest -> oldest) + goal_rows = ( + UserWorkoutGoalHistoryModel.get_query(info) + .filter(UserWorkoutGoalHistoryModel.user_id == user.id) + .with_entities( + UserWorkoutGoalHistoryModel.workout_goal, + UserWorkoutGoalHistoryModel.effective_at, + ) + .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) + .all() + ) + + if not goal_rows: + # No historical goals recorded => define streak as 0 + return 0 + + # Keep effective_at as datetime (newest -> oldest) for minute-level specificity. + # ws is a date (window start = midnight); comparison ws < effective_at uses that. + # Convert rows [(workout_goal, effective_at), (workout_goal, effective_at), ...] -> list[tuple[int, datetime]] descending + goal_values = [goal for goal, _ in goal_rows] + goal_effective_dates = [eff_at.date() for _, eff_at in goal_rows] + + # Rule: for a window starting on date ws, use the most recent goal whose effective_date <= ws + goal_index = 0 # pointer into (newest -> oldest) + + def goal_for_window_start(ws): + """ + Helper function to determine the workout goal for a given window start date. + Parameters: + - `ws` (datetime.date): The start date of the window. + Returns: + - The workout goal for the given window start date. + """ + nonlocal goal_index + # Move to older goals until the current goal is effective on/before ws + while goal_index < len(goal_values) - 1 and ws < goal_effective_dates[goal_index]: + goal_index += 1 + # If ws is still before the oldest goal effective date, we have no goal defined for that time + if ws < goal_effective_dates[-1]: + # No goal existed yet; stop counting further back + # This case should not be reached, as goals are defined before the first workout + return None + return goal_values[goal_index] + + # 3) Streak computation + streak = 0 + day_pointer = 0 + total = len(workout_dates) + + today = datetime.now().date() + window_end = today + + while day_pointer < total: + window_start = window_end - timedelta(days=6) + + # Determine which goal applies to this window + window_goal = goal_for_window_start(window_start) + if window_goal is None: + # No goal existed yet; stop counting further back + break + + # Count workout days in [window_start, window_end] using the descending list + count_in_window = 0 + day_iterator = day_pointer + while day_iterator < total and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end + count_in_window += 1 + day_iterator += 1 + + # If user did 0 workouts in this window, streak ends + if count_in_window == 0: + break + + # If window hits goal, increment streak; otherwise streak continues but doesn't increment + if count_in_window >= window_goal: + streak += 1 + + # Move to previous 7-day window and continue + window_end -= timedelta(days=7) + day_pointer = day_iterator + + return streak + + def resolve_last_streak_start(self, info): + user = User.get_query(info).filter(UserModel.id == self.id).first() if not user: raise GraphQLError("User with the given ID does not exist.") @@ -243,55 +351,85 @@ def resolve_active_streak(self, info): .all() ) - # Gets us the user's current workout goal - workout_goal = user.workout_goal - - # If the user has no logged workouts or no workout goal, return 0 - if not workout_date_rows or not workout_goal: - return 0 + if not workout_date_rows: + return None # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending workout_dates = [row[0] for row in workout_date_rows] # already date objects - # TODO: Revisit date logic, make UTC universal in codebase - today = datetime.utcnow().date() - streak = 0 + goal_rows = ( + UserWorkoutGoalHistoryModel.get_query(info) + .filter(UserWorkoutGoalHistoryModel.user_id == user.id) + .with_entities( + UserWorkoutGoalHistoryModel.workout_goal, + UserWorkoutGoalHistoryModel.effective_at, + ) + .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) + .all() + ) - # Walk through the workout_dates list once using an index pointer - day_pointer = 0 # Dates are sorted in descending order, so we start at the most recent date - total_workout_dates = len(workout_dates) + if not goal_rows: + return None + + # Convert rows [(workout_goal, effective_at), (workout_goal, effective_at), ...] -> list[tuple[int, datetime]] descending + goal_values = [goal for goal, _ in goal_rows] + goal_effective_dates = [eff_at.date() for _, eff_at in goal_rows] + + goal_index = 0 # pointer into (newest -> oldest) + + def goal_for_window_start(ws): + """ + Helper function to determine the workout goal for a given window start date. + Parameters: + - `ws` (datetime.date): The start date of the window. + Returns: + - The workout goal for the given window start date. + """ + nonlocal goal_index + # Move to older goals until the current goal is effective on/before ws + while goal_index < len(goal_values) - 1 and ws < goal_effective_dates[goal_index]: + goal_index += 1 + # If ws is still before the oldest goal effective date, we have no goal defined for that time + if ws < goal_effective_dates[-1]: + # No goal existed yet; stop counting further back + # This case should not be reached, as goals are defined before the first workout + return None + return goal_values[goal_index] + + # 3) Streak computation + day_pointer = 0 + total = len(workout_dates) - # Set the end of the current week to today, will look 6 days back from today to check for streaks + # TODO: Revisit date logic, make UTC universal in codebase + today = datetime.now().date() window_end = today + idx_last_streak_start = None + last_streak_start = None # date of the start of the most recent streak (earliest window in that streak) - while True: - # Never index past the list (defensive guard for edge cases) - if day_pointer >= total_workout_dates: - break - + while day_pointer < total: # Definition of a given week window_start = window_end - timedelta(days=6) + # Determine which goal applies to this window + window_goal = goal_for_window_start(window_start) + if window_goal is None: + # No goal existed yet; stop counting further back + break + # Count how many workout days fall within [window_start, window_end]; use bounded iteration to avoid index errors count_in_window = 0 day_iterator = day_pointer - for i in range(day_pointer, total_workout_dates): - if workout_dates[i] < window_start: - day_iterator = i - break + while day_iterator < total and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end count_in_window += 1 - day_iterator = i + 1 - else: - day_iterator = total_workout_dates + day_iterator += 1 # User completed no workouts in the current 7-day window, streak ends if count_in_window == 0: break - elif count_in_window >= workout_goal: # Window hits workout goal, streak increases - streak += 1 - else: - # User does not hit workout goal, but completes at least one workout in the current window, so streak continues - pass + + if count_in_window >= window_goal: + # Window hits workout goal; this window is part of the most recent streak. + idx_last_streak_start = day_iterator - 1 # Move to the previous rolling 7-day window window_end = window_end - timedelta(days=7) @@ -300,10 +438,15 @@ def resolve_active_streak(self, info): day_pointer = day_iterator # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop - if day_pointer >= total_workout_dates: + if day_pointer >= total: break - return streak + # Return as datetime at midnight (UTC) for graphene.DateTime, or None + if idx_last_streak_start is None: + return None + + last_streak_start = workout_dates[idx_last_streak_start] + return datetime.combine(last_streak_start, datetime.min.time()) # Must be returned as DateTime to adhere to schema, despite being Date object def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() From dab7e7fda0a5736c6dcb81fded4633d5e9f722ee Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Tue, 24 Feb 2026 19:31:08 -0500 Subject: [PATCH 08/55] add deleteReport mutation --- src/schema.py | 35 +++++++++++++++++++++++++++-------- 1 file changed, 27 insertions(+), 8 deletions(-) diff --git a/src/schema.py b/src/schema.py index 5c7b07b..a56434e 100644 --- a/src/schema.py +++ b/src/schema.py @@ -826,19 +826,37 @@ def mutate(self, info, description, issue, created_at, gym_id): db_session.commit() try: - sh.worksheet(SHEET_REPORTS).append_row([ - report.id, - issue, - gym.name, - description, - created_at.isoformat(), - ]) + sh.worksheet(SHEET_REPORTS).append_row([report.id, issue, gym.name, description, created_at.isoformat()]) except Exception as e: print(f"Error logging report to sheet: {e}") - + return CreateReport(report=report) +class DeleteReport(graphene.Mutation): + class Arguments: + report_id = graphene.Int(required=True) + + Output = Report + + def mutate(self, info, report_id): + # Check if report exists + report = Report.get_query(info).filter(ReportModel.id == report_id).first() + if not report: + raise GraphQLError("Report with given ID does not exist.") + + try: + worksheet = sh.worksheet(SHEET_REPORTS) + cell = worksheet.find(str(report_id), in_column=1) + worksheet.delete_rows(cell.row) + except Exception as e: + print(f"Error deleting report from sheet: {e}") + + db_session.delete(report) + db_session.commit() + return report + + class DeleteUserById(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1136,6 +1154,7 @@ class Mutation(graphene.ObjectType): logout_user = LogoutUser.Field(description="Logs out a user.") refresh_access_token = RefreshAccessToken.Field(description="Refreshes the access token.") create_report = CreateReport.Field(description="Creates a new report.") + delete_report = DeleteReport.Field(description="Deletes a report by ID.") delete_user = DeleteUserById.Field(description="Deletes a user by ID.") add_friend = AddFriend.Field(description="Adds a friend to a user.") remove_friend = RemoveFriend.Field(description="Removes a friend from a user.") From 2545a52bfcb3e2f9c3ce586b08ff8c10569caa3b Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 25 Feb 2026 11:45:41 -0500 Subject: [PATCH 09/55] Bug fix: Adjust time when workout goal changes go in effect --- schema.graphql | 11 ++- src/schema.py | 177 +++++++++++++++++++++++++++++-------------------- 2 files changed, 116 insertions(+), 72 deletions(-) diff --git a/schema.graphql b/schema.graphql index 3a40b5a..22bca08 100644 --- a/schema.graphql +++ b/schema.graphql @@ -308,14 +308,15 @@ type User { workoutGoal: Int lastGoalChange: DateTime lastStreak: Int! - lastStreakStart: DateTime encodedImage: String giveaways: [Giveaway] + goalHistory: [WorkoutGoalHistory] friendRequestsSent: [Friendship] friendRequestsReceived: [Friendship] friendships: [Friendship] friends: [User] totalGymDays: Int! + lastStreakStart: DateTime } type Workout { @@ -325,3 +326,11 @@ type Workout { facilityId: Int! gymName: String! } + +type WorkoutGoalHistory { + id: Int + userId: Int + workoutGoal: Int + effectiveAt: DateTime + user: User +} diff --git a/src/schema.py b/src/schema.py index 901e826..bd80620 100644 --- a/src/schema.py +++ b/src/schema.py @@ -196,6 +196,15 @@ def resolve_pricing(self, info): return query +class WorkoutGoalHistory(SQLAlchemyObjectType): + class Meta: + model = UserWorkoutGoalHistoryModel + + id = graphene.Int(description="The ID of the workout goal history.") + user_id = graphene.Int(description="The ID of the user who owns the workout goal history.") + workout_goal = graphene.Int(description="The workout goal.") + effective_at = graphene.DateTime(description="The date and time the workout goal was set.") + # MARK: - User @@ -234,7 +243,8 @@ def resolve_active_streak(self, info): if not user: raise GraphQLError("User with the given ID does not exist.") - # 1) Pull workout timestamps (assumed stored as UTC; may be naive UTC) + # Pull DISTINCT workout dates (not timestamps) in descending order (newest to oldest) + # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] workout_date_rows = ( Workout.get_query(info) .filter(WorkoutModel.user_id == user.id) @@ -244,89 +254,83 @@ def resolve_active_streak(self, info): .all() ) + # If the user has no logged workouts, return 0 if not workout_date_rows: return 0 - workout_dates = [row[0] for row in workout_date_rows] # already date objects - - if not workout_dates: - return 0 + # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending + workout_dates = [row[0] for row in workout_date_rows] - # 2) Pull goal history (newest -> oldest) - goal_rows = ( - UserWorkoutGoalHistoryModel.get_query(info) + # Pull the user's workout goal history in descending order (newest to oldest) + # Gets us a list of tuples [(workout_goal, effective_at), (workout_goal, effective_at), ...] + goal_hist = ( + db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) .filter(UserWorkoutGoalHistoryModel.user_id == user.id) - .with_entities( - UserWorkoutGoalHistoryModel.workout_goal, - UserWorkoutGoalHistoryModel.effective_at, - ) .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) .all() ) - if not goal_rows: - # No historical goals recorded => define streak as 0 - return 0 - - # Keep effective_at as datetime (newest -> oldest) for minute-level specificity. - # ws is a date (window start = midnight); comparison ws < effective_at uses that. - # Convert rows [(workout_goal, effective_at), (workout_goal, effective_at), ...] -> list[tuple[int, datetime]] descending - goal_values = [goal for goal, _ in goal_rows] - goal_effective_dates = [eff_at.date() for _, eff_at in goal_rows] + if not goal_hist: + # If the user has no workout goal history (meaning they have never set nor changed their workout goal), return 0 + if not self.workout_goal: + return 0 + goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - # Rule: for a window starting on date ws, use the most recent goal whose effective_date <= ws - goal_index = 0 # pointer into (newest -> oldest) + # print(f"goal_hist: {[(goal, eff_at.date()) for goal, eff_at in goal_hist]}") - def goal_for_window_start(ws): + def goal_at(window_start_date): """ Helper function to determine the workout goal for a given window start date. Parameters: - - `ws` (datetime.date): The start date of the window. + - `window_start_date` (datetime.date): The start date of the window. Returns: - The workout goal for the given window start date. """ - nonlocal goal_index - # Move to older goals until the current goal is effective on/before ws - while goal_index < len(goal_values) - 1 and ws < goal_effective_dates[goal_index]: - goal_index += 1 - # If ws is still before the oldest goal effective date, we have no goal defined for that time - if ws < goal_effective_dates[-1]: - # No goal existed yet; stop counting further back - # This case should not be reached, as goals are defined before the first workout - return None - return goal_values[goal_index] + for goal_days, effective_at in goal_hist: + # Find the most recent goal that was effective at or before the given datetime (remember, sorted by newest to oldest) + if effective_at.date() <= window_start_date: # Important to consider dates instead of datetimes + return goal_days + + # If we've gone through all the goals and haven't found one, return the oldest goal + return goal_hist[-1][0] # 3) Streak computation - streak = 0 - day_pointer = 0 - total = len(workout_dates) - today = datetime.now().date() + formatted_string = today.strftime("%m/%d/%Y, %H:%M:%S") + # print(f"today: {formatted_string}") + + day_pointer, total_workout_days = 0, len(workout_dates) window_end = today - while day_pointer < total: + streak = 0 + + while day_pointer < total_workout_days: + # Definition of a given week window_start = window_end - timedelta(days=6) - # Determine which goal applies to this window - window_goal = goal_for_window_start(window_start) - if window_goal is None: - # No goal existed yet; stop counting further back - break + # print(f"window_start: {window_start}, window_end: {window_end}") # Count workout days in [window_start, window_end] using the descending list - count_in_window = 0 day_iterator = day_pointer - while day_iterator < total and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end + count_in_window = 0 + + while day_iterator < total_workout_days and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end count_in_window += 1 day_iterator += 1 + # Get the user's workout goal for the current window + goal_days = goal_at(window_start) + # print(f"goal_days: {goal_days}") + # If user did 0 workouts in this window, streak ends if count_in_window == 0: break - # If window hits goal, increment streak; otherwise streak continues but doesn't increment - if count_in_window >= window_goal: + elif count_in_window >= goal_days: streak += 1 + else: + # User did not meet the goal for the current window, streak continues but doesn't increment + pass # Move to previous 7-day window and continue window_end -= timedelta(days=7) @@ -358,7 +362,7 @@ def resolve_last_streak_start(self, info): workout_dates = [row[0] for row in workout_date_rows] # already date objects goal_rows = ( - UserWorkoutGoalHistoryModel.get_query(info) + WorkoutGoalHistory.get_query(info) .filter(UserWorkoutGoalHistoryModel.user_id == user.id) .with_entities( UserWorkoutGoalHistoryModel.workout_goal, @@ -450,7 +454,6 @@ def goal_for_window_start(ws): def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() - if not user: raise GraphQLError("User with the given ID does not exist.") @@ -476,7 +479,7 @@ def resolve_max_streak(self, info): # Gets us a list of tuples [(workout_goal, effective_at), (workout_goal, effective_at), ...] goal_hist = ( db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) - .filter(UserWorkoutGoalHistoryModel.user_id == self.id) + .filter(UserWorkoutGoalHistoryModel.user_id == user.id) .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) .all() ) @@ -487,21 +490,37 @@ def resolve_max_streak(self, info): return 0 goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - def goal_at(window_end_date): - window_end_dt = datetime.combine(window_end_date, datetime.max.time()) + print(f"goal_hist: {[(goal, eff_at.date()) for goal, eff_at in goal_hist]}") + + def goal_at(window_start_date): + """ + Helper function to determine the workout goal for a given window start date. + Parameters: + - `window_start_date` (datetime.date): The start date of the window. + Returns: + - The workout goal for the given window start date. + """ for goal_days, effective_at in goal_hist: - if effective_at <= window_end_dt: # Find the most recent goal that was effective at or before the given date + # Find the most recent goal that was effective at or before the given datetime (remember, sorted by newest to oldest) + if effective_at.date() <= window_start_date: # Important to consider dates instead of datetimes return goal_days + + # If we've gone through all the goals and haven't found one, return the oldest goal return goal_hist[-1][0] - today = datetime.utcnow().date() + today = datetime.now().date() + formatted_string = today.strftime("%m/%d/%Y, %H:%M:%S") + print(f"today: {formatted_string}") + day_pointer, total_workout_dates = 0, len(workout_dates) window_end = today run_met_goal = 0 max_met_goal = 0 - while True: + while day_pointer < total_workout_dates: + # TODO: Ignore dates after today + # Definition of a given week window_start = window_end - timedelta(days=6) @@ -513,7 +532,9 @@ def goal_at(window_end_date): count_in_window += 1 day_iterator += 1 - goal_days = goal_at(window_end) # Get the user's workout goal for the current window + # Get the user's workout goal for the current window + goal_days = goal_at(window_start) + print(f"goal_days: {goal_days}") # If the user did not complete any workouts in the current window, the streak ends if count_in_window == 0: @@ -525,15 +546,11 @@ def goal_at(window_end_date): pass # Move to the previous rolling 7-day window - window_end = window_end - timedelta(days=7) + window_end -= timedelta(days=7) # day_iterator should have past the end of the current window and entered the next with the loops break condition, so we can set it as the new day_pointer day_pointer = day_iterator - # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop - if day_pointer >= total_workout_dates: - break - # Return the maximum number of consecutive weeks the user has met their goal max_met_goal = max(max_met_goal, run_met_goal) return max_met_goal @@ -1038,20 +1055,38 @@ def mutate(self, info, user_id, workout_goal, change_time=None): if not user: raise GraphQLError("User with given ID does not exist.") - # Update the last time the user changed their goal - if change_time: - user.last_goal_change = change_time + # Avoid writing duplicate history entries if goal didn't actually change + if user.workout_goal == workout_goal: + return user + + # check if first goal ever + has_history = db_session.query(UserWorkoutGoalHistoryModel.id).filter( + UserWorkoutGoalHistoryModel.user_id == user.id + ).first() is not None + + if not has_history: + effective_at = change_time or datetime.now() # apply immediately if no change time is provided else: - user.last_goal_change = datetime.now() + if change_time: + next_start_local = change_time.date() + timedelta(days=1) + else: + next_start_local = datetime.now().date() + timedelta(days=1) + effective_at = datetime.combine(next_start_local, datetime.min.time()) - # Update the user's last streak + user.last_goal_change = effective_at user.last_streak = user.active_streak - - # Update the user's workout goal user.workout_goal = workout_goal - db_session.commit() + # Append-only history entry + db_session.add( + UserWorkoutGoalHistoryModel( + user_id=user.id, + workout_goal=workout_goal, + effective_at=effective_at, + ) + ) + db_session.commit() return user From c3c91a9969dcbf862e900a03d52a88f997a71cff Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 25 Feb 2026 11:57:49 -0500 Subject: [PATCH 10/55] Convert backend times to utc, transition from deprecated library --- src/schema.py | 109 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 93 insertions(+), 16 deletions(-) diff --git a/src/schema.py b/src/schema.py index bd80620..726d640 100644 --- a/src/schema.py +++ b/src/schema.py @@ -38,6 +38,38 @@ def resolve_enum_value(entry): """Return the raw value for Enum objects while leaving plain strings untouched.""" return getattr(entry, "value", entry) + +def ensure_utc(dt): + """ + Normalize a datetime to UTC. + - If dt is None, return None. + - If dt is naive, assume it is already in UTC and attach UTC tzinfo. + - If dt is timezone-aware, convert it to UTC. + """ + if dt is None: + return None + if getattr(dt, "tzinfo", None) is None: + return dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc) + + +def to_local_time(dt): + """ + Convert a UTC datetime to the server's local timezone for output. + - If dt is None, return None. + - If dt is naive, assume it is UTC first. + - If dt is timezone-aware, convert from UTC to local. + """ + if dt is None: + return None + + dt_utc = ensure_utc(dt) + if dt_utc is None: + return None + + # Convert to local timezone (server-local) + return dt_utc.astimezone() + # MARK: - Gym @@ -205,6 +237,9 @@ class Meta: workout_goal = graphene.Int(description="The workout goal.") effective_at = graphene.DateTime(description="The date and time the workout goal was set.") + def resolve_effective_at(self, info): + return to_local_time(self.effective_at) + # MARK: - User @@ -227,6 +262,9 @@ class Meta: last_streak_start = graphene.DateTime( description="The start date of the most recent active streak." ) + last_goal_change = graphene.DateTime( + description="The last time the user changed their workout goal." + ) # NOTE: Current implementation counts total number of workouts with unqiue days, whereas more efficient implementation persists said value in User model # Persistence currently considered unnecessary/overkill (would require syncing + extra logic, and workouts are unlikely to exceed 800 for a single user — a low number by any standard) @@ -450,7 +488,9 @@ def goal_for_window_start(ws): return None last_streak_start = workout_dates[idx_last_streak_start] - return datetime.combine(last_streak_start, datetime.min.time()) # Must be returned as DateTime to adhere to schema, despite being Date object + last_streak_start_dt = datetime.combine(last_streak_start, datetime.min.time()) + # Store as UTC and convert to local time for output + return to_local_time(ensure_utc(last_streak_start_dt)) # Must be returned as DateTime to adhere to schema, despite being Date object def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() @@ -555,6 +595,9 @@ def goal_at(window_start_date): max_met_goal = max(max_met_goal, run_met_goal) return max_met_goal + def resolve_last_goal_change(self, info): + return to_local_time(self.last_goal_change) + def resolve_friendships(self, info): # Return all friendship relationships for this user query = Friendship.get_query(info).filter( @@ -604,6 +647,9 @@ def resolve_friend(self, info): query = User.get_query(info).filter(UserModel.id == self.friend_id).first() return query + def resolve_accepted_at(self, info): + return to_local_time(self.accepted_at) + # MARK: - Giveaway @@ -638,6 +684,9 @@ def resolve_gym_name(self, info): raise GraphQLError("Gym for workout not found.") return gym.name + def resolve_workout_time(self, info): + return to_local_time(self.workout_time) + # MARK: - Report @@ -652,6 +701,9 @@ def resolve_gym(self, info): query = Gym.get_query(info).filter(GymModel.id == self.gym_id).first() return query + def resolve_created_at(self, info): + return to_local_time(self.created_at) + # MARK: - Capacity Reminder @@ -734,8 +786,8 @@ def resolve_get_weekly_workout_days(self, info, id): if not user: raise GraphQLError("User with the given ID does not exist.") - # Get the date 7 days ago - one_week_ago = datetime.utcnow() - timedelta(days=7) + # Get the date 7 days ago in UTC + one_week_ago = datetime.now(timezone.utc) - timedelta(days=7) # Query distinct workout dates for the user in the past week. Workouts must never be logged for a future date. workout_days = ( @@ -747,7 +799,8 @@ def resolve_get_weekly_workout_days(self, info, id): ) # Extract days of the week from the workout times (use a set to avoid duplicates) - workout_days_set = {workout.workout_time.strftime("%A") for workout in workout_days} + # Convert workout_time to local time so the weekday reflects the user's local date. + workout_days_set = {to_local_time(workout.workout_time).strftime("%A") for workout in workout_days} return list(workout_days_set) @@ -1059,19 +1112,40 @@ def mutate(self, info, user_id, workout_goal, change_time=None): if user.workout_goal == workout_goal: return user - # check if first goal ever - has_history = db_session.query(UserWorkoutGoalHistoryModel.id).filter( - UserWorkoutGoalHistoryModel.user_id == user.id - ).first() is not None + # Determine the datetime of the last goal change for cooldown checks. + # Prefer the denormalized User.last_goal_change, but fall back to the most recent history entry if needed. + last_change_dt = user.last_goal_change + latest_history_entry = ( + db_session.query(UserWorkoutGoalHistoryModel) + .filter(UserWorkoutGoalHistoryModel.user_id == user.id) + .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) + .first() + ) + has_history = latest_history_entry is not None + + if last_change_dt is None and latest_history_entry is not None: + last_change_dt = latest_history_entry.effective_at + + # Enforce a 30-day cooldown between workout goal updates. + if last_change_dt is not None: + now_utc = datetime.now(timezone.utc) + last_change_utc = ensure_utc(last_change_dt) + if last_change_utc is not None and now_utc - last_change_utc < timedelta(days=30): + raise GraphQLError("Workout goal can only be updated once every 30 days.") + + # Normalize provided change_time to UTC for storage and further logic. + change_time_utc = ensure_utc(change_time) if change_time else None if not has_history: - effective_at = change_time or datetime.now() # apply immediately if no change time is provided + # First goal ever: apply immediately (or at provided change_time) in UTC. + effective_at = change_time_utc or datetime.now(timezone.utc) else: - if change_time: - next_start_local = change_time.date() + timedelta(days=1) + # Subsequent goals take effect starting the next day (local concept, but stored as UTC midnight). + if change_time_utc: + next_start_date = change_time_utc.date() + timedelta(days=1) else: - next_start_local = datetime.now().date() + timedelta(days=1) - effective_at = datetime.combine(next_start_local, datetime.min.time()) + next_start_date = datetime.now(timezone.utc).date() + timedelta(days=1) + effective_at = datetime.combine(next_start_date, datetime.min.time()).replace(tzinfo=timezone.utc) user.last_goal_change = effective_at user.last_streak = user.active_streak @@ -1109,7 +1183,9 @@ def mutate(self, info, workout_time, user_id, facility_id): if not facility: raise GraphQLError("Facility with given ID does not exist.") - workout = WorkoutModel(workout_time=workout_time, user_id=user.id, facility_id=facility.id) + workout_time_utc = ensure_utc(workout_time) + + workout = WorkoutModel(workout_time=workout_time_utc, user_id=user.id, facility_id=facility.id) db_session.add(workout) db_session.commit() @@ -1139,7 +1215,8 @@ def mutate(self, info, description, issue, created_at, gym_id): "OTHER", ]: raise GraphQLError("Issue is not a valid enumeration.") - report = ReportModel(description=description, issue=issue, created_at=created_at, gym_id=gym_id) + created_at_utc = ensure_utc(created_at) + report = ReportModel(description=description, issue=issue, created_at=created_at_utc, gym_id=gym_id) db_session.add(report) db_session.commit() return CreateReport(report=report) @@ -1379,7 +1456,7 @@ def mutate(self, info, friendship_id): # Accept friendship friendship.is_accepted = True - friendship.accepted_at = datetime.utcnow() + friendship.accepted_at = datetime.now(timezone.utc) db_session.commit() return friendship From d8625ab9072958fe42e98a82b111b64b14aeece2 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 25 Feb 2026 14:48:26 -0500 Subject: [PATCH 11/55] Final bug fixes with local time to utc conversion --- src/schema.py | 34 ++++++++++------------------------ 1 file changed, 10 insertions(+), 24 deletions(-) diff --git a/src/schema.py b/src/schema.py index 726d640..4487996 100644 --- a/src/schema.py +++ b/src/schema.py @@ -248,10 +248,10 @@ class Meta: model = UserModel active_streak = graphene.Int( - description="Get the current workout streak of a user, in terms of number of consecutive weeks, and the start date of the streak." + description="Get the current workout streak of a user, in terms of number of consecutive weeks, up until the current date." ) max_streak = graphene.Int( - description="Get the maximum number of consecutive weeks the user has met their goal." + description="Get the maximum number of consecutive weeks the user has met their goal up until the current date." ) friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) @@ -260,7 +260,7 @@ class Meta: description="Get the total number of gym days (unique workout days) for user." ) last_streak_start = graphene.DateTime( - description="The start date of the most recent active streak." + description="The start date of the most recent active streak, up until the current date." ) last_goal_change = graphene.DateTime( description="The last time the user changed their workout goal." @@ -314,8 +314,6 @@ def resolve_active_streak(self, info): return 0 goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - # print(f"goal_hist: {[(goal, eff_at.date()) for goal, eff_at in goal_hist]}") - def goal_at(window_start_date): """ Helper function to determine the workout goal for a given window start date. @@ -333,9 +331,7 @@ def goal_at(window_start_date): return goal_hist[-1][0] # 3) Streak computation - today = datetime.now().date() - formatted_string = today.strftime("%m/%d/%Y, %H:%M:%S") - # print(f"today: {formatted_string}") + today = datetime.now(timezone.utc).date() day_pointer, total_workout_days = 0, len(workout_dates) window_end = today @@ -346,8 +342,6 @@ def goal_at(window_start_date): # Definition of a given week window_start = window_end - timedelta(days=6) - # print(f"window_start: {window_start}, window_end: {window_end}") - # Count workout days in [window_start, window_end] using the descending list day_iterator = day_pointer count_in_window = 0 @@ -358,7 +352,6 @@ def goal_at(window_start_date): # Get the user's workout goal for the current window goal_days = goal_at(window_start) - # print(f"goal_days: {goal_days}") # If user did 0 workouts in this window, streak ends if count_in_window == 0: @@ -443,7 +436,7 @@ def goal_for_window_start(ws): total = len(workout_dates) # TODO: Revisit date logic, make UTC universal in codebase - today = datetime.now().date() + today = datetime.now(timezone.utc).date() window_end = today idx_last_streak_start = None last_streak_start = None # date of the start of the most recent streak (earliest window in that streak) @@ -490,7 +483,7 @@ def goal_for_window_start(ws): last_streak_start = workout_dates[idx_last_streak_start] last_streak_start_dt = datetime.combine(last_streak_start, datetime.min.time()) # Store as UTC and convert to local time for output - return to_local_time(ensure_utc(last_streak_start_dt)) # Must be returned as DateTime to adhere to schema, despite being Date object + return to_local_time(last_streak_start_dt) # Must be returned as DateTime to adhere to schema, despite being Date object def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() @@ -530,8 +523,6 @@ def resolve_max_streak(self, info): return 0 goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - print(f"goal_hist: {[(goal, eff_at.date()) for goal, eff_at in goal_hist]}") - def goal_at(window_start_date): """ Helper function to determine the workout goal for a given window start date. @@ -548,10 +539,7 @@ def goal_at(window_start_date): # If we've gone through all the goals and haven't found one, return the oldest goal return goal_hist[-1][0] - today = datetime.now().date() - formatted_string = today.strftime("%m/%d/%Y, %H:%M:%S") - print(f"today: {formatted_string}") - + today = datetime.now(timezone.utc).date() day_pointer, total_workout_dates = 0, len(workout_dates) window_end = today @@ -559,7 +547,9 @@ def goal_at(window_start_date): max_met_goal = 0 while day_pointer < total_workout_dates: - # TODO: Ignore dates after today + # Move to the first workout on or before today (i.e. ignore dates after today) + while day_pointer < total_workout_dates and workout_dates[day_pointer] > today: + day_pointer += 1 # Definition of a given week window_start = window_end - timedelta(days=6) @@ -574,7 +564,6 @@ def goal_at(window_start_date): # Get the user's workout goal for the current window goal_days = goal_at(window_start) - print(f"goal_days: {goal_days}") # If the user did not complete any workouts in the current window, the streak ends if count_in_window == 0: @@ -595,9 +584,6 @@ def goal_at(window_start_date): max_met_goal = max(max_met_goal, run_met_goal) return max_met_goal - def resolve_last_goal_change(self, info): - return to_local_time(self.last_goal_change) - def resolve_friendships(self, info): # Return all friendship relationships for this user query = Friendship.get_query(info).filter( From a6e903229a7a4e0bcfea4e41425701e81a38ed72 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 25 Feb 2026 16:40:00 -0500 Subject: [PATCH 12/55] Rename and refactor lastStreakStart resolver --- schema.graphql | 4 +- src/schema.py | 118 +++++++++++++++++++++++-------------------------- 2 files changed, 58 insertions(+), 64 deletions(-) diff --git a/schema.graphql b/schema.graphql index 22bca08..f26c1bf 100644 --- a/schema.graphql +++ b/schema.graphql @@ -85,6 +85,8 @@ type CreateReport { report: Report } +scalar Date + scalar DateTime enum DayOfWeekEnum { @@ -316,7 +318,7 @@ type User { friendships: [Friendship] friends: [User] totalGymDays: Int! - lastStreakStart: DateTime + streakStart: Date } type Workout { diff --git a/src/schema.py b/src/schema.py index 4487996..f7cc4d0 100644 --- a/src/schema.py +++ b/src/schema.py @@ -259,7 +259,7 @@ class Meta: required=True, description="Get the total number of gym days (unique workout days) for user." ) - last_streak_start = graphene.DateTime( + streak_start = graphene.Date( description="The start date of the most recent active streak, up until the current date." ) last_goal_change = graphene.DateTime( @@ -369,14 +369,12 @@ def goal_at(window_start_date): return streak - def resolve_last_streak_start(self, info): + def resolve_streak_start(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() - if not user: raise GraphQLError("User with the given ID does not exist.") - # Pull DISTINCT workout dates (not timestamps) in descending order - # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] + # DISTINCT workout dates (descending) workout_date_rows = ( Workout.get_query(info) .filter(WorkoutModel.user_id == user.id) @@ -389,101 +387,95 @@ def resolve_last_streak_start(self, info): if not workout_date_rows: return None - # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending - workout_dates = [row[0] for row in workout_date_rows] # already date objects + workout_dates = [row[0] for row in workout_date_rows] # list[date] desc + if not workout_dates: + return None - goal_rows = ( - WorkoutGoalHistory.get_query(info) - .filter(UserWorkoutGoalHistoryModel.user_id == user.id) - .with_entities( + goal_hist = ( + db_session.query( UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at, ) + .filter(UserWorkoutGoalHistoryModel.user_id == user.id) .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) .all() ) - if not goal_rows: + if not goal_hist: return None - # Convert rows [(workout_goal, effective_at), (workout_goal, effective_at), ...] -> list[tuple[int, datetime]] descending - goal_values = [goal for goal, _ in goal_rows] - goal_effective_dates = [eff_at.date() for _, eff_at in goal_rows] + goal_values = [goal for goal, _ in goal_hist] + goal_effective_dates = [] + for _, eff_at in goal_hist: + # tolerate naive timestamps by treating them as UTC-by-convention + if eff_at.tzinfo is None: + eff_at = eff_at.replace(tzinfo=timezone.utc) + goal_effective_dates.append(eff_at.date()) - goal_index = 0 # pointer into (newest -> oldest) + if not goal_effective_dates: + return None - def goal_for_window_start(ws): - """ - Helper function to determine the workout goal for a given window start date. - Parameters: - - `ws` (datetime.date): The start date of the window. - Returns: - - The workout goal for the given window start date. - """ + goal_index = 0 # pointer (newest -> oldest) + + def goal_for_window_start(ws_date): nonlocal goal_index - # Move to older goals until the current goal is effective on/before ws - while goal_index < len(goal_values) - 1 and ws < goal_effective_dates[goal_index]: + + # Advance pointer until we reach a goal effective on/before ws_date + while goal_index < len(goal_values) - 1 and ws_date < goal_effective_dates[goal_index]: goal_index += 1 - # If ws is still before the oldest goal effective date, we have no goal defined for that time - if ws < goal_effective_dates[-1]: - # No goal existed yet; stop counting further back - # This case should not be reached, as goals are defined before the first workout + + # If ws_date is earlier than the oldest goal's effective date, we have no goal defined for that window. + if ws_date < goal_effective_dates[-1]: return None + return goal_values[goal_index] - # 3) Streak computation + today = datetime.now(timezone.utc).date() + window_end = today + day_pointer = 0 total = len(workout_dates) - # TODO: Revisit date logic, make UTC universal in codebase - today = datetime.now(timezone.utc).date() - window_end = today - idx_last_streak_start = None - last_streak_start = None # date of the start of the most recent streak (earliest window in that streak) + idx_last_streak_start = None # index of first workout day (oldest) in earliest goal-met week of the active segment while day_pointer < total: - # Definition of a given week + # Move to the first workout on or before today (i.e. ignore dates after today) + while day_pointer < total and workout_dates[day_pointer] > today: + day_pointer += 1 + window_start = window_end - timedelta(days=6) - # Determine which goal applies to this window window_goal = goal_for_window_start(window_start) if window_goal is None: - # No goal existed yet; stop counting further back break - # Count how many workout days fall within [window_start, window_end]; use bounded iteration to avoid index errors - count_in_window = 0 - day_iterator = day_pointer - while day_iterator < total and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end - count_in_window += 1 - day_iterator += 1 + # Count workout days in [window_start, window_end] (dates are already <= current window_end by construction) + i = day_pointer + while i < total and workout_dates[i] >= window_start: + i += 1 - # User completed no workouts in the current 7-day window, streak ends + count_in_window = i - day_pointer + + # No workouts => streak segment ends if count_in_window == 0: break + # Met goal => record the oldest workout day in this window if count_in_window >= window_goal: - # Window hits workout goal; this window is part of the most recent streak. - idx_last_streak_start = day_iterator - 1 - - # Move to the previous rolling 7-day window - window_end = window_end - timedelta(days=7) - - # day_iterator is the first index not in the current window (or past end); use as next day_pointer - day_pointer = day_iterator + # i is first index AFTER this window; i-1 is oldest workout day within the window + if i - 1 >= 0: + idx_last_streak_start = i - 1 - # If we've gone through all the workout dates, we've checked all the windows and can terminate the loop - if day_pointer >= total: - break + # Move to previous window + window_end -= timedelta(days=7) + day_pointer = i - # Return as datetime at midnight (UTC) for graphene.DateTime, or None if idx_last_streak_start is None: return None - - last_streak_start = workout_dates[idx_last_streak_start] - last_streak_start_dt = datetime.combine(last_streak_start, datetime.min.time()) - # Store as UTC and convert to local time for output - return to_local_time(last_streak_start_dt) # Must be returned as DateTime to adhere to schema, despite being Date object + + last_streak_start_date = workout_dates[idx_last_streak_start] + + return last_streak_start_date def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() From bd50c04bff02e4b9e2a96f4046c87c4e88013ae3 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Wed, 25 Feb 2026 16:48:01 -0500 Subject: [PATCH 13/55] Remove dev parameters from mutation to set workout goal --- schema.graphql | 2 +- src/schema.py | 15 ++++----------- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/schema.graphql b/schema.graphql index f26c1bf..3fbb90a 100644 --- a/schema.graphql +++ b/schema.graphql @@ -218,7 +218,7 @@ type Mutation { createUser(email: String!, encodedImage: String, name: String!, netId: String!): User editUser(email: String, encodedImage: String, name: String, netId: String!): User enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance - setWorkoutGoals(changeTime: DateTime, userId: Int!, workoutGoal: Int!): User + setWorkoutGoals(userId: Int!, workoutGoal: Int!): User logWorkout(facilityId: Int!, userId: Int!, workoutTime: DateTime!): Workout loginUser(netId: String!): LoginUser logoutUser: LogoutUser diff --git a/src/schema.py b/src/schema.py index f7cc4d0..3382b71 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1076,12 +1076,11 @@ class Arguments: required=True, description="The new workout goal for the user in terms of number of days per week.", ) - change_time = graphene.DateTime(required=False, description="The date and time the user changed their goal.") Output = User @jwt_required() - def mutate(self, info, user_id, workout_goal, change_time=None): + def mutate(self, info, user_id, workout_goal): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: raise GraphQLError("User with given ID does not exist.") @@ -1111,18 +1110,12 @@ def mutate(self, info, user_id, workout_goal, change_time=None): if last_change_utc is not None and now_utc - last_change_utc < timedelta(days=30): raise GraphQLError("Workout goal can only be updated once every 30 days.") - # Normalize provided change_time to UTC for storage and further logic. - change_time_utc = ensure_utc(change_time) if change_time else None - if not has_history: - # First goal ever: apply immediately (or at provided change_time) in UTC. - effective_at = change_time_utc or datetime.now(timezone.utc) + # First goal ever: apply immediately in UTC. + effective_at = datetime.now(timezone.utc) else: # Subsequent goals take effect starting the next day (local concept, but stored as UTC midnight). - if change_time_utc: - next_start_date = change_time_utc.date() + timedelta(days=1) - else: - next_start_date = datetime.now(timezone.utc).date() + timedelta(days=1) + next_start_date = datetime.now(timezone.utc).date() + timedelta(days=1) effective_at = datetime.combine(next_start_date, datetime.min.time()).replace(tzinfo=timezone.utc) user.last_goal_change = effective_at From c8719052986296746df8446ed2f2c9db9a607055 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Sun, 1 Mar 2026 14:57:20 -0500 Subject: [PATCH 14/55] Generalize code and remove extra comments --- src/schema.py | 125 +++++++++++--------------------------------------- 1 file changed, 26 insertions(+), 99 deletions(-) diff --git a/src/schema.py b/src/schema.py index 3382b71..b1b8859 100644 --- a/src/schema.py +++ b/src/schema.py @@ -70,6 +70,21 @@ def to_local_time(dt): # Convert to local timezone (server-local) return dt_utc.astimezone() +def goal_at(goal_history, window_start_date): + """ + Determine the workout goal for a given window start date from the goal history. + Parameters: + - `window_start_date` (datetime.date): The start date of the window. + - `goal_history` (list[tuple[int, datetime.datetime]]): The list of workout goal history entries. + Returns: + - The workout goal for the given window start date. + """ + for workout_goal, effective_at in goal_history: + if effective_at.date() <= window_start_date: + return workout_goal + + return goal_history[-1][0] + # MARK: - Gym @@ -232,11 +247,6 @@ class WorkoutGoalHistory(SQLAlchemyObjectType): class Meta: model = UserWorkoutGoalHistoryModel - id = graphene.Int(description="The ID of the workout goal history.") - user_id = graphene.Int(description="The ID of the user who owns the workout goal history.") - workout_goal = graphene.Int(description="The workout goal.") - effective_at = graphene.DateTime(description="The date and time the workout goal was set.") - def resolve_effective_at(self, info): return to_local_time(self.effective_at) @@ -247,12 +257,6 @@ class User(SQLAlchemyObjectType): class Meta: model = UserModel - active_streak = graphene.Int( - description="Get the current workout streak of a user, in terms of number of consecutive weeks, up until the current date." - ) - max_streak = graphene.Int( - description="Get the maximum number of consecutive weeks the user has met their goal up until the current date." - ) friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) total_gym_days = graphene.Int( @@ -262,12 +266,7 @@ class Meta: streak_start = graphene.Date( description="The start date of the most recent active streak, up until the current date." ) - last_goal_change = graphene.DateTime( - description="The last time the user changed their workout goal." - ) - # NOTE: Current implementation counts total number of workouts with unqiue days, whereas more efficient implementation persists said value in User model - # Persistence currently considered unnecessary/overkill (would require syncing + extra logic, and workouts are unlikely to exceed 800 for a single user — a low number by any standard) def resolve_total_gym_days(self, info): return ( Workout.get_query(info) @@ -281,8 +280,6 @@ def resolve_active_streak(self, info): if not user: raise GraphQLError("User with the given ID does not exist.") - # Pull DISTINCT workout dates (not timestamps) in descending order (newest to oldest) - # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] workout_date_rows = ( Workout.get_query(info) .filter(WorkoutModel.user_id == user.id) @@ -292,15 +289,11 @@ def resolve_active_streak(self, info): .all() ) - # If the user has no logged workouts, return 0 if not workout_date_rows: return 0 - # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending workout_dates = [row[0] for row in workout_date_rows] - # Pull the user's workout goal history in descending order (newest to oldest) - # Gets us a list of tuples [(workout_goal, effective_at), (workout_goal, effective_at), ...] goal_hist = ( db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) .filter(UserWorkoutGoalHistoryModel.user_id == user.id) @@ -309,28 +302,10 @@ def resolve_active_streak(self, info): ) if not goal_hist: - # If the user has no workout goal history (meaning they have never set nor changed their workout goal), return 0 if not self.workout_goal: return 0 - goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - - def goal_at(window_start_date): - """ - Helper function to determine the workout goal for a given window start date. - Parameters: - - `window_start_date` (datetime.date): The start date of the window. - Returns: - - The workout goal for the given window start date. - """ - for goal_days, effective_at in goal_hist: - # Find the most recent goal that was effective at or before the given datetime (remember, sorted by newest to oldest) - if effective_at.date() <= window_start_date: # Important to consider dates instead of datetimes - return goal_days - - # If we've gone through all the goals and haven't found one, return the oldest goal - return goal_hist[-1][0] - - # 3) Streak computation + goal_hist = [(self.workout_goal, datetime.min)] + today = datetime.now(timezone.utc).date() day_pointer, total_workout_days = 0, len(workout_dates) @@ -339,31 +314,24 @@ def goal_at(window_start_date): streak = 0 while day_pointer < total_workout_days: - # Definition of a given week window_start = window_end - timedelta(days=6) - # Count workout days in [window_start, window_end] using the descending list day_iterator = day_pointer count_in_window = 0 - while day_iterator < total_workout_days and workout_dates[day_iterator] >= window_start: # window_start <= workout_dates[i] <= window_end + while day_iterator < total_workout_days and workout_dates[day_iterator] >= window_start: count_in_window += 1 day_iterator += 1 - # Get the user's workout goal for the current window - goal_days = goal_at(window_start) + goal_days = goal_at(goal_hist, window_start) - # If user did 0 workouts in this window, streak ends if count_in_window == 0: break - # If window hits goal, increment streak; otherwise streak continues but doesn't increment elif count_in_window >= goal_days: streak += 1 else: - # User did not meet the goal for the current window, streak continues but doesn't increment pass - # Move to previous 7-day window and continue window_end -= timedelta(days=7) day_pointer = day_iterator @@ -374,7 +342,6 @@ def resolve_streak_start(self, info): if not user: raise GraphQLError("User with the given ID does not exist.") - # DISTINCT workout dates (descending) workout_date_rows = ( Workout.get_query(info) .filter(WorkoutModel.user_id == user.id) @@ -387,7 +354,7 @@ def resolve_streak_start(self, info): if not workout_date_rows: return None - workout_dates = [row[0] for row in workout_date_rows] # list[date] desc + workout_dates = [row[0] for row in workout_date_rows] if not workout_dates: return None @@ -407,7 +374,6 @@ def resolve_streak_start(self, info): goal_values = [goal for goal, _ in goal_hist] goal_effective_dates = [] for _, eff_at in goal_hist: - # tolerate naive timestamps by treating them as UTC-by-convention if eff_at.tzinfo is None: eff_at = eff_at.replace(tzinfo=timezone.utc) goal_effective_dates.append(eff_at.date()) @@ -415,16 +381,14 @@ def resolve_streak_start(self, info): if not goal_effective_dates: return None - goal_index = 0 # pointer (newest -> oldest) + goal_index = 0 def goal_for_window_start(ws_date): nonlocal goal_index - # Advance pointer until we reach a goal effective on/before ws_date while goal_index < len(goal_values) - 1 and ws_date < goal_effective_dates[goal_index]: goal_index += 1 - # If ws_date is earlier than the oldest goal's effective date, we have no goal defined for that window. if ws_date < goal_effective_dates[-1]: return None @@ -436,10 +400,9 @@ def goal_for_window_start(ws_date): day_pointer = 0 total = len(workout_dates) - idx_last_streak_start = None # index of first workout day (oldest) in earliest goal-met week of the active segment + idx_last_streak_start = None while day_pointer < total: - # Move to the first workout on or before today (i.e. ignore dates after today) while day_pointer < total and workout_dates[day_pointer] > today: day_pointer += 1 @@ -449,24 +412,19 @@ def goal_for_window_start(ws_date): if window_goal is None: break - # Count workout days in [window_start, window_end] (dates are already <= current window_end by construction) i = day_pointer while i < total and workout_dates[i] >= window_start: i += 1 count_in_window = i - day_pointer - # No workouts => streak segment ends if count_in_window == 0: break - # Met goal => record the oldest workout day in this window if count_in_window >= window_goal: - # i is first index AFTER this window; i-1 is oldest workout day within the window if i - 1 >= 0: idx_last_streak_start = i - 1 - # Move to previous window window_end -= timedelta(days=7) day_pointer = i @@ -482,8 +440,6 @@ def resolve_max_streak(self, info): if not user: raise GraphQLError("User with the given ID does not exist.") - # Pull DISTINCT workout dates (not timestamps) in descending order (newest to oldest) - # Gets us a list of date objects [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] workout_date_rows = ( Workout.get_query(info) .filter(WorkoutModel.user_id == user.id) @@ -493,15 +449,11 @@ def resolve_max_streak(self, info): .all() ) - # If the user has no logged workouts, return 0 if not workout_date_rows: return 0 - # Convert rows [(datetime.date(2026, 2, 18), ), (datetime.date(2026, 2, 17), ), ...] -> list[date] descending workout_dates = [row[0] for row in workout_date_rows] - # Pull the user's workout goal history in descending order (newest to oldest) - # Gets us a list of tuples [(workout_goal, effective_at), (workout_goal, effective_at), ...] goal_hist = ( db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) .filter(UserWorkoutGoalHistoryModel.user_id == user.id) @@ -509,27 +461,10 @@ def resolve_max_streak(self, info): .all() ) - # If the user has no workout goal history (meaning they have never set nor changed their workout goal), return 0 if not goal_hist: if not self.workout_goal: return 0 - goal_hist = [(self.workout_goal, datetime.min)] # Set the user's current workout goal as the first goal in the history - - def goal_at(window_start_date): - """ - Helper function to determine the workout goal for a given window start date. - Parameters: - - `window_start_date` (datetime.date): The start date of the window. - Returns: - - The workout goal for the given window start date. - """ - for goal_days, effective_at in goal_hist: - # Find the most recent goal that was effective at or before the given datetime (remember, sorted by newest to oldest) - if effective_at.date() <= window_start_date: # Important to consider dates instead of datetimes - return goal_days - - # If we've gone through all the goals and haven't found one, return the oldest goal - return goal_hist[-1][0] + goal_hist = [(self.workout_goal, datetime.min)] today = datetime.now(timezone.utc).date() day_pointer, total_workout_dates = 0, len(workout_dates) @@ -539,40 +474,32 @@ def goal_at(window_start_date): max_met_goal = 0 while day_pointer < total_workout_dates: - # Move to the first workout on or before today (i.e. ignore dates after today) while day_pointer < total_workout_dates and workout_dates[day_pointer] > today: day_pointer += 1 - # Definition of a given week window_start = window_end - timedelta(days=6) day_iterator = day_pointer count_in_window = 0 - # Count how many workout days fall within [window_start, window_end] while day_iterator < total_workout_dates and workout_dates[day_iterator] >= window_start: count_in_window += 1 day_iterator += 1 - # Get the user's workout goal for the current window - goal_days = goal_at(window_start) + goal_days = goal_at(goal_hist, window_start) - # If the user did not complete any workouts in the current window, the streak ends if count_in_window == 0: max_met_goal = max(max_met_goal, run_met_goal) run_met_goal = 0 - elif goal_days and count_in_window >= goal_days: # Window hits workout goal, streak increases + elif goal_days and count_in_window >= goal_days: run_met_goal += 1 - else: # User does not hit workout goal, but completes at least one workout in the current window, so streak continues + else: pass - # Move to the previous rolling 7-day window window_end -= timedelta(days=7) - # day_iterator should have past the end of the current window and entered the next with the loops break condition, so we can set it as the new day_pointer day_pointer = day_iterator - # Return the maximum number of consecutive weeks the user has met their goal max_met_goal = max(max_met_goal, run_met_goal) return max_met_goal From add4eb6581a58cf873db4b101137af3d370274b2 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 2 Mar 2026 05:34:12 -0500 Subject: [PATCH 15/55] Remove extra comments --- schema.graphql | 12 ++++++------ src/schema.py | 7 ------- 2 files changed, 6 insertions(+), 13 deletions(-) diff --git a/schema.graphql b/schema.graphql index 3fbb90a..54800e5 100644 --- a/schema.graphql +++ b/schema.graphql @@ -305,8 +305,8 @@ type User { email: String netId: String! name: String! - activeStreak: Int - maxStreak: Int + activeStreak: Int! + maxStreak: Int! workoutGoal: Int lastGoalChange: DateTime lastStreak: Int! @@ -330,9 +330,9 @@ type Workout { } type WorkoutGoalHistory { - id: Int - userId: Int - workoutGoal: Int - effectiveAt: DateTime + id: ID! + userId: Int! + workoutGoal: Int! + effectiveAt: DateTime! user: User } diff --git a/src/schema.py b/src/schema.py index b1b8859..ef768a2 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1012,12 +1012,9 @@ def mutate(self, info, user_id, workout_goal): if not user: raise GraphQLError("User with given ID does not exist.") - # Avoid writing duplicate history entries if goal didn't actually change if user.workout_goal == workout_goal: return user - # Determine the datetime of the last goal change for cooldown checks. - # Prefer the denormalized User.last_goal_change, but fall back to the most recent history entry if needed. last_change_dt = user.last_goal_change latest_history_entry = ( db_session.query(UserWorkoutGoalHistoryModel) @@ -1030,7 +1027,6 @@ def mutate(self, info, user_id, workout_goal): if last_change_dt is None and latest_history_entry is not None: last_change_dt = latest_history_entry.effective_at - # Enforce a 30-day cooldown between workout goal updates. if last_change_dt is not None: now_utc = datetime.now(timezone.utc) last_change_utc = ensure_utc(last_change_dt) @@ -1038,10 +1034,8 @@ def mutate(self, info, user_id, workout_goal): raise GraphQLError("Workout goal can only be updated once every 30 days.") if not has_history: - # First goal ever: apply immediately in UTC. effective_at = datetime.now(timezone.utc) else: - # Subsequent goals take effect starting the next day (local concept, but stored as UTC midnight). next_start_date = datetime.now(timezone.utc).date() + timedelta(days=1) effective_at = datetime.combine(next_start_date, datetime.min.time()).replace(tzinfo=timezone.utc) @@ -1049,7 +1043,6 @@ def mutate(self, info, user_id, workout_goal): user.last_streak = user.active_streak user.workout_goal = workout_goal - # Append-only history entry db_session.add( UserWorkoutGoalHistoryModel( user_id=user.id, From 149f584174cd0f9e0cb0671595ceeec9d66b133e Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 2 Mar 2026 06:51:13 -0500 Subject: [PATCH 16/55] Debug timezone conversions --- ...ebug_internal_default_conversion_to_utc.py | 69 +++++++++++++++++++ src/models/user_workout_goal_history.py | 4 +- src/models/workout.py | 5 +- 3 files changed, 74 insertions(+), 4 deletions(-) create mode 100644 migrations/versions/48923aecacb0_debug_internal_default_conversion_to_utc.py diff --git a/migrations/versions/48923aecacb0_debug_internal_default_conversion_to_utc.py b/migrations/versions/48923aecacb0_debug_internal_default_conversion_to_utc.py new file mode 100644 index 0000000..30d839d --- /dev/null +++ b/migrations/versions/48923aecacb0_debug_internal_default_conversion_to_utc.py @@ -0,0 +1,69 @@ +"""Debug internal default conversion to UTC + +Revision ID: 48923aecacb0 +Revises: +Create Date: 2026-03-02 06:30:17.794409 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = '48923aecacb0' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.alter_column( + "workout", + "workout_time", + type_=sa.DateTime(timezone=True), + postgresql_using="workout_time AT TIME ZONE 'UTC'", + existing_nullable=False, + ) + + op.alter_column( + "user_workout_goal_history", + "effective_at", + type_=sa.DateTime(timezone=True), + postgresql_using="effective_at AT TIME ZONE 'UTC'", + existing_nullable=False, + ) + + op.alter_column( + "user_workout_goal_history", + "effective_at", + server_default=sa.text("CURRENT_TIMESTAMP"), + existing_type=sa.DateTime(timezone=True), + existing_nullable=False, + ) + + +def downgrade(): + op.alter_column( + "user_workout_goal_history", + "effective_at", + server_default=None, + existing_type=sa.DateTime(timezone=True), + existing_nullable=False, + ) + + op.alter_column( + "user_workout_goal_history", + "effective_at", + type_=sa.DateTime(timezone=False), + postgresql_using="effective_at::timestamp", + existing_nullable=False, + ) + + op.alter_column( + "workout", + "workout_time", + type_=sa.DateTime(timezone=False), + postgresql_using="workout_time::timestamp", + existing_nullable=False, + ) diff --git a/src/models/user_workout_goal_history.py b/src/models/user_workout_goal_history.py index 17c2f8a..31408a5 100644 --- a/src/models/user_workout_goal_history.py +++ b/src/models/user_workout_goal_history.py @@ -1,7 +1,7 @@ from sqlalchemy import Column, Integer, Float, ForeignKey, String, DateTime, text from sqlalchemy.orm import backref, relationship from src.database import Base -from datetime import datetime +from datetime import datetime, timezone class UserWorkoutGoalHistory(Base): """ @@ -19,6 +19,6 @@ class UserWorkoutGoalHistory(Base): id = Column(Integer, primary_key=True) user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False) workout_goal = Column(Integer, nullable=False) - effective_at = Column(DateTime, nullable=False, default=datetime.utcnow, server_default=text("CURRENT_TIMESTAMP")) + effective_at = Column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), server_default=text("CURRENT_TIMESTAMP")) user = relationship("User", back_populates='goal_history') \ No newline at end of file diff --git a/src/models/workout.py b/src/models/workout.py index 50cd48b..1b27940 100644 --- a/src/models/workout.py +++ b/src/models/workout.py @@ -1,6 +1,7 @@ -from sqlalchemy import Column, Integer, Float, ForeignKey, String, DateTime +from sqlalchemy import Column, Integer, Float, ForeignKey, String, DateTime, text from sqlalchemy.orm import backref, relationship from src.database import Base +from datetime import timezone class Workout(Base): @@ -17,6 +18,6 @@ class Workout(Base): __tablename__ = "workout" id = Column(Integer, primary_key=True) - workout_time = Column(DateTime(), nullable=False) # should this be nullable? + workout_time = Column(DateTime(timezone=True), nullable=False, server_default=text("CURRENT_TIMESTAMP")) # should this be nullable? user_id = Column(Integer, ForeignKey("users.id"), nullable=False) facility_id = Column(Integer, ForeignKey("facility.id"), nullable=False) From 6e6247d8be8ce9fc563e833dad95ed50fbb34ee4 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 2 Mar 2026 06:51:40 -0500 Subject: [PATCH 17/55] Resolve migration issues --- migrations/versions/31b1fa20772f_popular_times.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/31b1fa20772f_popular_times.py b/migrations/versions/31b1fa20772f_popular_times.py index 6f50ab8..0a982ea 100644 --- a/migrations/versions/31b1fa20772f_popular_times.py +++ b/migrations/versions/31b1fa20772f_popular_times.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = '31b1fa20772f' -down_revision = None +down_revision = 'eb948c31a342' branch_labels = None depends_on = None From 9ab9efb2a2b38fea9cf8f0b45992d1abeb3b3152 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 2 Mar 2026 07:11:56 -0500 Subject: [PATCH 18/55] Merge --- .../eb948c31a342_create_gear_table.py | 34 +++++++ src/schema.py | 95 +++++++++---------- src/utils/constants.py | 3 + 3 files changed, 83 insertions(+), 49 deletions(-) create mode 100644 migrations/versions/eb948c31a342_create_gear_table.py diff --git a/migrations/versions/eb948c31a342_create_gear_table.py b/migrations/versions/eb948c31a342_create_gear_table.py new file mode 100644 index 0000000..e8e62dc --- /dev/null +++ b/migrations/versions/eb948c31a342_create_gear_table.py @@ -0,0 +1,34 @@ +"""Create gear table + +Revision ID: eb948c31a342 +Revises: 6fb4a21a1201 +Create Date: 2026-03-02 06:22:00.042780 + +""" +from alembic import op +import sqlalchemy as sa + +# Import the PriceType enum used in the model so the Enum can be created +from src.models.activity import PriceType + + +# revision identifiers, used by Alembic. +revision = 'eb948c31a342' +down_revision = '6fb4a21a1201' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "gear", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("activity_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(), nullable=False), + sa.Column("cost", sa.Float(), nullable=False), + sa.Column("rate", sa.String(), nullable=True), + sa.Column("type", sa.Enum(PriceType), nullable=False), + ) + +def downgrade(): + op.drop_table("gear") diff --git a/src/schema.py b/src/schema.py index ef768a2..8fecf30 100644 --- a/src/schema.py +++ b/src/schema.py @@ -27,8 +27,6 @@ from src.models.user_workout_goal_history import UserWorkoutGoalHistory as UserWorkoutGoalHistoryModel from src.database import db_session import requests -import json -import os from firebase_admin import messaging import logging from sqlalchemy import func, cast, Date @@ -637,19 +635,12 @@ class Query(graphene.ObjectType): HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." ) get_user_friends = graphene.List( - User, - user_id=graphene.Int(required=True), - description="Get all friends for a user." + User, user_id=graphene.Int(required=True), description="Get all friends for a user." ) get_capacity_reminder_by_id = graphene.Field( - CapacityReminder, - id=graphene.Int(required=True), - description="Get a specific capacity reminder by its ID." - ) - get_all_capacity_reminders = graphene.List( - CapacityReminder, - description="Get all capacity reminders." + CapacityReminder, id=graphene.Int(required=True), description="Get a specific capacity reminder by its ID." ) + get_all_capacity_reminders = graphene.List(CapacityReminder, description="Get all capacity reminders.") def resolve_get_all_gyms(self, info): query = Gym.get_query(info) @@ -727,14 +718,18 @@ def resolve_get_user_friends(self, info, user_id): raise GraphQLError("User with the given ID does not exist.") # Direct friendships where user is the initiator - direct_friendships = Friendship.get_query(info).filter( - (FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True) - ).all() + direct_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) # Reverse friendships where user is the recipient - reverse_friendships = Friendship.get_query(info).filter( - (FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True) - ).all() + reverse_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) friend_ids = set() for friendship in direct_friendships: @@ -745,7 +740,7 @@ def resolve_get_user_friends(self, info, user_id): # Query for all friends at once return User.get_query(info).filter(UserModel.id.in_(friend_ids)).all() - + @jwt_required() def resolve_get_capacity_reminder_by_id(self, info, id): reminder = CapacityReminder.get_query(info).filter(CapacityReminderModel.id == id).first() @@ -754,7 +749,7 @@ def resolve_get_capacity_reminder_by_id(self, info, id): raise GraphQLError("Capacity reminder with the given ID does not exist.") return reminder - + @jwt_required() def resolve_get_all_capacity_reminders(self, info): query = CapacityReminder.get_query(info) @@ -1221,11 +1216,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho for topic in topics: try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) - logging.info( - "Unsubscribe %s from %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) for error in response.errors: logging.warning( "Error unsubscribing %s from %s -> reason: %s", reminder.fcm_token[:12], topic, error.reason @@ -1241,11 +1232,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho for topic in topics: try: response = messaging.subscribe_to_topic(reminder.fcm_token, topic) - logging.info( - "Resubscribing %s to %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Resubscribing %s to %s", reminder.fcm_token[:12], topic) if response.success_count == 0: raise Exception(response.errors[0].reason) except Exception as error: @@ -1279,13 +1266,9 @@ def mutate(self, info, reminder_id): for topic in topics: try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) - logging.info( - "Unsubscribe %s from %s", - reminder.fcm_token[:12], - topic, - ) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) if response.success_count == 0: - raise Exception(response.errors[0].reason) + raise Exception(response.errors[0].reason) except Exception as error: raise GraphQLError(f"Error unsubscribing from topic {topic}: {error}") @@ -1294,6 +1277,7 @@ def mutate(self, info, reminder_id): return reminder + class AddFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1313,10 +1297,14 @@ def mutate(self, info, user_id, friend_id): raise GraphQLError("Friend with given ID does not exist.") # Check if friendship already exists - existing = Friendship.get_query(info).filter( - ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) | - ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) - ).first() + existing = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if existing: raise GraphQLError("Friendship already exists.") @@ -1328,6 +1316,7 @@ def mutate(self, info, user_id, friend_id): return friendship + class AcceptFriendRequest(graphene.Mutation): class Arguments: friendship_id = graphene.Int(required=True) @@ -1362,10 +1351,14 @@ class Arguments: @jwt_required() def mutate(self, info, user_id, friend_id): # Find the friendship - friendship = Friendship.get_query(info).filter( - ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) | - ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) - ).first() + friendship = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if not friendship: raise GraphQLError("Friendship not found.") @@ -1376,6 +1369,7 @@ def mutate(self, info, user_id, friend_id): return RemoveFriend(success=True) + class GetPendingFriendRequests(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1390,10 +1384,11 @@ def mutate(self, info, user_id): raise GraphQLError("User with given ID does not exist.") # Get pending friend requests (where this user is the friend) - pending = Friendship.get_query(info).filter( - (FriendshipModel.friend_id == user_id) & - (FriendshipModel.is_accepted == False) - ).all() + pending = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == False)) + .all() + ) return GetPendingFriendRequests(pending_requests=pending) @@ -1409,6 +1404,7 @@ class Mutation(graphene.ObjectType): logout_user = LogoutUser.Field(description="Logs out a user.") refresh_access_token = RefreshAccessToken.Field(description="Refreshes the access token.") create_report = CreateReport.Field(description="Creates a new report.") + delete_report = DeleteReport.Field(description="Deletes a report by ID.") delete_user = DeleteUserById.Field(description="Deletes a user by ID.") add_friend = AddFriend.Field(description="Adds a friend to a user.") remove_friend = RemoveFriend.Field(description="Removes a friend from a user.") @@ -1419,7 +1415,8 @@ class Mutation(graphene.ObjectType): accept_friend_request = AcceptFriendRequest.Field(description="Accept a friend request.") remove_friend = RemoveFriend.Field(description="Remove a friendship.") get_pending_friend_requests = GetPendingFriendRequests.Field( - description="Get all pending friend requests for a user.") + description="Get all pending friend requests for a user." + ) schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/src/utils/constants.py b/src/utils/constants.py index 3d0ed63..1cf8664 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -131,6 +131,9 @@ # Worksheet name for regular facility hours SHEET_REG_FACILITY = "[REG] Facility Hours" +# Worksheet name for reports +SHEET_REPORTS = "Reports" + # Worksheet name for special facility hours SHEET_SP_FACILITY = "[SP] Facility Hours" From 8b853ad59d809dda205bd9242f4a1f7c236cfd42 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 2 Mar 2026 07:21:20 -0500 Subject: [PATCH 19/55] Merge again --- src/schema.py | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/src/schema.py b/src/schema.py index 8fecf30..47aa626 100644 --- a/src/schema.py +++ b/src/schema.py @@ -535,6 +535,7 @@ class UserInput(graphene.InputObjectType): # MARK: - Friendship + class Friendship(SQLAlchemyObjectType): class Meta: model = FriendshipModel @@ -636,11 +637,14 @@ class Query(graphene.ObjectType): ) get_user_friends = graphene.List( User, user_id=graphene.Int(required=True), description="Get all friends for a user." + User, user_id=graphene.Int(required=True), description="Get all friends for a user." ) get_capacity_reminder_by_id = graphene.Field( CapacityReminder, id=graphene.Int(required=True), description="Get a specific capacity reminder by its ID." + CapacityReminder, id=graphene.Int(required=True), description="Get a specific capacity reminder by its ID." ) get_all_capacity_reminders = graphene.List(CapacityReminder, description="Get all capacity reminders.") + get_all_capacity_reminders = graphene.List(CapacityReminder, description="Get all capacity reminders.") def resolve_get_all_gyms(self, info): query = Gym.get_query(info) @@ -723,6 +727,11 @@ def resolve_get_user_friends(self, info, user_id): .filter((FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True)) .all() ) + direct_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.user_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) # Reverse friendships where user is the recipient reverse_friendships = ( @@ -730,6 +739,11 @@ def resolve_get_user_friends(self, info, user_id): .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True)) .all() ) + reverse_friendships = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == True)) + .all() + ) friend_ids = set() for friendship in direct_friendships: @@ -741,6 +755,7 @@ def resolve_get_user_friends(self, info, user_id): # Query for all friends at once return User.get_query(info).filter(UserModel.id.in_(friend_ids)).all() + @jwt_required() def resolve_get_capacity_reminder_by_id(self, info, id): reminder = CapacityReminder.get_query(info).filter(CapacityReminderModel.id == id).first() @@ -750,6 +765,7 @@ def resolve_get_capacity_reminder_by_id(self, info, id): return reminder + @jwt_required() def resolve_get_all_capacity_reminders(self, info): query = CapacityReminder.get_query(info) @@ -945,6 +961,7 @@ def mutate(self, info, name): db_session.commit() return giveaway + class AddFriend(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True, description="The Net ID of the user.") @@ -968,6 +985,7 @@ def mutate(self, info, user_net_id, friend_net_id): db_session.commit() return user + class RemoveFriend(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True, description="The Net ID of the user.") @@ -991,6 +1009,7 @@ def mutate(self, info, user_net_id, friend_net_id): db_session.commit() return user + class SetWorkoutGoals(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True, description="The ID of the user.") @@ -1217,6 +1236,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) for error in response.errors: logging.warning( "Error unsubscribing %s from %s -> reason: %s", reminder.fcm_token[:12], topic, error.reason @@ -1233,6 +1253,7 @@ def mutate(self, info, reminder_id, new_gyms, days_of_week, new_capacity_thresho try: response = messaging.subscribe_to_topic(reminder.fcm_token, topic) logging.info("Resubscribing %s to %s", reminder.fcm_token[:12], topic) + logging.info("Resubscribing %s to %s", reminder.fcm_token[:12], topic) if response.success_count == 0: raise Exception(response.errors[0].reason) except Exception as error: @@ -1267,8 +1288,10 @@ def mutate(self, info, reminder_id): try: response = messaging.unsubscribe_from_topic(reminder.fcm_token, topic) logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) + logging.info("Unsubscribe %s from %s", reminder.fcm_token[:12], topic) if response.success_count == 0: raise Exception(response.errors[0].reason) + raise Exception(response.errors[0].reason) except Exception as error: raise GraphQLError(f"Error unsubscribing from topic {topic}: {error}") @@ -1278,6 +1301,7 @@ def mutate(self, info, reminder_id): return reminder + class AddFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1305,6 +1329,14 @@ def mutate(self, info, user_id, friend_id): ) .first() ) + existing = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if existing: raise GraphQLError("Friendship already exists.") @@ -1317,6 +1349,7 @@ def mutate(self, info, user_id, friend_id): return friendship + class AcceptFriendRequest(graphene.Mutation): class Arguments: friendship_id = graphene.Int(required=True) @@ -1341,6 +1374,7 @@ def mutate(self, info, friendship_id): return friendship + class RemoveFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1359,6 +1393,14 @@ def mutate(self, info, user_id, friend_id): ) .first() ) + friendship = ( + Friendship.get_query(info) + .filter( + ((FriendshipModel.user_id == user_id) & (FriendshipModel.friend_id == friend_id)) + | ((FriendshipModel.user_id == friend_id) & (FriendshipModel.friend_id == user_id)) + ) + .first() + ) if not friendship: raise GraphQLError("Friendship not found.") @@ -1370,6 +1412,7 @@ def mutate(self, info, user_id, friend_id): return RemoveFriend(success=True) + class GetPendingFriendRequests(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1389,6 +1432,11 @@ def mutate(self, info, user_id): .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == False)) .all() ) + pending = ( + Friendship.get_query(info) + .filter((FriendshipModel.friend_id == user_id) & (FriendshipModel.is_accepted == False)) + .all() + ) return GetPendingFriendRequests(pending_requests=pending) @@ -1405,6 +1453,7 @@ class Mutation(graphene.ObjectType): refresh_access_token = RefreshAccessToken.Field(description="Refreshes the access token.") create_report = CreateReport.Field(description="Creates a new report.") delete_report = DeleteReport.Field(description="Deletes a report by ID.") + delete_report = DeleteReport.Field(description="Deletes a report by ID.") delete_user = DeleteUserById.Field(description="Deletes a user by ID.") add_friend = AddFriend.Field(description="Adds a friend to a user.") remove_friend = RemoveFriend.Field(description="Removes a friend from a user.") @@ -1417,6 +1466,8 @@ class Mutation(graphene.ObjectType): get_pending_friend_requests = GetPendingFriendRequests.Field( description="Get all pending friend requests for a user." ) + description="Get all pending friend requests for a user." + ) schema = graphene.Schema(query=Query, mutation=Mutation) From fbfb360412338633af4a4b8f1b858c1b630d6449 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Thu, 5 Mar 2026 12:01:32 -0500 Subject: [PATCH 20/55] Changed assets to use S3 buckets --- src/constants.json | 8 ++++---- src/utils/constants.py | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/constants.json b/src/constants.json index 24d32d5..5c7f65c 100644 --- a/src/constants.json +++ b/src/constants.json @@ -43,7 +43,7 @@ } ], "hours": [], - "image_url": "gyms/helen-newman.jpg", + "image_url": "/helen-newman.jpg", "latitude": 42.453188923853595, "longitude": -76.47730907608567, "name": "Helen Newman" @@ -59,7 +59,7 @@ } ], "hours": [], - "image_url": "gyms/morrison.jpeg", + "image_url": "/morrison.jpeg", "latitude": 42.45582093240726, "longitude": -76.47883902202813, "name": "Toni Morrison" @@ -90,7 +90,7 @@ } ], "hours": [], - "image_url": "gyms/noyes.jpg", + "image_url": "/noyes.jpg", "latitude": 42.44660528140398, "longitude": -76.48803891048553, "name": "Noyes" @@ -126,7 +126,7 @@ } ], "hours": [], - "image_url": "gyms/teagle.jpg", + "image_url": "/teagle.jpg", "latitude": 42.4459926380709, "longitude": -76.47915389837931, "name": "Teagle" diff --git a/src/utils/constants.py b/src/utils/constants.py index 1cf8664..b25222d 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -1,7 +1,7 @@ import os # URL for Uplift image assets -ASSET_BASE_URL = "https://raw.githubusercontent.com/cuappdev/assets/master/uplift/" +ASSET_BASE_URL = "https://appdev-upload.nyc3.cdn.digitaloceanspaces.com/uplift" # Base URL for Cornell Recreation Website BASE_URL = "https://scl.cornell.edu/recreation/" From ffb3042979dd32a4b2c6fb4c08d11061b68a6f4b Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Thu, 5 Mar 2026 15:15:36 -0500 Subject: [PATCH 21/55] Moved migration run from dockerfile to workflow file --- .github/workflows/deploy-dev.yml | 8 +++----- Dockerfile | 2 +- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index da074eb..e5a8eca 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -40,10 +40,8 @@ jobs: source tags export IMAGE_TAG=${{ steps.vars.outputs.sha_short }} cd docker-compose - docker stack rm the-stack - sleep 20s - sudo systemctl stop nginx + docker pull cornellappdev/uplift-dev:$IMAGE_TAG + # temporary container to run migrations + docker run --rm --env-file uplift.env cornellappdev/uplift-dev:$IMAGE_TAG flask --app migrations db upgrade sudo systemctl restart nginx docker stack deploy -c docker-compose.yml the-stack --with-registry-auth - sleep 60s - yes | docker system prune -a diff --git a/Dockerfile b/Dockerfile index 30de6de..ed8ae6e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,4 +6,4 @@ COPY . . ENV MAX_CONCURRENT_PIP=4 RUN pip3 install --upgrade pip RUN pip3 install --exists-action w -r requirements.txt -CMD flask --app migrations db upgrade && python3 app.py \ No newline at end of file +CMD python3 app.py \ No newline at end of file From 1ab36b4d376533123f03c7b76892c46d6013b27e Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Thu, 5 Mar 2026 15:21:16 -0500 Subject: [PATCH 22/55] mounted certs when running migrations --- .github/workflows/deploy-dev.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index e5a8eca..229b959 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -42,6 +42,6 @@ jobs: cd docker-compose docker pull cornellappdev/uplift-dev:$IMAGE_TAG # temporary container to run migrations - docker run --rm --env-file uplift.env cornellappdev/uplift-dev:$IMAGE_TAG flask --app migrations db upgrade + docker run --rm --env-file uplift.env -v ./certs:/usr/src/app/certs cornellappdev/uplift-dev:$IMAGE_TAG flask --app migrations db upgrade sudo systemctl restart nginx docker stack deploy -c docker-compose.yml the-stack --with-registry-auth From fcd572333f417afa184aaac7d1e80aa8c4dc1757 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:04:29 -0500 Subject: [PATCH 23/55] Try fixing migrations --- .../b09a4d8151d6_merge_diverging_branches.py | 24 +++++++++++++++++++ schema.graphql | 1 + 2 files changed, 25 insertions(+) create mode 100644 migrations/versions/b09a4d8151d6_merge_diverging_branches.py diff --git a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py new file mode 100644 index 0000000..7658b87 --- /dev/null +++ b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py @@ -0,0 +1,24 @@ +"""Merge diverging branches + +Revision ID: b09a4d8151d6 +Revises: 6fb4a21a1201, 48923aecacb0 +Create Date: 2026-03-05 17:15:46.737524 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'b09a4d8151d6' +down_revision = ('6fb4a21a1201', '48923aecacb0') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/schema.graphql b/schema.graphql index 54800e5..d9def62 100644 --- a/schema.graphql +++ b/schema.graphql @@ -224,6 +224,7 @@ type Mutation { logoutUser: LogoutUser refreshAccessToken: RefreshAccessToken createReport(createdAt: DateTime!, description: String!, gymId: Int!, issue: String!): CreateReport + deleteReport(reportId: Int!): Report deleteUser(userId: Int!): User createCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, fcmToken: String!, gyms: [String]!): CapacityReminder editCapacityReminder(daysOfWeek: [String]!, newCapacityThreshold: Int!, newGyms: [String]!, reminderId: Int!): CapacityReminder From 9e01dd4c26101f75df9c2cc6d652b8e429aef86e Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:27:13 -0500 Subject: [PATCH 24/55] Try fixing migrations --- migrations/versions/b09a4d8151d6_merge_diverging_branches.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py index 7658b87..74cd2b9 100644 --- a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py +++ b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py @@ -1,7 +1,7 @@ """Merge diverging branches Revision ID: b09a4d8151d6 -Revises: 6fb4a21a1201, 48923aecacb0 +Revises: 6b01a81bb92b, 48923aecacb0 Create Date: 2026-03-05 17:15:46.737524 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = 'b09a4d8151d6' -down_revision = ('6fb4a21a1201', '48923aecacb0') +down_revision = ('6b01a81bb92b', '48923aecacb0') branch_labels = None depends_on = None From 3e7ffb9b1938a751f067aee6027a01b003cbf790 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:37:12 -0500 Subject: [PATCH 25/55] Try fixing migrations 3 --- migrations/versions/31b1fa20772f_popular_times.py | 2 +- migrations/versions/b09a4d8151d6_merge_diverging_branches.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/migrations/versions/31b1fa20772f_popular_times.py b/migrations/versions/31b1fa20772f_popular_times.py index 0a982ea..6f50ab8 100644 --- a/migrations/versions/31b1fa20772f_popular_times.py +++ b/migrations/versions/31b1fa20772f_popular_times.py @@ -12,7 +12,7 @@ # revision identifiers, used by Alembic. revision = '31b1fa20772f' -down_revision = 'eb948c31a342' +down_revision = None branch_labels = None depends_on = None diff --git a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py index 74cd2b9..b2c3382 100644 --- a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py +++ b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py @@ -1,7 +1,7 @@ """Merge diverging branches Revision ID: b09a4d8151d6 -Revises: 6b01a81bb92b, 48923aecacb0 +Revises: eb948c31a342, 48923aecacb0 Create Date: 2026-03-05 17:15:46.737524 """ @@ -11,7 +11,7 @@ # revision identifiers, used by Alembic. revision = 'b09a4d8151d6' -down_revision = ('6b01a81bb92b', '48923aecacb0') +down_revision = ('eb948c31a342', '48923aecacb0') branch_labels = None depends_on = None From 3fefa5e16a76d4e55b3453d875c362f325e1f9b9 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:43:50 -0500 Subject: [PATCH 26/55] Try fixing migrations 4: remove pricetype error --- migrations/versions/eb948c31a342_create_gear_table.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/migrations/versions/eb948c31a342_create_gear_table.py b/migrations/versions/eb948c31a342_create_gear_table.py index e8e62dc..d48a499 100644 --- a/migrations/versions/eb948c31a342_create_gear_table.py +++ b/migrations/versions/eb948c31a342_create_gear_table.py @@ -27,7 +27,7 @@ def upgrade(): sa.Column("name", sa.String(), nullable=False), sa.Column("cost", sa.Float(), nullable=False), sa.Column("rate", sa.String(), nullable=True), - sa.Column("type", sa.Enum(PriceType), nullable=False), + sa.Column("type", sa.Enum(PriceType, create_type=False), nullable=False), ) def downgrade(): From f0cb96b065033c8f7fd9737dd79978ea9d56d83c Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:50:31 -0500 Subject: [PATCH 27/55] Try fixing migrations 5: remove pricetype error --- migrations/versions/eb948c31a342_create_gear_table.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/migrations/versions/eb948c31a342_create_gear_table.py b/migrations/versions/eb948c31a342_create_gear_table.py index d48a499..17ec182 100644 --- a/migrations/versions/eb948c31a342_create_gear_table.py +++ b/migrations/versions/eb948c31a342_create_gear_table.py @@ -7,9 +7,7 @@ """ from alembic import op import sqlalchemy as sa - -# Import the PriceType enum used in the model so the Enum can be created -from src.models.activity import PriceType +from sqlalchemy.dialects import postgresql # revision identifiers, used by Alembic. @@ -18,6 +16,9 @@ branch_labels = None depends_on = None +### Ensures alembic does not try to create enum +price_type_enum = postgresql.ENUM('rate', 'gear', name='pricetype', create_type=False) + def upgrade(): op.create_table( @@ -27,7 +28,7 @@ def upgrade(): sa.Column("name", sa.String(), nullable=False), sa.Column("cost", sa.Float(), nullable=False), sa.Column("rate", sa.String(), nullable=True), - sa.Column("type", sa.Enum(PriceType, create_type=False), nullable=False), + sa.Column("type", price_type_enum, nullable=False), ) def downgrade(): From aba9db780babd46a75a47c378f26dee41315cd08 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 00:57:02 -0500 Subject: [PATCH 28/55] Try fixing migrations 6: gear table fix --- .../eb948c31a342_create_gear_table.py | 25 ++++++++++++------- 1 file changed, 16 insertions(+), 9 deletions(-) diff --git a/migrations/versions/eb948c31a342_create_gear_table.py b/migrations/versions/eb948c31a342_create_gear_table.py index 17ec182..28a8392 100644 --- a/migrations/versions/eb948c31a342_create_gear_table.py +++ b/migrations/versions/eb948c31a342_create_gear_table.py @@ -21,15 +21,22 @@ def upgrade(): - op.create_table( - "gear", - sa.Column("id", sa.Integer(), primary_key=True), - sa.Column("activity_id", sa.Integer(), nullable=False), - sa.Column("name", sa.String(), nullable=False), - sa.Column("cost", sa.Float(), nullable=False), - sa.Column("rate", sa.String(), nullable=True), - sa.Column("type", price_type_enum, nullable=False), - ) + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'gear') THEN + CREATE TABLE gear ( + id SERIAL NOT NULL, + activity_id INTEGER NOT NULL, + name VARCHAR NOT NULL, + cost FLOAT NOT NULL, + rate VARCHAR, + type pricetype NOT NULL, + PRIMARY KEY (id) + ); + END IF; + END $$; + """) def downgrade(): op.drop_table("gear") From 4304cc640ec31879cc61f3c150749d5bb76d9d0b Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 01:48:01 -0500 Subject: [PATCH 29/55] Migration: backfill last_streak in users to be nullable False --- ...573_make_last_streak_non_null_default_0.py | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 migrations/versions/46ba03c28573_make_last_streak_non_null_default_0.py diff --git a/migrations/versions/46ba03c28573_make_last_streak_non_null_default_0.py b/migrations/versions/46ba03c28573_make_last_streak_non_null_default_0.py new file mode 100644 index 0000000..651e0ce --- /dev/null +++ b/migrations/versions/46ba03c28573_make_last_streak_non_null_default_0.py @@ -0,0 +1,33 @@ +"""make last_streak non_null default 0 + +Revision ID: 46ba03c28573 +Revises: b09a4d8151d6 +Create Date: 2026-03-06 01:47:07.348774 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '46ba03c28573' +down_revision = 'b09a4d8151d6' +branch_labels = None +depends_on = None + + +def upgrade(): + op.execute("UPDATE users SET last_streak = 0 WHERE last_streak IS NULL") + op.alter_column('users', 'last_streak', + existing_type=sa.INTEGER(), + nullable=False, + server_default=sa.text('0') + ) + + +def downgrade(): + op.alter_column('users', 'last_streak', + existing_type=sa.INTEGER(), + nullable=True, + server_default=None + ) From 4caf1f5f757e674811c699ea9060b3623f538111 Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 01:53:43 -0500 Subject: [PATCH 30/55] Update workflow: prune unused images --- .github/workflows/deploy-dev.yml | 1 + .github/workflows/deploy-prod.yml | 9 ++++----- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index 229b959..a062369 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -45,3 +45,4 @@ jobs: docker run --rm --env-file uplift.env -v ./certs:/usr/src/app/certs cornellappdev/uplift-dev:$IMAGE_TAG flask --app migrations db upgrade sudo systemctl restart nginx docker stack deploy -c docker-compose.yml the-stack --with-registry-auth + docker system prune -f diff --git a/.github/workflows/deploy-prod.yml b/.github/workflows/deploy-prod.yml index 623ea56..552b1cb 100644 --- a/.github/workflows/deploy-prod.yml +++ b/.github/workflows/deploy-prod.yml @@ -39,11 +39,10 @@ jobs: touch tags source tags export IMAGE_TAG=${{ steps.vars.outputs.sha_short }} - echo "export IMAGE_TAG=${IMAGE_TAG}" > tags cd docker-compose - docker stack rm the-stack - sleep 20s - sudo systemctl stop nginx + docker pull cornellappdev/uplift-prod:$IMAGE_TAG + # temporary container to run migrations + docker run --rm --env-file uplift.env -v ./certs:/usr/src/app/certs cornellappdev/uplift-prod:$IMAGE_TAG flask --app migrations db upgrade sudo systemctl restart nginx docker stack deploy -c docker-compose.yml the-stack --with-registry-auth - yes | docker system prune -a \ No newline at end of file + docker system prune -f \ No newline at end of file From 43c6267f4d71fd705576bbbd4ec78ebfa465475b Mon Sep 17 00:00:00 2001 From: Joshua Dirga Date: Fri, 6 Mar 2026 13:44:43 -0500 Subject: [PATCH 31/55] Add health endpoint --- app_factory.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app_factory.py b/app_factory.py index 57fdcd7..856c557 100644 --- a/app_factory.py +++ b/app_factory.py @@ -3,7 +3,8 @@ from flask_jwt_extended import JWTManager from src.utils.constants import SERVICE_ACCOUNT_PATH, JWT_SECRET_KEY from datetime import datetime -from flask import Flask, render_template +from flask import Flask, jsonify, render_template +from sqlalchemy import text from graphene import Schema from graphql.utils import schema_printer from src.database import db_session, init_db @@ -89,6 +90,14 @@ def check_if_token_revoked(jwt_header, jwt_payload: dict) -> bool: def index(): return render_template("index.html") + @app.route("/health") + def health_check(): + try: + db_session.execute(text("SELECT 1")) + return jsonify({"status": "healthy", "database": "connected"}), 200 + except Exception: + return jsonify({"status": "unhealthy", "database": "disconnected"}), 503 + app.add_url_rule("/graphql", view_func=GraphQLView.as_view("graphql", schema=schema, graphiql=True)) @app.teardown_appcontext From e1977e0235e702bd5984b8725af8bc99ca3e07f6 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sat, 7 Mar 2026 16:11:08 -0500 Subject: [PATCH 32/55] initial commit --- src/schema.py | 44 ++++++++++++++++++++++---------------------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/src/schema.py b/src/schema.py index 428829a..68a2c71 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,3 +1,5 @@ +import base64 + import graphene import os from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required @@ -68,6 +70,7 @@ def to_local_time(dt): # Convert to local timezone (server-local) return dt_utc.astimezone() + def goal_at(goal_history, window_start_date): """ Determine the workout goal for a given window start date from the goal history. @@ -83,6 +86,7 @@ def goal_at(goal_history, window_start_date): return goal_history[-1][0] + # MARK: - Gym @@ -248,6 +252,7 @@ class Meta: def resolve_effective_at(self, info): return to_local_time(self.effective_at) + # MARK: - User @@ -258,8 +263,7 @@ class Meta: friendships = graphene.List(lambda: Friendship) friends = graphene.List(lambda: User) total_gym_days = graphene.Int( - required=True, - description="Get the total number of gym days (unique workout days) for user." + required=True, description="Get the total number of gym days (unique workout days) for user." ) streak_start = graphene.Date( description="The start date of the most recent active streak, up until the current date." @@ -269,7 +273,9 @@ def resolve_total_gym_days(self, info): return ( Workout.get_query(info) .filter(WorkoutModel.user_id == self.id) - .with_entities(func.count(func.distinct(cast(WorkoutModel.workout_time, Date)))) # We cast the datetiem object as a Date object to get the unique days + .with_entities( + func.count(func.distinct(cast(WorkoutModel.workout_time, Date))) + ) # We cast the datetiem object as a Date object to get the unique days .scalar() ) @@ -290,7 +296,7 @@ def resolve_active_streak(self, info): if not workout_date_rows: return 0 - workout_dates = [row[0] for row in workout_date_rows] + workout_dates = [row[0] for row in workout_date_rows] goal_hist = ( db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) @@ -316,7 +322,7 @@ def resolve_active_streak(self, info): day_iterator = day_pointer count_in_window = 0 - + while day_iterator < total_workout_days and workout_dates[day_iterator] >= window_start: count_in_window += 1 day_iterator += 1 @@ -357,10 +363,7 @@ def resolve_streak_start(self, info): return None goal_hist = ( - db_session.query( - UserWorkoutGoalHistoryModel.workout_goal, - UserWorkoutGoalHistoryModel.effective_at, - ) + db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) .filter(UserWorkoutGoalHistoryModel.user_id == user.id) .order_by(UserWorkoutGoalHistoryModel.effective_at.desc()) .all() @@ -450,7 +453,7 @@ def resolve_max_streak(self, info): if not workout_date_rows: return 0 - workout_dates = [row[0] for row in workout_date_rows] + workout_dates = [row[0] for row in workout_date_rows] goal_hist = ( db_session.query(UserWorkoutGoalHistoryModel.workout_goal, UserWorkoutGoalHistoryModel.effective_at) @@ -484,7 +487,7 @@ def resolve_max_streak(self, info): count_in_window += 1 day_iterator += 1 - goal_days = goal_at(goal_hist, window_start) + goal_days = goal_at(goal_hist, window_start) if count_in_window == 0: max_met_goal = max(max_met_goal, run_met_goal) @@ -554,6 +557,7 @@ def resolve_friend(self, info): def resolve_accepted_at(self, info): return to_local_time(self.accepted_at) + # MARK: - Giveaway @@ -703,7 +707,7 @@ def resolve_get_weekly_workout_days(self, info, id): def resolve_get_all_reports(self, info): query = ReportModel.query.all() - return query + return query def resolve_get_hourly_average_capacities_by_facility_id(self, info, facility_id): valid_facility_ids = [14492437, 8500985, 7169406, 10055021, 2323580, 16099753, 15446768, 12572681] @@ -829,10 +833,11 @@ def mutate(self, info, name, net_id, email, encoded_image=None): if encoded_image: upload_url = os.getenv("DIGITAL_OCEAN_URL") - payload = {"bucket": os.getenv("BUCKET_NAME"), "image": encoded_image} # Base64-encoded image string - headers = {"Content-Type": "application/json"} + image_bytes = base64.b64decode(encoded_image) + files = {"image": ("profile.png", image_bytes, "image/png")} + data = {"bucket": os.getenv("BUCKET_NAME")} try: - response = requests.post(upload_url, json=payload, headers=headers) + response = requests.post(upload_url, files=files, data=data) response.raise_for_status() json_response = response.json() final_photo_url = json_response.get("data") @@ -999,8 +1004,7 @@ class SetWorkoutGoals(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True, description="The ID of the user.") workout_goal = graphene.Int( - required=True, - description="The new workout goal for the user in terms of number of days per week.", + required=True, description="The new workout goal for the user in terms of number of days per week." ) Output = User @@ -1043,11 +1047,7 @@ def mutate(self, info, user_id, workout_goal): user.workout_goal = workout_goal db_session.add( - UserWorkoutGoalHistoryModel( - user_id=user.id, - workout_goal=workout_goal, - effective_at=effective_at, - ) + UserWorkoutGoalHistoryModel(user_id=user.id, workout_goal=workout_goal, effective_at=effective_at) ) db_session.commit() From 6b0fa9e4ec69d5001bcb95888e751df418eaf737 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Sat, 7 Mar 2026 16:15:17 -0500 Subject: [PATCH 33/55] fix upload --- src/schema.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/schema.py b/src/schema.py index 68a2c71..95d7bf2 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,6 +1,5 @@ -import base64 - import graphene +import base64 import os from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required from functools import wraps From 9f19fbd26b60255f2a504def86e7073df0a7a419 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 9 Mar 2026 18:36:02 -0400 Subject: [PATCH 34/55] Remove user field from WorkoutGoalHistory type --- schema.graphql | 1 - 1 file changed, 1 deletion(-) diff --git a/schema.graphql b/schema.graphql index d9def62..ff98f1d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -335,5 +335,4 @@ type WorkoutGoalHistory { userId: Int! workoutGoal: Int! effectiveAt: DateTime! - user: User } From ee67ebdce23682ac258923a3fd234b42acc78012 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 9 Mar 2026 18:59:25 -0400 Subject: [PATCH 35/55] Make lastStreak field DateTime object, modify timezone info --- schema.graphql | 4 +--- src/schema.py | 14 ++++++++------ 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/schema.graphql b/schema.graphql index ff98f1d..c14b1bb 100644 --- a/schema.graphql +++ b/schema.graphql @@ -85,8 +85,6 @@ type CreateReport { report: Report } -scalar Date - scalar DateTime enum DayOfWeekEnum { @@ -319,7 +317,7 @@ type User { friendships: [Friendship] friends: [User] totalGymDays: Int! - streakStart: Date + streakStart: DateTime } type Workout { diff --git a/src/schema.py b/src/schema.py index 95d7bf2..ba5651e 100644 --- a/src/schema.py +++ b/src/schema.py @@ -3,7 +3,7 @@ import os from flask_jwt_extended import create_access_token, create_refresh_token, get_jwt_identity, get_jwt, jwt_required from functools import wraps -from datetime import datetime, timedelta, timezone +from datetime import datetime, timedelta, time, timezone from graphene_sqlalchemy import SQLAlchemyObjectType from graphql import GraphQLError from src.models.capacity import Capacity as CapacityModel @@ -30,8 +30,10 @@ import requests from firebase_admin import messaging import logging +from zoneinfo import ZoneInfo from sqlalchemy import func, cast, Date +local_tz = ZoneInfo("America/New_York") def resolve_enum_value(entry): """Return the raw value for Enum objects while leaving plain strings untouched.""" @@ -67,7 +69,7 @@ def to_local_time(dt): return None # Convert to local timezone (server-local) - return dt_utc.astimezone() + return dt_utc.astimezone(local_tz) def goal_at(goal_history, window_start_date): @@ -264,8 +266,8 @@ class Meta: total_gym_days = graphene.Int( required=True, description="Get the total number of gym days (unique workout days) for user." ) - streak_start = graphene.Date( - description="The start date of the most recent active streak, up until the current date." + streak_start = graphene.DateTime( + description="The start datetime of the most recent active streak (midnight of the day in local timezone), up until the current date." ) def resolve_total_gym_days(self, info): @@ -432,8 +434,8 @@ def goal_for_window_start(ws_date): return None last_streak_start_date = workout_dates[idx_last_streak_start] - - return last_streak_start_date + local_midnight = datetime.combine(last_streak_start_date, time.min, tzinfo=local_tz) + return local_midnight def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() From e87b6e901d92b8a6ea89a86aed1db137f29558a9 Mon Sep 17 00:00:00 2001 From: Chimdi Ejiogu Date: Mon, 9 Mar 2026 19:01:46 -0400 Subject: [PATCH 36/55] Add workout history field to User type --- schema.graphql | 2 ++ src/schema.py | 5 +++++ 2 files changed, 7 insertions(+) diff --git a/schema.graphql b/schema.graphql index c14b1bb..70d5f9d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -318,6 +318,7 @@ type User { friends: [User] totalGymDays: Int! streakStart: DateTime + workoutHistory: [Workout] } type Workout { @@ -333,4 +334,5 @@ type WorkoutGoalHistory { userId: Int! workoutGoal: Int! effectiveAt: DateTime! + user: User } diff --git a/src/schema.py b/src/schema.py index ba5651e..d971cd1 100644 --- a/src/schema.py +++ b/src/schema.py @@ -269,6 +269,11 @@ class Meta: streak_start = graphene.DateTime( description="The start datetime of the most recent active streak (midnight of the day in local timezone), up until the current date." ) + workout_history = graphene.List(lambda: Workout) + + def resolve_workout_history(self, info): + query = Workout.get_query(info).filter(WorkoutModel.user_id == self.id).order_by(WorkoutModel.workout_time.desc()) + return query.all() def resolve_total_gym_days(self, info): return ( From 1d3b4fac5a232b090eb40ad9c7710b55895ebc73 Mon Sep 17 00:00:00 2001 From: Sophie Strausberg Date: Wed, 18 Mar 2026 17:51:31 -0400 Subject: [PATCH 37/55] logs --- src/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.py b/src/schema.py index d971cd1..383127f 100644 --- a/src/schema.py +++ b/src/schema.py @@ -851,7 +851,7 @@ def mutate(self, info, name, net_id, email, encoded_image=None): raise GraphQLError("No URL returned from upload service.") except requests.exceptions.RequestException as e: print(f"Request failed: {e}") - raise GraphQLError("Failed to upload photo.") + raise GraphQLError(f"Failed to upload photo: {e}") new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url) db_session.add(new_user) From d506b5597264dfdf1dede498a554cf9fa6cbc096 Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Fri, 20 Mar 2026 12:11:08 -0400 Subject: [PATCH 38/55] Added log --- src/schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schema.py b/src/schema.py index 428829a..4aa8e54 100644 --- a/src/schema.py +++ b/src/schema.py @@ -616,7 +616,7 @@ class CapacityReminder(SQLAlchemyObjectType): class Meta: model = CapacityReminderModel exclude_fields = ("fcm_token",) - + # MARK: - Query @@ -829,6 +829,8 @@ def mutate(self, info, name, net_id, email, encoded_image=None): if encoded_image: upload_url = os.getenv("DIGITAL_OCEAN_URL") + if not upload_url: + raise GraphQLError("Upload URL not configured.") payload = {"bucket": os.getenv("BUCKET_NAME"), "image": encoded_image} # Base64-encoded image string headers = {"Content-Type": "application/json"} try: @@ -1052,8 +1054,6 @@ def mutate(self, info, user_id, workout_goal): db_session.commit() return user - - class logWorkout(graphene.Mutation): class Arguments: workout_time = graphene.DateTime(required=True) From 67567c5c5d24bad447173dd0fcc5749dd78cae8c Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Sun, 22 Mar 2026 14:27:39 -0400 Subject: [PATCH 39/55] Changes to how users are added and deleted --- requirements.txt | Bin 1564 -> 5070 bytes src/models/user.py | 1 + src/models/workout.py | 2 ++ src/schema.py | 66 ++++++++++++++++++++++++++++++------------ 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/requirements.txt b/requirements.txt index c6219de5411bc83656beccc834a62b8e32665056..7e8c8ce6f259399009d094a931c687f581e4c0d0 100644 GIT binary patch literal 5070 zcmZ{oTTdHT6ot=orT&LXeXPd8mq4f#sZrEcii(tmN>QI88-p=md*U&n@Z;OA@2uHA zW3Xk}fX_brvi91S;lF>!=`LNSMVh2}x=3&J>Ze{h()%=hpT0}uwA4wHR_RST*7yA^ zGi+rBS*DVPAJR;6oAgt<*5@YGy7O~-rI)|IPWQ6WlZ}8|yf>h?E%)BUTb z?;j=YyT7EX{JKo9<;6IAG11T0(s-f2j?zfp4%0y14W(_M@5fp0NT0`gpX%FTEAvM3 zKkDz9UT<|)e6UqV?QiF4tvCXekvtmZh~K4;>0Gw2rDv*0z;L}qrk^o1He=m)ERV9a zkYs2xhPDP`VQC;wdu#Ex%|Q10VnHvz2W>tsGCxAXJ~?7eX5tr-pxgSvg6xeICH&iL&+*ea zj`Jp;SBeKr7P@n%Ogv@JLMs@CJx&9E{4_?kNStVa3FBM1%9C}&kVTxIkqYW=0gD*e92 z?a%Z__R-l1AG;Plq|fOu#q?|XM@U^NCfG2|w}@(=&s928^v(`@l+ER(rfx+~o%)opof zID`9d(7`rhB2~?OI0stA%f51aSPR|jdK*zcV`VNpw`a*p(w-t__aNUze_`*Tn=8d> zExVOba=CkxXy~WYJ;=D?;N{^vQ6bdYF0St6`*7{rjC*6*C~Wc!*?)Jf3^47=ymp^? z=_i%jC^otclK~bi^hr-}{~`bTZ<{Smi^@Xxoc&r1^^_=+@g!G3cuRkyZ^4jmL}brC z?n+eh@vFR}v+if}bInM<#Yd zEZe8K?z(E32*v}i(G`;vnWd~m_wihsEBke3cg!2}2(PEvbKhVZP-o5;`(TsLmzmq} zCL#>9EO{fp;AHeiPk4L%LEkUajjXr}xC4ZZo6Kj>0YCSc$W`cOUs}qO;Ff&_-9e7s zyt$FYb2&0o%Qx~!owK#DjabeY9kp7Ran^LFX%d?rRyc7iXS#o@+rbDtwU?Z%b;e$I zuHUa@HFDs1s2u8;J@GpKb|?0&(nFRjsL&m}!R(hlTy3zopC`J#Wy-_Md!c7J(C!mx-`B^n&)YbMWjZGuVV`Ff!$PBq z1T&p&ql>2T;S@BT52D96*HfK)#zuMY)(1OdL$?RWhZQQ*{enIT?@NZeI;jAoTCyAV ze8%Lj^!84CyVZqFEDjYN=#(y8>G!*BT^YqP>x$e>s9Hw17r;L7vqfmBW=Z&4gd2 zSplA)Q@VtAINRRnduwJxZ(4NmuT+06d&Y(5J9;~qAOnNU5OPm$mda?HmYLf!ecF~W{wQnS_zD-mE3!yTW<+5VDDf`-mOaRw?@~U|6C5A*$oPJF z=H_lSARlb*?pW@J-Y9}g#2d^-_9tS<3VT7rp8Kt_N`Cqr-1=J8m~rq z;i;JlZ`!z!!kW97Z%5w`?u4e2d8WRfRv+aV6RSL#_$eba88K!){UD|ea|NyNr0~Cy zMDe@3&%t*#B>FgvfD_;x-j|FW+Vb(eYzN%u$&?EA^CgbacCw9m5)Q+f- zV+!&FJk0pH|CuGOm2Wy-OBerdB6Bf|@CgLa=sx%$iAENqcc81VqSq7| literal 1564 zcmY*ZO>g5i5WVwP6zFjwh5E49S-?PZXbThxu)A5H=xH&MD48)uG9=~1`SpE6Ic|EA zIGm3+Gw+d%nmgU^_f;v&TR~Pwx7*5Uzi(iuMWKF~boz8i$;U4@H~2p+UD><&reDbW zL7i^aPwCGkZhsrcQEhf4tBy8ZFJ*h%)K#PEjz~({(yeM>*~wqkH0`gUg8y`gjH4}E zK@sWBG#fSmiJ+ADwr)xQ6`i6b>2Wd9Exz6F+j3hrqBGPV5pY{J9eg4#B$}v1PyIn9 z=L|Be33?R}uaQtzyK-07t?0+;#Mf{T{ei-u(mDiO;TTmatP3+4{iXun)Bw^O%_VpE z9dt}acU-e6KZ}uK8uU1>XDLQ)lyU5g0yQT4hoOiQe#^~zDDJ*K0_d<9E>M*fL&Z?7kDMbtP%~B zVBexEDRhbhSv2Yv<`gVXY5*C)6;MI^rR@{ddHKS;G6!3XqZSWzba^ibc%X1x{3DH* zF;rp(M-HHeaW5-xWa!(E*8j~1rM5_E-GKhUy9y+f!jl~+LxK!&3K0b*t{UMDr4jsF zRe~Kd4=@W%Yo0acgK1^I;AR~&Dg@!+Q^C|fvJY#z*$N-ez4KFDblOtLv%|WDPiF-5 zDguGbgYpXss1s$1FlqgnqQ&C?ZgKg1DrU!&%|R?xe@+Ky3k+OAqzJ89VDDxOI=M`5 z?4FHpaFEb-Bfg&hxqGdM68c#{{5AE-GUqOB9`}^L~TJ<=+IN@ z!B@x-{!!|6I ScGN+26e09s)|NNGbNCk>*4#e; diff --git a/src/models/user.py b/src/models/user.py index 7cce2cf..42fbde8 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -48,6 +48,7 @@ class User(Base): foreign_keys="Friendship.friend_id", back_populates="friend") + workouts = relationship("Workout", cascade="all, delete-orphan", back_populates="user") def add_friend(self, friend): # Check if friendship already exists existing = Friendship.query.filter( diff --git a/src/models/workout.py b/src/models/workout.py index 1b27940..2b5224e 100644 --- a/src/models/workout.py +++ b/src/models/workout.py @@ -21,3 +21,5 @@ class Workout(Base): workout_time = Column(DateTime(timezone=True), nullable=False, server_default=text("CURRENT_TIMESTAMP")) # should this be nullable? user_id = Column(Integer, ForeignKey("users.id"), nullable=False) facility_id = Column(Integer, ForeignKey("facility.id"), nullable=False) + + user = relationship("User", back_populates='workouts') diff --git a/src/schema.py b/src/schema.py index 4aa8e54..1453e02 100644 --- a/src/schema.py +++ b/src/schema.py @@ -7,6 +7,7 @@ from graphql import GraphQLError from src.models.capacity import Capacity as CapacityModel from src.models.capacity_reminder import CapacityReminder as CapacityReminderModel +from src.models.weekly_challenge import WeeklyChallenge as WeeklyChallengeModel from src.models.facility import Facility as FacilityModel from src.models.gym import Gym as GymModel from src.models.openhours import OpenHours as OpenHoursModel @@ -30,6 +31,9 @@ from firebase_admin import messaging import logging from sqlalchemy import func, cast, Date +import boto3 +from botocore.exceptions import ClientError +import base64 def resolve_enum_value(entry): @@ -823,27 +827,35 @@ class Arguments: def mutate(self, info, name, net_id, email, encoded_image=None): # Check if a user with the given NetID already exists existing_user = db_session.query(UserModel).filter(UserModel.net_id == net_id).first() - final_photo_url = None if existing_user: raise GraphQLError("NetID already exists.") - if encoded_image: - upload_url = os.getenv("DIGITAL_OCEAN_URL") - if not upload_url: - raise GraphQLError("Upload URL not configured.") - payload = {"bucket": os.getenv("BUCKET_NAME"), "image": encoded_image} # Base64-encoded image string - headers = {"Content-Type": "application/json"} - try: - response = requests.post(upload_url, json=payload, headers=headers) - response.raise_for_status() - json_response = response.json() - final_photo_url = json_response.get("data") - if not final_photo_url: - raise GraphQLError("No URL returned from upload service.") - except requests.exceptions.RequestException as e: - print(f"Request failed: {e}") - raise GraphQLError("Failed to upload photo.") + s3 = boto3.client('s3', + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + ) + + # Decode the base64 image + image_data = base64.b64decode(encoded_image) + # Upload to Spaces + try: + response = s3.put_object( + Bucket="appdev-upload", + Key=f"uplift-dev/user-profile/{net_id}-profile.png", + Body=image_data, + ContentType="image/png", + ACL="public-read" + ) + + key = "uplift-dev/photo.jpg" + final_photo_url = f"https://nyc3.digitaloceanspaces.com/uplift-dev/user-profile/{net_id}-profile.png" + + + except ClientError as e: + print("Upload error:", e) + new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url) db_session.add(new_user) db_session.commit() @@ -1007,7 +1019,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: @@ -1054,6 +1066,7 @@ def mutate(self, info, user_id, workout_goal): db_session.commit() return user + class logWorkout(graphene.Mutation): class Arguments: workout_time = graphene.DateTime(required=True) @@ -1062,7 +1075,7 @@ class Arguments: Output = Workout - @jwt_required() + # @jwt_required() def mutate(self, info, workout_time, user_id, facility_id): if not workout_time: raise GraphQLError("Workout time is required.") @@ -1153,6 +1166,21 @@ def mutate(self, info, user_id): user = User.get_query(info).filter(UserModel.id == user_id).first() if not user: raise GraphQLError("User with given ID does not exist.") + + s3 = boto3.client('s3', + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + ) + + try: + s3.delete_object( + Bucket="appdev-upload", + Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", + ) + except ClientError as e: + print("Upload error:", e) + db_session.delete(user) db_session.commit() return user From 8700d0685b812bb1c3b036684727be62ca282f3e Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Sun, 22 Mar 2026 14:46:23 -0400 Subject: [PATCH 40/55] Minor syntax fix --- src/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schema.py b/src/schema.py index 1453e02..bbdf863 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1019,7 +1019,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: @@ -1075,7 +1075,7 @@ class Arguments: Output = Workout - # @jwt_required() + @jwt_required() def mutate(self, info, workout_time, user_id, facility_id): if not workout_time: raise GraphQLError("Workout time is required.") From b42a0cc83f9d58ba0000aee4785a48f1e265dcac Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Sun, 22 Mar 2026 19:10:56 -0400 Subject: [PATCH 41/55] Fixed minor logging errors and profile picture upload checks --- src/schema.py | 56 ++++++++++++++++++++++++++++----------------------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/src/schema.py b/src/schema.py index 8f4d047..0bc9380 100644 --- a/src/schema.py +++ b/src/schema.py @@ -846,25 +846,29 @@ def mutate(self, info, name, net_id, email, encoded_image=None): aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") ) - # Decode the base64 image - image_data = base64.b64decode(encoded_image) + final_photo_url = None + if encoded_image: + # Decode the base64 image + image_data = base64.b64decode(encoded_image) - # Upload to Spaces - try: - response = s3.put_object( - Bucket="appdev-upload", - Key=f"uplift-dev/user-profile/{net_id}-profile.png", - Body=image_data, - ContentType="image/png", - ACL="public-read" - ) - - key = "uplift-dev/photo.jpg" - final_photo_url = f"https://nyc3.digitaloceanspaces.com/uplift-dev/user-profile/{net_id}-profile.png" - - - except ClientError as e: - print("Upload error:", e) + # Upload to Spaces + try: + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{net_id}-profile.png" + region = "nyc3" + + s3.put_object( + Bucket=bucket, + Key=path, + Body=image_data, + ContentType="image/png", + ACL="public-read" + ) + + final_photo_url = f"https://{bucket}.{region}.digitaloceanspaces.com/{path}" + except ClientError as e: + print("Upload error:", e) + raise GraphQLError("Error uploading user profile picture.") new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url) db_session.add(new_user) @@ -1178,13 +1182,15 @@ def mutate(self, info, user_id): aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") ) - try: - s3.delete_object( - Bucket="appdev-upload", - Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", - ) - except ClientError as e: - print("Upload error:", e) + if user.encoded_image: + try: + s3.delete_object( + Bucket="appdev-upload", + Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", + ) + except ClientError as e: + print("Delete error:", e) + raise GraphQLError("Error deleting user profile picture") db_session.delete(user) db_session.commit() From 9aaad161c00566dcf23968fd50fac58124afe41b Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Tue, 24 Mar 2026 13:49:13 -0400 Subject: [PATCH 42/55] Updated EditUser mutation and added auth checks --- src/schema.py | 76 +++++++++++++++++++++++++++------------------------ 1 file changed, 40 insertions(+), 36 deletions(-) diff --git a/src/schema.py b/src/schema.py index 0bc9380..990054b 100644 --- a/src/schema.py +++ b/src/schema.py @@ -8,7 +8,6 @@ from graphql import GraphQLError from src.models.capacity import Capacity as CapacityModel from src.models.capacity_reminder import CapacityReminder as CapacityReminderModel -from src.models.weekly_challenge import WeeklyChallenge as WeeklyChallengeModel from src.models.facility import Facility as FacilityModel from src.models.gym import Gym as GymModel from src.models.openhours import OpenHours as OpenHoursModel @@ -35,7 +34,6 @@ from sqlalchemy import func, cast, Date import boto3 from botocore.exceptions import ClientError -import base64 local_tz = ZoneInfo("America/New_York") @@ -840,14 +838,15 @@ def mutate(self, info, name, net_id, email, encoded_image=None): if existing_user: raise GraphQLError("NetID already exists.") - s3 = boto3.client('s3', - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), - aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") - ) - final_photo_url = None if encoded_image: + + s3 = boto3.client('s3', + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + ) + # Decode the base64 image image_data = base64.b64decode(encoded_image) @@ -877,49 +876,53 @@ def mutate(self, info, name, net_id, email, encoded_image=None): return new_user -class EditUser(graphene.Mutation): +class EditUserById(graphene.Mutation): class Arguments: + user_id = graphene.String(required=True) name = graphene.String(required=False) - net_id = graphene.String(required=True) email = graphene.String(required=False) encoded_image = graphene.String(required=False) Output = User - def mutate(self, info, net_id, name=None, email=None, encoded_image=None): - existing_user = db_session.query(UserModel).filter(UserModel.net_id == net_id).first() + @jwt_required() + def mutate(self, info, user_id, name=None, email=None, encoded_image=None): + existing_user = db_session.query(UserModel).filter(UserModel.id == user_id).first() + if not existing_user: - raise GraphQLError("User with given net id does not exist.") - + raise GraphQLError("User with given id does not exist.") if name is not None: existing_user.name = name if email is not None: existing_user.email = email if encoded_image is not None: - upload_url = os.getenv("DIGITAL_OCEAN_URL") # Base URL for upload endpoint - if not upload_url: - raise GraphQLError("Upload URL not configured.") - - payload = { - "bucket": os.getenv("BUCKET_NAME", "DEV_BUCKET"), - "image": encoded_image, # Base64-encoded image string - } - headers = {"Content-Type": "application/json"} - - print(f"Uploading image with payload: {payload}") + final_photo_url = None + s3 = boto3.client('s3', + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + ) + + image_data = base64.b64decode(encoded_image) try: - response = requests.post(upload_url, json=payload, headers=headers) - response.raise_for_status() - json_response = response.json() - print(f"Upload API response: {json_response}") - final_photo_url = json_response.get("data") - if not final_photo_url: - raise GraphQLError("No URL returned from upload service.") + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{net_id}-profile.png" + region = "nyc3" + + s3.put_object( + Bucket=bucket, + Key=path, + Body=image_data, + ContentType="image/png", + ACL="public-read" + ) + + final_photo_url = f"https://{bucket}.{region}.digitaloceanspaces.com/{path}" existing_user.encoded_image = final_photo_url - except requests.exceptions.RequestException as e: - print(f"Request failed: {e}") - raise GraphQLError("Failed to upload photo.") + except ClientError as e: + print("Upload error:", e) + raise GraphQLError("Error adding new user profile picture.") db_session.commit() return existing_user @@ -1170,6 +1173,7 @@ class Arguments: Output = User + @jwt_required() def mutate(self, info, user_id): # Check if user exists user = User.get_query(info).filter(UserModel.id == user_id).first() @@ -1470,7 +1474,7 @@ def mutate(self, info, user_id): class Mutation(graphene.ObjectType): create_giveaway = CreateGiveaway.Field(description="Creates a new giveaway.") create_user = CreateUser.Field(description="Creates a new user.") - edit_user = EditUser.Field(description="Edit a new user.") + edit_user = EditUserById.Field(description="Edit a new user by id.") 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.") From e79568150687ce521a9675a4b5cef638dd2f73c5 Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Tue, 24 Mar 2026 14:08:04 -0400 Subject: [PATCH 43/55] Minor bug fixes --- src/schema.py | 32 ++++++++++++++++++++++++-------- 1 file changed, 24 insertions(+), 8 deletions(-) diff --git a/src/schema.py b/src/schema.py index 990054b..3446e4d 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,3 +1,5 @@ +import binascii + import graphene import base64 import os @@ -839,18 +841,21 @@ def mutate(self, info, name, net_id, email, encoded_image=None): raise GraphQLError("NetID already exists.") final_photo_url = None + if encoded_image: - s3 = boto3.client('s3', + s3 = boto3.client( + "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") ) - # Decode the base64 image - image_data = base64.b64decode(encoded_image) + try: + image_data = base64.b64decode(encoded_image, validate=True) + except (binascii.Error, ValueError) as err: + raise GraphQLError("Invalid profile image encoding.") - # Upload to Spaces try: bucket = "appdev-upload" path = f"uplift-dev/user-profile/{net_id}-profile.png" @@ -891,23 +896,29 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): if not existing_user: raise GraphQLError("User with given id does not exist.") + if get_jwt_identity() != user_id: + raise GraphQLError("Unauthorized operation") if name is not None: existing_user.name = name if email is not None: existing_user.email = email if encoded_image is not None: final_photo_url = None - s3 = boto3.client('s3', + s3 = boto3.client( + "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") ) - image_data = base64.b64decode(encoded_image) + try: + image_data = base64.b64decode(encoded_image, validate=True) + except (binascii.Error, ValueError) as err: + raise GraphQLError("Invalid profile image encoding.") try: bucket = "appdev-upload" - path = f"uplift-dev/user-profile/{net_id}-profile.png" + path = f"uplift-dev/user-profile/{existing_user.net_id}-profile.png" region = "nyc3" s3.put_object( @@ -1177,10 +1188,15 @@ class Arguments: def mutate(self, info, user_id): # Check if user exists user = User.get_query(info).filter(UserModel.id == user_id).first() + if not user: raise GraphQLError("User with given ID does not exist.") + + if get_jwt_identity() != user_id: + raise GraphQLError("Unauthorized operation") - s3 = boto3.client('s3', + s3 = boto3.client( + "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") From 6fbd1bcccdc1b72eb26a802907b85adddd8a5856 Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Tue, 24 Mar 2026 14:19:29 -0400 Subject: [PATCH 44/55] Minor auth bug fix --- src/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schema.py b/src/schema.py index 3446e4d..a6a8952 100644 --- a/src/schema.py +++ b/src/schema.py @@ -896,7 +896,7 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): if not existing_user: raise GraphQLError("User with given id does not exist.") - if get_jwt_identity() != user_id: + if get_jwt_identity() != str(user_id): raise GraphQLError("Unauthorized operation") if name is not None: existing_user.name = name @@ -1192,7 +1192,7 @@ def mutate(self, info, user_id): if not user: raise GraphQLError("User with given ID does not exist.") - if get_jwt_identity() != user_id: + if get_jwt_identity() != str(user_id): raise GraphQLError("Unauthorized operation") s3 = boto3.client( From 8caf28f2e92ae4220daa2b59183e64d60bc7a8e5 Mon Sep 17 00:00:00 2001 From: yitbrekmata Date: Tue, 24 Mar 2026 14:45:12 -0400 Subject: [PATCH 45/55] More bug fixes --- src/schema.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/schema.py b/src/schema.py index a6a8952..ec20359 100644 --- a/src/schema.py +++ b/src/schema.py @@ -883,7 +883,7 @@ def mutate(self, info, name, net_id, email, encoded_image=None): class EditUserById(graphene.Mutation): class Arguments: - user_id = graphene.String(required=True) + user_id = graphene.Int(required=True) name = graphene.String(required=False) email = graphene.String(required=False) encoded_image = graphene.String(required=False) @@ -896,7 +896,7 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): if not existing_user: raise GraphQLError("User with given id does not exist.") - if get_jwt_identity() != str(user_id): + if get_jwt_identity() != user_id: raise GraphQLError("Unauthorized operation") if name is not None: existing_user.name = name @@ -1192,7 +1192,7 @@ def mutate(self, info, user_id): if not user: raise GraphQLError("User with given ID does not exist.") - if get_jwt_identity() != str(user_id): + if get_jwt_identity() != user_id: raise GraphQLError("Unauthorized operation") s3 = boto3.client( From a71a3d6e7a8ed3bfbf2d1689253678003561fbf5 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 12 Apr 2026 17:02:05 -0400 Subject: [PATCH 46/55] fix bug --- src/schema.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/schema.py b/src/schema.py index ec20359..51eb239 100644 --- a/src/schema.py +++ b/src/schema.py @@ -896,7 +896,7 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): if not existing_user: raise GraphQLError("User with given id does not exist.") - if get_jwt_identity() != user_id: + if int(get_jwt_identity()) != user_id: raise GraphQLError("Unauthorized operation") if name is not None: existing_user.name = name @@ -1192,7 +1192,7 @@ def mutate(self, info, user_id): if not user: raise GraphQLError("User with given ID does not exist.") - if get_jwt_identity() != user_id: + if int(get_jwt_identity()) != user_id: raise GraphQLError("Unauthorized operation") s3 = boto3.client( From 288e43297a3fcaee98644b03cd9371e464b58252 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 12 Apr 2026 17:19:19 -0400 Subject: [PATCH 47/55] fix --- src/schema.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/schema.py b/src/schema.py index 51eb239..111c1fa 100644 --- a/src/schema.py +++ b/src/schema.py @@ -894,6 +894,7 @@ class Arguments: def mutate(self, info, user_id, name=None, email=None, encoded_image=None): existing_user = db_session.query(UserModel).filter(UserModel.id == user_id).first() + # quick-fix if not existing_user: raise GraphQLError("User with given id does not exist.") if int(get_jwt_identity()) != user_id: From c302ab6b00f1652e49862f81a6dd9568a0164b7f Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 12 Apr 2026 17:31:47 -0400 Subject: [PATCH 48/55] g --- src/schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schema.py b/src/schema.py index 111c1fa..51eb239 100644 --- a/src/schema.py +++ b/src/schema.py @@ -894,7 +894,6 @@ class Arguments: def mutate(self, info, user_id, name=None, email=None, encoded_image=None): existing_user = db_session.query(UserModel).filter(UserModel.id == user_id).first() - # quick-fix if not existing_user: raise GraphQLError("User with given id does not exist.") if int(get_jwt_identity()) != user_id: From 452fecf7d96ae540c041db19a3b1957aaa868993 Mon Sep 17 00:00:00 2001 From: Tran Tran Date: Sun, 12 Apr 2026 17:56:58 -0400 Subject: [PATCH 49/55] update schema.py --- src/schema.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/schema.py b/src/schema.py index 51eb239..1f631bd 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1208,7 +1208,7 @@ def mutate(self, info, user_id): Bucket="appdev-upload", Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", ) - except ClientError as e: + except Exception as e: print("Delete error:", e) raise GraphQLError("Error deleting user profile picture") From 9deefdec0d890beb800c13515628fb9a3f72a963 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sun, 12 Apr 2026 18:04:41 -0400 Subject: [PATCH 50/55] update schema --- src/schema.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/schema.py b/src/schema.py index 1f631bd..7efe88b 100644 --- a/src/schema.py +++ b/src/schema.py @@ -35,6 +35,7 @@ from zoneinfo import ZoneInfo from sqlalchemy import func, cast, Date import boto3 +from botocore.config import Config from botocore.exceptions import ClientError local_tz = ZoneInfo("America/New_York") @@ -848,7 +849,8 @@ def mutate(self, info, name, net_id, email, encoded_image=None): "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), ) try: @@ -908,7 +910,8 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), ) try: @@ -1199,7 +1202,8 @@ def mutate(self, info, user_id): "s3", endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS") + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), ) if user.encoded_image: From ebad7fa689ba5c38f1a826c8ccfba2a1c737ac93 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sun, 12 Apr 2026 18:11:46 -0400 Subject: [PATCH 51/55] bug --- src/schema.py | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/src/schema.py b/src/schema.py index 7efe88b..51a46fb 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1197,25 +1197,29 @@ def mutate(self, info, user_id): if int(get_jwt_identity()) != user_id: raise GraphQLError("Unauthorized operation") - - s3 = boto3.client( - "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), - aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), - config=Config(s3={"addressing_style": "path"}), - ) - + + logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info(f"User encoded_image: {user.encoded_image}") + if user.encoded_image: try: + logging.info("Attempting S3 delete...") + s3 = boto3.client( + "s3", + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), + ) s3.delete_object( Bucket="appdev-upload", - Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", + Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", ) + logging.info("S3 delete succeeded") except Exception as e: - print("Delete error:", e) - raise GraphQLError("Error deleting user profile picture") - + logging.error(f"S3 delete failed: {type(e).__name__}: {e}") + raise GraphQLError(f"S3 error: {type(e).__name__}: {e}") + db_session.delete(user) db_session.commit() return user From e3c5dd809fac7126620277f658d58c11c2bc0595 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sun, 12 Apr 2026 18:23:47 -0400 Subject: [PATCH 52/55] update schema --- src/schema.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/schema.py b/src/schema.py index 51a46fb..86f7219 100644 --- a/src/schema.py +++ b/src/schema.py @@ -293,7 +293,7 @@ def resolve_total_gym_days(self, info): def resolve_active_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() if not user: - raise GraphQLError("User with the given ID does not exist.") + return self.active_streak workout_date_rows = ( Workout.get_query(info) @@ -355,7 +355,7 @@ def resolve_active_streak(self, info): def resolve_streak_start(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() if not user: - raise GraphQLError("User with the given ID does not exist.") + return None workout_date_rows = ( Workout.get_query(info) @@ -450,7 +450,7 @@ def goal_for_window_start(ws_date): def resolve_max_streak(self, info): user = User.get_query(info).filter(UserModel.id == self.id).first() if not user: - raise GraphQLError("User with the given ID does not exist.") + return self.max_streak workout_date_rows = ( Workout.get_query(info) @@ -1221,6 +1221,8 @@ def mutate(self, info, user_id): raise GraphQLError(f"S3 error: {type(e).__name__}: {e}") db_session.delete(user) + db_session.flush() + db_session.expunge(user) db_session.commit() return user From eceaed1af2d74cf12e15fdc2d3d92180c7b11fb4 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sun, 12 Apr 2026 18:35:55 -0400 Subject: [PATCH 53/55] logs --- src/schema.py | 107 +++++++++++++++++++++++++++++++++----------------- 1 file changed, 70 insertions(+), 37 deletions(-) diff --git a/src/schema.py b/src/schema.py index 86f7219..8d8e04b 100644 --- a/src/schema.py +++ b/src/schema.py @@ -36,7 +36,6 @@ from sqlalchemy import func, cast, Date import boto3 from botocore.config import Config -from botocore.exceptions import ClientError local_tz = ZoneInfo("America/New_York") @@ -844,37 +843,53 @@ def mutate(self, info, name, net_id, email, encoded_image=None): final_photo_url = None if encoded_image: - - s3 = boto3.client( - "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), - aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), - config=Config(s3={"addressing_style": "path"}), + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{net_id}-profile.png" + region = "nyc3" + + logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info( + "CreateUser profile picture upload: net_id=%s, bucket=%s, key=%s", + net_id, + bucket, + path, ) - + try: image_data = base64.b64decode(encoded_image, validate=True) except (binascii.Error, ValueError) as err: - raise GraphQLError("Invalid profile image encoding.") + logging.warning( + "Invalid profile image encoding: %s: %s", + type(err).__name__, + err, + ) + raise GraphQLError("Invalid profile image encoding.") try: - bucket = "appdev-upload" - path = f"uplift-dev/user-profile/{net_id}-profile.png" - region = "nyc3" - + logging.info("Attempting S3 put_object for new user profile picture...") + s3 = boto3.client( + "s3", + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), + ) s3.put_object( Bucket=bucket, - Key=path, + Key=path, Body=image_data, ContentType="image/png", - ACL="public-read" + ACL="public-read", ) - + logging.info("S3 put_object succeeded for new user profile picture") final_photo_url = f"https://{bucket}.{region}.digitaloceanspaces.com/{path}" - except ClientError as e: - print("Upload error:", e) - raise GraphQLError("Error uploading user profile picture.") + except Exception as e: + logging.error( + "S3 upload failed (create user): %s: %s", + type(e).__name__, + e, + ) + raise GraphQLError(f"S3 error: {type(e).__name__}: {e}") new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url) db_session.add(new_user) @@ -906,37 +921,55 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): existing_user.email = email if encoded_image is not None: final_photo_url = None - s3 = boto3.client( - "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), - aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), - aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), - config=Config(s3={"addressing_style": "path"}), + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{existing_user.net_id}-profile.png" + region = "nyc3" + + logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info( + "EditUser profile picture upload: user_id=%s, net_id=%s, bucket=%s, key=%s", + user_id, + existing_user.net_id, + bucket, + path, ) - + try: image_data = base64.b64decode(encoded_image, validate=True) except (binascii.Error, ValueError) as err: + logging.warning( + "Invalid profile image encoding: %s: %s", + type(err).__name__, + err, + ) raise GraphQLError("Invalid profile image encoding.") try: - bucket = "appdev-upload" - path = f"uplift-dev/user-profile/{existing_user.net_id}-profile.png" - region = "nyc3" - + logging.info("Attempting S3 put_object for edited user profile picture...") + s3 = boto3.client( + "s3", + endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), + aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), + config=Config(s3={"addressing_style": "path"}), + ) s3.put_object( Bucket=bucket, - Key=path, + Key=path, Body=image_data, ContentType="image/png", - ACL="public-read" + ACL="public-read", ) - + logging.info("S3 put_object succeeded for edited user profile picture") final_photo_url = f"https://{bucket}.{region}.digitaloceanspaces.com/{path}" existing_user.encoded_image = final_photo_url - except ClientError as e: - print("Upload error:", e) - raise GraphQLError("Error adding new user profile picture.") + except Exception as e: + logging.error( + "S3 upload failed (edit user): %s: %s", + type(e).__name__, + e, + ) + raise GraphQLError(f"S3 error: {type(e).__name__}: {e}") db_session.commit() return existing_user From dd36d62fa2fb81914f079a09d5b7798be24ead58 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sun, 12 Apr 2026 18:46:36 -0400 Subject: [PATCH 54/55] quick fix --- src/schema.py | 25 +++++++++++++++++++------ src/utils/constants.py | 12 ++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/src/schema.py b/src/schema.py index 8d8e04b..d8ce338 100644 --- a/src/schema.py +++ b/src/schema.py @@ -28,6 +28,7 @@ from src.models.report import Report as ReportModel from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel from src.models.user_workout_goal_history import UserWorkoutGoalHistory as UserWorkoutGoalHistoryModel +from src.utils.constants import get_digital_ocean_s3_endpoint_url from src.database import db_session import requests from firebase_admin import messaging @@ -847,7 +848,11 @@ def mutate(self, info, name, net_id, email, encoded_image=None): path = f"uplift-dev/user-profile/{net_id}-profile.png" region = "nyc3" - logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info( + "DIGITAL_OCEAN_URL raw=%r normalized=%r", + os.getenv("DIGITAL_OCEAN_URL"), + get_digital_ocean_s3_endpoint_url(), + ) logging.info( "CreateUser profile picture upload: net_id=%s, bucket=%s, key=%s", net_id, @@ -869,7 +874,7 @@ def mutate(self, info, name, net_id, email, encoded_image=None): logging.info("Attempting S3 put_object for new user profile picture...") s3 = boto3.client( "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + endpoint_url=get_digital_ocean_s3_endpoint_url(), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), config=Config(s3={"addressing_style": "path"}), @@ -925,7 +930,11 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): path = f"uplift-dev/user-profile/{existing_user.net_id}-profile.png" region = "nyc3" - logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info( + "DIGITAL_OCEAN_URL raw=%r normalized=%r", + os.getenv("DIGITAL_OCEAN_URL"), + get_digital_ocean_s3_endpoint_url(), + ) logging.info( "EditUser profile picture upload: user_id=%s, net_id=%s, bucket=%s, key=%s", user_id, @@ -948,7 +957,7 @@ def mutate(self, info, user_id, name=None, email=None, encoded_image=None): logging.info("Attempting S3 put_object for edited user profile picture...") s3 = boto3.client( "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + endpoint_url=get_digital_ocean_s3_endpoint_url(), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), config=Config(s3={"addressing_style": "path"}), @@ -1231,7 +1240,11 @@ def mutate(self, info, user_id): if int(get_jwt_identity()) != user_id: raise GraphQLError("Unauthorized operation") - logging.info(f"DIGITAL_OCEAN_URL: {os.getenv('DIGITAL_OCEAN_URL')}") + logging.info( + "DIGITAL_OCEAN_URL raw=%r normalized=%r", + os.getenv("DIGITAL_OCEAN_URL"), + get_digital_ocean_s3_endpoint_url(), + ) logging.info(f"User encoded_image: {user.encoded_image}") if user.encoded_image: @@ -1239,7 +1252,7 @@ def mutate(self, info, user_id): logging.info("Attempting S3 delete...") s3 = boto3.client( "s3", - endpoint_url=os.getenv("DIGITAL_OCEAN_URL"), + endpoint_url=get_digital_ocean_s3_endpoint_url(), aws_access_key_id=os.getenv("DIGITAL_OCEAN_ACCESS"), aws_secret_access_key=os.getenv("DIGITAL_OCEAN_SECRET_ACCESS"), config=Config(s3={"addressing_style": "path"}), diff --git a/src/utils/constants.py b/src/utils/constants.py index b25222d..d24d81f 100644 --- a/src/utils/constants.py +++ b/src/utils/constants.py @@ -142,3 +142,15 @@ # The path for Teagle Up Fitness Center details TEAGLE_UP_DETAILS = "https://scl.cornell.edu/recreation/facility/teagle-upstairs" + + +def get_digital_ocean_s3_endpoint_url(): + """ + DIGITAL_OCEAN_URL for boto3. Strips whitespace and surrounding quotes that + often appear when the value is copied into .env or secret managers with quotes. + """ + raw = os.getenv("DIGITAL_OCEAN_URL") + if not raw: + return None + u = raw.strip().strip("\"'") + return u or None From f3bf1cefe4653916fb0c0c5943e54e64979302b4 Mon Sep 17 00:00:00 2001 From: Jiwon Jeong Date: Sat, 18 Apr 2026 23:12:15 -0400 Subject: [PATCH 55/55] restart --- src/schema.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/schema.py b/src/schema.py index d8ce338..f1bd193 100644 --- a/src/schema.py +++ b/src/schema.py @@ -220,7 +220,6 @@ def resolve_class_instances(self, info): # MARK: - Class Instance - class ClassInstance(SQLAlchemyObjectType): class Meta: model = ClassInstanceModel