diff --git a/app_factory.py b/app_factory.py index 57fdcd7..99d58ed 100644 --- a/app_factory.py +++ b/app_factory.py @@ -26,7 +26,7 @@ def initialize_firebase(): raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") else: firebase_app = firebase_admin.get_app() - logging.info("Firebase app created...") + logging.info("Firebase app created") return firebase_app @@ -198,7 +198,7 @@ def cleanup_expired_tokens(): # Update hourly average capacity every hour @scheduler.task("cron", id="update_capacity", hour="*") - def scheduled_job(): + def update_hourly_avg_capacity(): current_time = datetime.now() current_day = current_time.strftime("%A").upper() current_hour = current_time.hour diff --git a/migrations/versions/723cd68cf306_add_workout_reminders.py b/migrations/versions/723cd68cf306_add_workout_reminders.py new file mode 100644 index 0000000..827f9b4 --- /dev/null +++ b/migrations/versions/723cd68cf306_add_workout_reminders.py @@ -0,0 +1,77 @@ +"""add workout reminders + +Revision ID: 723cd68cf306 +Revises: 7a3c14648e56 +Create Date: 2025-04-01 01:06:38.476352 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + +# revision identifiers, used by Alembic. +revision = '723cd68cf306' +down_revision = '7a3c14648e56' +branch_labels = None +depends_on = None + +def upgrade(): + + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'workout_reminder' + ) THEN + CREATE TABLE workout_reminder ( + id SERIAL PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + days_of_week dayofweekenum[] NOT NULL, + reminder_time TIME NOT NULL, + is_active BOOLEAN DEFAULT TRUE + ); + END IF; + END + $$; + """) + + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'fcm_token' + ) THEN + ALTER TABLE users ADD COLUMN fcm_token VARCHAR NOT NULL DEFAULT 'unset'; + ALTER TABLE users ALTER COLUMN fcm_token DROP DEFAULT; + END IF; + END + $$; + """) + + +def downgrade(): + op.execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.tables WHERE table_name = 'workout_reminder' + ) THEN + DROP TABLE workout_reminder; + END IF; + END + $$; + """) + + op.execute(""" + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'users' AND column_name = 'fcm_token' + ) THEN + ALTER TABLE users DROP COLUMN fcm_token; + END IF; + END + $$; + """) diff --git a/schema.graphql b/schema.graphql index 3f1dadd..e59ac4e 100644 --- a/schema.graphql +++ b/schema.graphql @@ -216,8 +216,8 @@ enum MuscleGroup { type Mutation { createGiveaway(name: String!): Giveaway - createUser(email: String!, encodedImage: String, name: String!, netId: String!): User - editUser(email: String, encodedImage: String, name: String, netId: String!): User + createUser(email: String!, encodedImage: String, fcmToken: String!, name: String!, netId: String!): User + editUser(email: String, encodedImage: String, fcmToken: String, name: String, netId: String!): User enterGiveaway(giveawayId: Int!, userNetId: String!): GiveawayInstance setWorkoutGoals(userId: Int!, workoutGoal: [String]!): User logWorkout(facilityId: Int!, userId: Int!, workoutTime: DateTime!): Workout @@ -229,6 +229,10 @@ type Mutation { createCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, fcmToken: String!, gyms: [String]!): CapacityReminder editCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, gyms: [String]!, reminderId: Int!): CapacityReminder deleteCapacityReminder(reminderId: Int!): CapacityReminder + createWorkoutReminder(daysOfWeek: [String]!, reminderTime: Time!, userId: Int!): WorkoutReminder + toggleWorkoutReminder(reminderId: Int!): WorkoutReminder + editWorkoutReminder(daysOfWeek: [String], reminderId: Int!, reminderTime: Time, userId: Int): WorkoutReminder + deleteWorkoutReminder(reminderId: Int!): WorkoutReminder addFriend(friendId: Int!, userId: Int!): Friendship acceptFriendRequest(friendshipId: Int!): Friendship removeFriend(friendId: Int!, userId: Int!): RemoveFriend @@ -273,6 +277,7 @@ type Query { getWorkoutGoals(id: Int!): [String] getUserStreak(id: Int!): JSONString getHourlyAverageCapacitiesByFacilityId(facilityId: Int): [HourlyAverageCapacity] + getWorkoutRemindersByUserId(userId: Int): [WorkoutReminder] getUserFriends(userId: Int!): [User] } @@ -301,6 +306,8 @@ enum ReportType { OTHER } +scalar Time + type User { id: ID! email: String @@ -310,7 +317,9 @@ type User { maxStreak: Int workoutGoal: [DayOfWeekGraphQLEnum] encodedImage: String + fcmToken: String! giveaways: [Giveaway] + workoutReminders: [WorkoutReminder] friendRequestsSent: [Friendship] friendRequestsReceived: [Friendship] friendships: [Friendship] @@ -323,3 +332,11 @@ type Workout { userId: Int! facilityId: Int! } + +type WorkoutReminder { + id: ID! + userId: Int! + daysOfWeek: [DayOfWeekGraphQLEnum] + reminderTime: String! + isActive: Boolean +} diff --git a/src/models/user.py b/src/models/user.py index 98ffb39..7771ce3 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -32,6 +32,8 @@ class User(Base): max_streak = Column(Integer, nullable=True) workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) encoded_image = Column(String, nullable=True) + fcm_token = Column(String, nullable=False) + workout_reminders = relationship("WorkoutReminder") friend_requests_sent = relationship("Friendship", foreign_keys="Friendship.user_id", diff --git a/src/models/workout_reminder.py b/src/models/workout_reminder.py new file mode 100644 index 0000000..c354466 --- /dev/null +++ b/src/models/workout_reminder.py @@ -0,0 +1,24 @@ +from sqlalchemy import Column, Integer, ForeignKey, TIME, Boolean +from sqlalchemy.dialects.postgresql import ARRAY +from sqlalchemy import Enum as SQLAEnum +from src.models.enums import DayOfWeekEnum +from src.database import Base + +class WorkoutReminder(Base): + """ + A workout reminder for an Uplift user. + Attributes: + - `id` The ID of the workout reminder. + - `user_id` The ID of the user who owns this reminder. + - `days_of_week` The days of the week when the reminder is active. + - `reminder_time` The time of day the reminder is scheduled for. + - `is_active` Whether the reminder is currently active (default is True). + """ + + __tablename__ = "workout_reminder" + + id = Column(Integer, primary_key=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + days_of_week = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=False) + reminder_time = Column(TIME, nullable=False) + is_active = Column(Boolean, default=True) \ No newline at end of file diff --git a/src/schema.py b/src/schema.py index 21dfdc0..060737f 100644 --- a/src/schema.py +++ b/src/schema.py @@ -22,6 +22,7 @@ from src.models.giveaway import Giveaway as GiveawayModel from src.models.giveaway import GiveawayInstance as GiveawayInstanceModel from src.models.workout import Workout as WorkoutModel +from src.models.workout_reminder import WorkoutReminder as WorkoutReminderModel from src.models.report import Report as ReportModel from src.models.hourly_average_capacity import HourlyAverageCapacity as HourlyAverageCapacityModel from src.database import db_session @@ -295,6 +296,16 @@ class Meta: exclude_fields = ("fcm_token",) +# MARK: - Workout Reminder + + +class WorkoutReminder(SQLAlchemyObjectType): + class Meta: + model = WorkoutReminderModel + + days_of_week = graphene.List(DayOfWeekGraphQLEnum) + + # MARK: - Query @@ -320,6 +331,7 @@ class Query(graphene.ObjectType): get_hourly_average_capacities_by_facility_id = graphene.List( HourlyAverageCapacity, facility_id=graphene.Int(), description="Get all facility hourly average capacities." ) + get_workout_reminders_by_user_id = graphene.List(WorkoutReminder, user_id=graphene.Int(), description="Get the workout reminders of a user by user ID.") get_user_friends = graphene.List( User, user_id=graphene.Int(required=True), @@ -450,6 +462,10 @@ def resolve_get_hourly_average_capacities_by_facility_id(self, info, facility_id raise GraphQLError("Invalid facility ID.") query = HourlyAverageCapacity.get_query(info).filter(HourlyAverageCapacityModel.facility_id == facility_id) return query.all() + + def resolve_get_workout_reminders_by_user_id(self, info, user_id): + query = WorkoutReminder.get_query(info).filter(WorkoutReminderModel.user_id == user_id) + return query.all() @jwt_required() def resolve_get_user_friends(self, info, user_id): @@ -551,11 +567,12 @@ class Arguments: name = graphene.String(required=True) net_id = graphene.String(required=True) email = graphene.String(required=True) + fcm_token = graphene.String(required=True) encoded_image = graphene.String(required=False) Output = User - def mutate(self, info, name, net_id, email, encoded_image=None): + def mutate(self, info, name, net_id, email, fcm_token, 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 @@ -577,7 +594,7 @@ def mutate(self, info, name, net_id, email, encoded_image=None): print(f"Request failed: {e}") raise GraphQLError("Failed to upload photo.") - new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url) + new_user = UserModel(name=name, net_id=net_id, email=email, encoded_image=final_photo_url, fcm_token=fcm_token) db_session.add(new_user) db_session.commit() @@ -589,11 +606,12 @@ class Arguments: name = graphene.String(required=False) net_id = graphene.String(required=True) email = graphene.String(required=False) + fcm_token = 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): + def mutate(self, info, net_id, name=None, email=None, fcm_token=None, encoded_image=None): existing_user = db_session.query(UserModel).filter(UserModel.net_id == net_id).first() if not existing_user: raise GraphQLError("User with given net id does not exist.") @@ -602,6 +620,8 @@ def mutate(self, info, net_id, name=None, email=None, encoded_image=None): existing_user.name = name if email is not None: existing_user.email = email + if fcm_token is not None: + existing_user.fcm_token = fcm_token if encoded_image is not None: upload_url = os.getenv("DIGITAL_OCEAN_URL") # Base URL for upload endpoint if not upload_url: @@ -1067,6 +1087,104 @@ def mutate(self, info, user_id): return GetPendingFriendRequests(pending_requests=pending) +class CreateWorkoutReminder(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + reminder_time = graphene.Time(required=True) + days_of_week = graphene.List(graphene.String, required=True) + + Output = WorkoutReminder + + def mutate(self, info, user_id, reminder_time, days_of_week): + # Validate user existence + user = db_session.query(UserModel).filter_by(id=user_id).first() + if not user: + raise GraphQLError("User not found.") + + # Validate days of the week + validated_workout_days = [] + for day in days_of_week: + try: + validated_workout_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + + reminder = WorkoutReminderModel( + user_id=user_id, reminder_time=reminder_time, days_of_week=validated_workout_days + ) + + db_session.add(reminder) + db_session.commit() + + return reminder + + +class ToggleWorkoutReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = WorkoutReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(WorkoutReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("Workout reminder not found.") + + reminder.is_active = not reminder.is_active + db_session.commit() + + return reminder + + +class EditWorkoutReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + user_id = graphene.Int(required=False) + reminder_time = graphene.Time(required=False) + days_of_week = graphene.List(graphene.String, required=False) + + Output = WorkoutReminder + + def mutate(self, info, reminder_id, user_id=None, reminder_time=None, days_of_week=None): + reminder = db_session.query(WorkoutReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("Workout reminder not found.") + + if user_id is not None: + reminder.user_id = user_id + if reminder_time is not None: + reminder.reminder_time = reminder_time + if days_of_week is not None: + validated_days = [] + for day in days_of_week: + try: + validated_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + reminder.days_of_week = validated_days + + db_session.commit() + + return reminder + + +class DeleteWorkoutReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = WorkoutReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(WorkoutReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("Workout reminder not found.") + + db_session.delete(reminder) + db_session.commit() + + return reminder + + class Mutation(graphene.ObjectType): create_giveaway = CreateGiveaway.Field(description="Creates a new giveaway.") create_user = CreateUser.Field(description="Creates a new user.") @@ -1084,6 +1202,10 @@ class Mutation(graphene.ObjectType): create_capacity_reminder = CreateCapacityReminder.Field(description="Create a new capacity reminder.") edit_capacity_reminder = EditCapacityReminder.Field(description="Edit capacity reminder.") delete_capacity_reminder = DeleteCapacityReminder.Field(description="Delete a capacity reminder") + create_workout_reminder = CreateWorkoutReminder.Field(description="Create a new workout reminder.") + toggle_workout_reminder = ToggleWorkoutReminder.Field(description="Toggle a workout reminder on or off.") + edit_workout_reminder = EditWorkoutReminder.Field(description="Edit a workout reminder.") + delete_workout_reminder = DeleteWorkoutReminder.Field(description="Delete a workout reminder.") add_friend = AddFriend.Field(description="Send a friend request to another user.") accept_friend_request = AcceptFriendRequest.Field(description="Accept a friend request.") remove_friend = RemoveFriend.Field(description="Remove a friendship.") diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index 7e6157b..69b8129 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -105,7 +105,7 @@ def fetch_capacities(): current_time = int(time.time()) - is_open = any(hour.start_time <= current_time <= hour.end_time for hour in facility.hours) + is_open = any(hour.start_time <= current_time < hour.end_time for hour in facility.hours) if is_open: topic_enum = gym_mapping[db_name]