diff --git a/.gitignore b/.gitignore index e78072a..2edd957 100644 --- a/.gitignore +++ b/.gitignore @@ -16,4 +16,5 @@ build/ Archive scripts *.sqlite3 -service-account-key.json \ No newline at end of file +service-account-key.json +docker-compose.yml \ No newline at end of file diff --git a/app_factory.py b/app_factory.py index 9754b75..57fdcd7 100644 --- a/app_factory.py +++ b/app_factory.py @@ -1,11 +1,11 @@ import logging from datetime import timedelta, timezone 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 graphene import Schema from graphql.utils import schema_printer -from src.utils.constants import JWT_SECRET_KEY from src.database import db_session, init_db from src.database import Base as db from src.database import db_url, db_user, db_password, db_name, db_host, db_port @@ -14,6 +14,20 @@ from flasgger import Swagger from flask_graphql import GraphQLView from src.models.token_blacklist import TokenBlocklist +import firebase_admin +from firebase_admin import credentials + +def initialize_firebase(): + if not firebase_admin._apps: + if SERVICE_ACCOUNT_PATH: + cred = credentials.Certificate(SERVICE_ACCOUNT_PATH) + firebase_app = firebase_admin.initialize_app(cred) + else: + raise ValueError("GOOGLE_SERVICE_ACCOUNT_PATH environment variable not set.") + else: + firebase_app = firebase_admin.get_app() + logging.info("Firebase app created...") + return firebase_app # Set up logging at module level @@ -32,6 +46,7 @@ def create_app(run_migrations=False): Configured Flask application """ logger.info("Initializing application") + initialize_firebase() # Create and configure Flask app app = Flask(__name__) @@ -56,7 +71,7 @@ def create_app(run_migrations=False): schema = Schema(query=Query, mutation=Mutation) swagger = Swagger(app) - app.config["JWT_SECRET_KEY"] = JWT_SECRET_KEY + app.config['JWT_SECRET_KEY'] = JWT_SECRET_KEY app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(hours=1) app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30) diff --git a/migrations/versions/3c406131c004_merge_branches.py b/migrations/versions/3c406131c004_merge_branches.py new file mode 100644 index 0000000..bbeca01 --- /dev/null +++ b/migrations/versions/3c406131c004_merge_branches.py @@ -0,0 +1,24 @@ +"""merge branches + +Revision ID: 3c406131c004 +Revises: 7a3c14648e56 +Create Date: 2025-03-29 00:36:22.980924 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '3c406131c004' +down_revision = '7a3c14648e56' +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/7a3c14648e56_add_capacity_reminder_model.py b/migrations/versions/7a3c14648e56_add_capacity_reminder_model.py new file mode 100644 index 0000000..2370e9a --- /dev/null +++ b/migrations/versions/7a3c14648e56_add_capacity_reminder_model.py @@ -0,0 +1,68 @@ +"""add capacity reminder model + +Revision ID: 7a3c14648e56 +Revises: add99ce06ff5 +Create Date: 2025-03-19 17:32:02.592027 + +""" +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = '7a3c14648e56' +down_revision = 'add99ce06ff5' +branch_labels = None +depends_on = None + +capacity_reminder_gym_enum = postgresql.ENUM( + 'TEAGLEUP', 'TEAGLEDOWN', 'HELENNEWMAN', 'TONIMORRISON', 'NOYES', + name='capacityremindergym', create_type=False +) + +day_of_week_enum = postgresql.ENUM( + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday', + name='dayofweekenum', create_type=False +) + +def upgrade(): + op.execute(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'capacity_reminder') THEN + CREATE TABLE capacity_reminder ( + id SERIAL PRIMARY KEY, + fcm_token VARCHAR NOT NULL, + gyms capacityremindergym[] NOT NULL, + capacity_threshold INTEGER NOT NULL, + days_of_week dayofweekenum[] NOT NULL, + is_active BOOLEAN DEFAULT TRUE NOT NULL + ); + END IF; + END $$; + """) + + +def downgrade(): + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM information_schema.tables WHERE table_name = 'capacity_reminder') THEN + DROP TABLE capacity_reminder; + END IF; + END $$; + """) + + op.execute(""" + DO $$ + BEGIN + IF EXISTS (SELECT 1 FROM pg_type WHERE typname = 'capacityremindergym') + AND NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE atttypid = (SELECT oid FROM pg_type WHERE typname = 'capacityremindergym') + ) THEN + DROP TYPE capacityremindergym CASCADE; + END IF; + END $$ LANGUAGE plpgsql; + """) diff --git a/migrations/versions/add_friends_table_migration.py b/migrations/versions/add_friends_table_migration.py new file mode 100644 index 0000000..57e5a4e --- /dev/null +++ b/migrations/versions/add_friends_table_migration.py @@ -0,0 +1,36 @@ +"""Add friends table + +Revision ID: add_friends_table +Revises: 3c406131c004 +Create Date: 2025-03-29 00:55:00.000000 + +""" +from alembic import op +import sqlalchemy as sa +from datetime import datetime + + +# revision identifiers, used by Alembic. +revision = 'add_friends_table' +down_revision = '3c406131c004' +branch_labels = None +depends_on = None + + +def upgrade(): + # Create friends table + op.create_table('friends', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('friend_id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=True, server_default=sa.text('CURRENT_TIMESTAMP')), + sa.Column('is_accepted', sa.Boolean(), nullable=True, server_default=sa.text('false')), + sa.Column('accepted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['friend_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'friend_id') + ) + + +def downgrade(): + # Drop friends table + op.drop_table('friends') diff --git a/requirements.txt b/requirements.txt index 3d4cc15..c6219de 100644 --- a/requirements.txt +++ b/requirements.txt @@ -24,7 +24,7 @@ Flask-Script==2.0.5 Flask-SQLAlchemy==2.3.1 Flask-RESTful==0.3.10 flasgger==0.9.7.1 -google-auth==1.12.0 +google-auth==2.14.1 graphene==2.1.3 graphene-sqlalchemy==2.3.0 graphql-core==2.1 @@ -79,4 +79,5 @@ wcwidth==0.2.6 Werkzeug==2.2.2 zipp==3.15.0 sentry-sdk==2.13.0 -flask_jwt_extended==4.7.1 \ No newline at end of file +flask_jwt_extended==4.7.1 +firebase-admin==6.4.0 diff --git a/schema.graphql b/schema.graphql index a4aeaea..3f1dadd 100644 --- a/schema.graphql +++ b/schema.graphql @@ -38,6 +38,23 @@ type Capacity { updated: Int! } +type CapacityReminder { + id: ID! + fcmToken: String! + gyms: [CapacityReminderGym]! + capacityThreshold: Int! + daysOfWeek: [DayOfWeekEnum]! + isActive: Boolean +} + +enum CapacityReminderGym { + TEAGLEUP + TEAGLEDOWN + HELENNEWMAN + TONIMORRISON + NOYES +} + type Class { id: ID! name: String! @@ -71,6 +88,16 @@ type CreateReport { scalar DateTime +enum DayOfWeekEnum { + MONDAY + TUESDAY + WEDNESDAY + THURSDAY + FRIDAY + SATURDAY + SUNDAY +} + enum DayOfWeekGraphQLEnum { MONDAY TUESDAY @@ -109,6 +136,21 @@ enum FacilityType { COURT } +type Friendship { + id: ID! + userId: Int! + friendId: Int! + createdAt: DateTime + isAccepted: Boolean + acceptedAt: DateTime + user: User + friend: User +} + +type GetPendingFriendRequests { + pendingRequests: [Friendship] +} + type Giveaway { id: ID! name: String! @@ -184,6 +226,13 @@ type Mutation { refreshAccessToken: RefreshAccessToken createReport(createdAt: DateTime!, description: String!, gymId: Int!, issue: String!): CreateReport deleteUser(userId: Int!): User + createCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, fcmToken: String!, gyms: [String]!): CapacityReminder + editCapacityReminder(capacityPercent: Int!, daysOfWeek: [String]!, gyms: [String]!, reminderId: Int!): CapacityReminder + deleteCapacityReminder(reminderId: Int!): CapacityReminder + addFriend(friendId: Int!, userId: Int!): Friendship + acceptFriendRequest(friendshipId: Int!): Friendship + removeFriend(friendId: Int!, userId: Int!): RemoveFriend + getPendingFriendRequests(userId: Int!): GetPendingFriendRequests } type OpenHours { @@ -215,6 +264,7 @@ enum PriceType { type Query { getAllGyms: [Gym] getUserByNetId(netId: String): [User] + getUsersFriends(id: Int): [User] getUsersByGiveawayId(id: Int): [User] getWeeklyWorkoutDays(id: Int): [String] getWorkoutsById(id: Int): [Workout] @@ -223,12 +273,17 @@ type Query { getWorkoutGoals(id: Int!): [String] getUserStreak(id: Int!): JSONString getHourlyAverageCapacitiesByFacilityId(facilityId: Int): [HourlyAverageCapacity] + getUserFriends(userId: Int!): [User] } type RefreshAccessToken { newAccessToken: String } +type RemoveFriend { + success: Boolean +} + type Report { id: ID! createdAt: DateTime! @@ -256,6 +311,10 @@ type User { workoutGoal: [DayOfWeekGraphQLEnum] encodedImage: String giveaways: [Giveaway] + friendRequestsSent: [Friendship] + friendRequestsReceived: [Friendship] + friendships: [Friendship] + friends: [User] } type Workout { diff --git a/src/models/capacity_reminder.py b/src/models/capacity_reminder.py new file mode 100644 index 0000000..a7c9f59 --- /dev/null +++ b/src/models/capacity_reminder.py @@ -0,0 +1,27 @@ +from sqlalchemy import Column, Integer, ForeignKey, ARRAY, Boolean, Table, String +from sqlalchemy.orm import relationship +from sqlalchemy import Enum as SQLAEnum +from src.models.enums import DayOfWeekEnum, CapacityReminderGym +from src.database import Base + +class CapacityReminder(Base): + """ + A capacity reminder for an Uplift user. + Attributes: + - `id` The ID of the capacity reminder. + - `fcm_token` FCM token used to send notifications to the user's device. + - `user_id` The ID of the user who owns this reminder. + - `gyms` The list of gyms the user wants to monitor for capacity. + - `capacity_threshold` Notify user when gym capacity dips below this percentage. + - `days_of_week` The days of the week when the reminder is active. + - `is_active` Whether the reminder is currently active (default is True). + """ + + __tablename__ = "capacity_reminder" + + id = Column(Integer, primary_key=True) + fcm_token = Column(String, nullable=False) + gyms = Column(ARRAY(SQLAEnum(CapacityReminderGym)), nullable=False) + capacity_threshold = Column(Integer, nullable=False) + days_of_week = Column(ARRAY(SQLAEnum(DayOfWeekEnum)), nullable=False) + is_active = Column(Boolean, default=True) diff --git a/src/models/enums.py b/src/models/enums.py index b88465b..6feee91 100644 --- a/src/models/enums.py +++ b/src/models/enums.py @@ -19,4 +19,18 @@ class DayOfWeekGraphQLEnum(GrapheneEnum): THURSDAY = "THURSDAY" FRIDAY = "FRIDAY" SATURDAY = "SATURDAY" - SUNDAY = "SUNDAY" \ No newline at end of file + SUNDAY = "SUNDAY" + +class CapacityReminderGym(enum.Enum): + TEAGLEUP = "TEAGLEUP" + TEAGLEDOWN = "TEAGLEDOWN" + HELENNEWMAN = "HELENNEWMAN" + TONIMORRISON = "TONIMORRISON" + NOYES = "NOYES" + +class CapacityReminderGymGraphQLEnum(GrapheneEnum): + TEAGLEUP = "TEAGLEUP" + TEAGLEDOWN = "TEAGLEDOWN" + HELENNEWMAN = "HELENNEWMAN" + TONIMORRISON = "TONIMORRISON" + NOYES = "NOYES" \ No newline at end of file diff --git a/src/models/friends.py b/src/models/friends.py new file mode 100644 index 0000000..db3b525 --- /dev/null +++ b/src/models/friends.py @@ -0,0 +1,36 @@ +from sqlalchemy import Column, Integer, ForeignKey, DateTime, Boolean, UniqueConstraint +from sqlalchemy.orm import relationship +from src.database import Base +from datetime import datetime + +class Friendship(Base): + """ + A friendship relationship between two users. + + Attributes: + - `id` The ID of the friendship. + - `user_id` The ID of the user who initiated the friendship. + - `friend_id` The ID of the user who received the friendship request. + - `created_at` When the friendship was created. + - `is_accepted` Whether the friendship has been accepted. + - `accepted_at` When the friendship was accepted. + """ + + __tablename__ = "friends" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + friend_id = Column(Integer, ForeignKey("users.id"), nullable=False) + __table_args__ = (UniqueConstraint('user_id', 'friend_id', name='unique_friendship'),) + + created_at = Column(DateTime, default=datetime.utcnow) + is_accepted = Column(Boolean, default=False) + accepted_at = Column(DateTime, nullable=True) + + user = relationship("User", foreign_keys=[user_id], back_populates="friend_requests_sent") + friend = relationship("User", foreign_keys=[friend_id], back_populates="friend_requests_received") + + def accept(self): + """Accept a friendship request.""" + self.is_accepted = True + self.accepted_at = datetime.utcnow() diff --git a/src/models/user.py b/src/models/user.py index 9e608a7..98ffb39 100644 --- a/src/models/user.py +++ b/src/models/user.py @@ -1,7 +1,8 @@ -from sqlalchemy import Column, Integer, String, ARRAY, Enum -from sqlalchemy.orm import backref, relationship +from sqlalchemy import Column, Integer, String, ARRAY, Enum, ForeignKey +from sqlalchemy.orm import relationship from src.database import Base from src.models.enums import DayOfWeekEnum +from src.models.friends import Friendship class User(Base): """ @@ -30,4 +31,48 @@ class User(Base): active_streak = Column(Integer, nullable=True) max_streak = Column(Integer, nullable=True) workout_goal = Column(ARRAY(Enum(DayOfWeekEnum)), nullable=True) - encoded_image = Column(String, nullable=True) \ No newline at end of file + encoded_image = Column(String, nullable=True) + + friend_requests_sent = relationship("Friendship", + foreign_keys="Friendship.user_id", + back_populates="user") + + friend_requests_received = relationship("Friendship", + foreign_keys="Friendship.friend_id", + back_populates="friend") + + def add_friend(self, friend): + # Check if friendship already exists + existing = Friendship.query.filter( + (Friendship.user_id == self.id) & + (Friendship.friend_id == friend.id) + ).first() + + if not existing: + new_friendship = Friendship(user_id=self.id, friend_id=friend.id) + # Add to database session here or return for external handling + return new_friendship + + def remove_friend(self, friend): + friendship = Friendship.query.filter( + (Friendship.user_id == self.id) & + (Friendship.friend_id == friend.id) + ).first() + + if friendship: + # Delete from database session here or return for external handling + return friendship + + def get_friends(self): + # Get users this user has added as friends + direct_friends_query = Friendship.query.filter_by(user_id=self.id) + direct_friends = [friendship.friend for friendship in direct_friends_query] + + # Get users who have added this user as a friend + reverse_friends_query = Friendship.query.filter_by(friend_id=self.id) + reverse_friends = [friendship.user for friendship in reverse_friends_query] + + # Combine both lists and remove duplicates + all_friends = list(set(direct_friends + reverse_friends)) + + return all_friends diff --git a/src/schema.py b/src/schema.py index e3d27bb..21dfdc0 100644 --- a/src/schema.py +++ b/src/schema.py @@ -6,6 +6,7 @@ from graphene_sqlalchemy import SQLAlchemyObjectType from graphql import GraphQLError from src.models.capacity import Capacity as CapacityModel +from src.models.capacity_reminder import CapacityReminder as CapacityReminderModel from src.models.facility import Facility as FacilityModel from src.models.gym import Gym as GymModel from src.models.openhours import OpenHours as OpenHoursModel @@ -16,7 +17,8 @@ from src.models.classes import ClassInstance as ClassInstanceModel from src.models.token_blacklist import TokenBlocklist from src.models.user import User as UserModel -from src.models.enums import DayOfWeekGraphQLEnum +from src.models.friends import Friendship as FriendshipModel +from src.models.enums import DayOfWeekGraphQLEnum, CapacityReminderGymGraphQLEnum from src.models.giveaway import Giveaway as GiveawayModel from src.models.giveaway import GiveawayInstance as GiveawayInstanceModel from src.models.workout import Workout as WorkoutModel @@ -26,6 +28,7 @@ import requests import json import os +from firebase_admin import messaging # MARK: - Gym @@ -193,6 +196,34 @@ class Meta: model = UserModel workout_goal = graphene.List(DayOfWeekGraphQLEnum) + friendships = graphene.List(lambda: Friendship) + friends = graphene.List(lambda: User) + + def resolve_friendships(self, info): + # Return all friendship relationships for this user + query = Friendship.get_query(info).filter( + (FriendshipModel.user_id == self.id) | (FriendshipModel.friend_id == self.id) + ) + return query.all() + + def resolve_friends(self, info): + # Return all friend users for this user + direct_friendships = Friendship.get_query(info).filter(FriendshipModel.user_id == self.id).all() + reverse_friendships = Friendship.get_query(info).filter(FriendshipModel.friend_id == self.id).all() + + friend_ids = set() + # Add friend_ids from direct friendships + for friendship in direct_friendships: + if friendship.is_accepted: # Only include accepted friendships + friend_ids.add(friendship.friend_id) + + # Add user_ids from reverse friendships + for friendship in reverse_friendships: + if friendship.is_accepted: # Only include accepted friendships + friend_ids.add(friendship.user_id) + + # Query for all the users at once + return User.get_query(info).filter(UserModel.id.in_(friend_ids)).all() class UserInput(graphene.InputObjectType): @@ -200,6 +231,23 @@ class UserInput(graphene.InputObjectType): giveaway_id = graphene.Int(required=True) +# MARK: - Friendship + +class Friendship(SQLAlchemyObjectType): + class Meta: + model = FriendshipModel + + user = graphene.Field(lambda: User) + friend = graphene.Field(lambda: User) + + def resolve_user(self, info): + query = User.get_query(info).filter(UserModel.id == self.user_id).first() + return query + + def resolve_friend(self, info): + query = User.get_query(info).filter(UserModel.id == self.friend_id).first() + return query + # MARK: - Giveaway @@ -238,12 +286,22 @@ def resolve_gym(self, info): return query +# MARK: - Capacity Reminder + + +class CapacityReminder(SQLAlchemyObjectType): + class Meta: + model = CapacityReminderModel + exclude_fields = ("fcm_token",) + + # MARK: - Query class Query(graphene.ObjectType): get_all_gyms = graphene.List(Gym, description="Get all gyms.") get_user_by_net_id = graphene.List(User, net_id=graphene.String(), description="Get user by Net ID.") + get_users_friends = graphene.List(User, id=graphene.Int(), description="Get all friends of a user by ID.") get_users_by_giveaway_id = graphene.List(User, id=graphene.Int(), description="Get all users given a giveaway ID.") get_weekly_workout_days = graphene.List( graphene.String, id=graphene.Int(), description="Get the days a user worked out for the current week." @@ -251,13 +309,31 @@ 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_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." ) + get_user_friends = graphene.List( + 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." + ) def resolve_get_all_gyms(self, info): query = Gym.get_query(info) @@ -273,6 +349,13 @@ def resolve_get_user_by_net_id(self, info, net_id): raise GraphQLError("User with the given Net ID does not exist.") return user + def resolve_get_users_friends(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.") + friends = user.get_friends() + return friends + def resolve_get_users_by_giveaway_id(self, info, id): entries = GiveawayInstance.get_query(info).filter(GiveawayInstanceModel.giveaway_id == id).all() users = [User.get_query(info).filter(UserModel.id == entry.user_id).first() for entry in entries] @@ -361,7 +444,6 @@ def resolve_get_user_streak(self, info, id): return {"active_streak": active_streak, "max_streak": max_streak} - def resolve_get_hourly_average_capacities_by_facility_id(self, info, facility_id): valid_facility_ids = [14492437, 8500985, 7169406, 10055021, 2323580, 16099753, 15446768, 12572681] if facility_id not in valid_facility_ids: @@ -369,6 +451,46 @@ def resolve_get_hourly_average_capacities_by_facility_id(self, info, facility_id query = HourlyAverageCapacity.get_query(info).filter(HourlyAverageCapacityModel.facility_id == facility_id) return query.all() + @jwt_required() + def resolve_get_user_friends(self, info, user_id): + user = User.get_query(info).filter(UserModel.id == user_id).first() + if not user: + 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() + + # Reverse friendships where user is the recipient + 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: + friend_ids.add(friendship.friend_id) + + for friendship in reverse_friendships: + friend_ids.add(friendship.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() + + if not reminder: + 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) + return query.all() + # MARK: - Mutation @@ -442,10 +564,7 @@ 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 - } + 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) @@ -463,7 +582,8 @@ def mutate(self, info, name, net_id, email, encoded_image=None): db_session.commit() return new_user - + + class EditUser(graphene.Mutation): class Arguments: name = graphene.String(required=False) @@ -477,7 +597,7 @@ 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() if not existing_user: raise GraphQLError("User with given net id does not exist.") - + if name is not None: existing_user.name = name if email is not None: @@ -489,12 +609,12 @@ def mutate(self, info, net_id, name=None, email=None, encoded_image=None): payload = { "bucket": os.getenv("BUCKET_NAME", "DEV_BUCKET"), - "image": encoded_image # Base64-encoded image string + "image": encoded_image, # Base64-encoded image string } headers = {"Content-Type": "application/json"} - + print(f"Uploading image with payload: {payload}") - + try: response = requests.post(upload_url, json=payload, headers=headers) response.raise_for_status() @@ -511,6 +631,7 @@ def mutate(self, info, net_id, name=None, email=None, encoded_image=None): db_session.commit() return existing_user + class EnterGiveaway(graphene.Mutation): class Arguments: user_net_id = graphene.String(required=True) @@ -560,6 +681,51 @@ 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.") + friend_net_id = graphene.String(required=True, description="The Net ID of the friend to add.") + + Output = User + + def mutate(self, info, user_net_id, friend_net_id): + user = User.get_query(info).filter(UserModel.net_id == user_net_id).first() + if not user: + raise GraphQLError("User with given NetID does not exist.") + + friend = User.get_query(info).filter(UserModel.net_id == friend_net_id).first() + if not friend: + raise GraphQLError("Friend with given NetID does not exist.") + + # Add friend + if friend not in user.friends: + user.add_friend(friend) + + 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.") + friend_net_id = graphene.String(required=True, description="The Net ID of the friend to remove.") + + Output = User + + def mutate(self, info, user_net_id, friend_net_id): + user = User.get_query(info).filter(UserModel.net_id == user_net_id).first() + if not user: + raise GraphQLError("User with given NetID does not exist.") + + friend = User.get_query(info).filter(UserModel.net_id == friend_net_id).first() + if not friend: + raise GraphQLError("Friend with given NetID does not exist.") + + # Remove friend + if friend in user.friends: + user.remove_friend(friend) + + db_session.commit() + return user class SetWorkoutGoals(graphene.Mutation): class Arguments: @@ -663,6 +829,244 @@ def mutate(self, info, user_id): return user +class CreateCapacityReminder(graphene.Mutation): + class Arguments: + fcm_token = graphene.String(required=True) + gyms = graphene.List(graphene.String, required=True) + days_of_week = graphene.List(graphene.String, required=True) + capacity_percent = graphene.Int(required=True) + + Output = CapacityReminder + + def mutate(self, info, fcm_token, days_of_week, gyms, capacity_percent): + if capacity_percent not in range(0, 91, 10): + raise GraphQLError("Capacity percent must be an interval of 10 from 0-90.") + + # Validate days of the week + validated_workout_days = [] + for day in days_of_week: + try: + validated_workout_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + + # Validate gyms + valid_gyms = [] + for gym in gyms: + try: + valid_gyms.append(CapacityReminderGymGraphQLEnum[gym].value) + except KeyError: + raise GraphQLError(f"Invalid gym: {gym}") + + # Subscribe to Firebase topics for each gym and day + for gym in valid_gyms: + for day in validated_workout_days: + topic_name = f"{gym}_{day}_{capacity_percent}" + try: + messaging.subscribe_to_topic(fcm_token, topic_name) + except Exception as error: + raise GraphQLError(f"Error subscribing to topic for {topic_name}: {error}") + + reminder = CapacityReminderModel( + fcm_token=fcm_token, + gyms=valid_gyms, + capacity_threshold=capacity_percent, + days_of_week=validated_workout_days, + ) + db_session.add(reminder) + db_session.commit() + + return reminder + + +class EditCapacityReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + gyms = graphene.List(graphene.String, required=True) + days_of_week = graphene.List(graphene.String, required=True) + capacity_percent = graphene.Int(required=True) + + Output = CapacityReminder + + def mutate(self, info, reminder_id, gyms, days_of_week, capacity_percent): + reminder = db_session.query(CapacityReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("CapacityReminder not found.") + + # Validate days of the week + validated_workout_days = [] + for day in days_of_week: + try: + validated_workout_days.append(DayOfWeekGraphQLEnum[day.upper()].value) + except KeyError: + raise GraphQLError(f"Invalid day of the week: {day}") + + # Validate gyms + valid_gyms = [] + for gym in gyms: + try: + valid_gyms.append(CapacityReminderGymGraphQLEnum[gym].value) + except KeyError: + raise GraphQLError(f"Invalid gym: {gym}") + + # Unsubscribe from old reminders + topics = [ + f"{gym}_{day}_{reminder.capacity_threshold}" for gym in reminder.gyms for day in reminder.days_of_week + ] + + for topic in topics: + try: + messaging.unsubscribe_from_topic(reminder.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error subscribing to topic: {error}") + + # Subscribe to new reminders + topics = [f"{gym}_{day}_{reminder.capacity_threshold}" for gym in valid_gyms for day in validated_workout_days] + + for topic in topics: + try: + messaging.subscribe_to_topic(reminder.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error subscribing to topic: {error}") + + reminder.gyms = valid_gyms + reminder.days_of_week = validated_workout_days + reminder.capacity_threshold = capacity_percent + + db_session.commit() + return reminder + + +class DeleteCapacityReminder(graphene.Mutation): + class Arguments: + reminder_id = graphene.Int(required=True) + + Output = CapacityReminder + + def mutate(self, info, reminder_id): + reminder = db_session.query(CapacityReminderModel).filter_by(id=reminder_id).first() + if not reminder: + raise GraphQLError("CapacityReminder not found.") + + topics = [ + f"{gym}_{day}_{reminder.capacity_threshold}" for gym in reminder.gyms for day in reminder.days_of_week + ] + + for topic in topics: + try: + messaging.unsubscribe_from_topic(reminder.fcm_token, topic) + except Exception as error: + raise GraphQLError(f"Error unsubscribing from topic {topic}: {error}") + + db_session.delete(reminder) + db_session.commit() + + return reminder + +class AddFriend(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + friend_id = graphene.Int(required=True) + + Output = Friendship + + @jwt_required() + def mutate(self, info, user_id, friend_id): + # Check if users exist + user = User.get_query(info).filter(UserModel.id == user_id).first() + if not user: + raise GraphQLError("User with given ID does not exist.") + + friend = User.get_query(info).filter(UserModel.id == friend_id).first() + if not friend: + 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() + + if existing: + raise GraphQLError("Friendship already exists.") + + # Create new friendship (not automatically accepted) + friendship = FriendshipModel(user_id=user_id, friend_id=friend_id) + db_session.add(friendship) + db_session.commit() + + return friendship + +class AcceptFriendRequest(graphene.Mutation): + class Arguments: + friendship_id = graphene.Int(required=True) + + Output = Friendship + + @jwt_required() + def mutate(self, info, friendship_id): + # Find the friendship + friendship = Friendship.get_query(info).filter(FriendshipModel.id == friendship_id).first() + if not friendship: + raise GraphQLError("Friendship not found.") + + # Check if already accepted + if friendship.is_accepted: + raise GraphQLError("Friendship already accepted.") + + # Accept friendship + friendship.is_accepted = True + friendship.accepted_at = datetime.utcnow() + db_session.commit() + + return friendship + +class RemoveFriend(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + friend_id = graphene.Int(required=True) + + success = graphene.Boolean() + + @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() + + if not friendship: + raise GraphQLError("Friendship not found.") + + # Delete friendship + db_session.delete(friendship) + db_session.commit() + + return RemoveFriend(success=True) + +class GetPendingFriendRequests(graphene.Mutation): + class Arguments: + user_id = graphene.Int(required=True) + + pending_requests = graphene.List(Friendship) + + @jwt_required() + 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.") + + # 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() + + return GetPendingFriendRequests(pending_requests=pending) + + class Mutation(graphene.ObjectType): create_giveaway = CreateGiveaway.Field(description="Creates a new giveaway.") create_user = CreateUser.Field(description="Creates a new user.") @@ -675,6 +1079,16 @@ class Mutation(graphene.ObjectType): refresh_access_token = RefreshAccessToken.Field(description="Refreshes the access token.") create_report = CreateReport.Field(description="Creates a new report.") 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.") + 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") + 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.") + get_pending_friend_requests = GetPendingFriendRequests.Field( + description="Get all pending friend requests for a user.") schema = graphene.Schema(query=Query, mutation=Mutation) diff --git a/src/scrapers/capacities_scraper.py b/src/scrapers/capacities_scraper.py index 8e58857..7e6157b 100644 --- a/src/scrapers/capacities_scraper.py +++ b/src/scrapers/capacities_scraper.py @@ -1,11 +1,14 @@ import requests +import time from bs4 import BeautifulSoup from collections import namedtuple from datetime import datetime from src.database import db_session +from src.utils.messaging import send_capacity_reminder from src.models.capacity import Capacity from src.models.hourly_average_capacity import HourlyAverageCapacity -from src.models.enums import DayOfWeekEnum +from src.models.facility import Facility +from src.models.enums import DayOfWeekEnum, CapacityReminderGym from src.utils.constants import ( C2C_URL, CRC_URL_NEW, @@ -52,13 +55,12 @@ def fetch_capacities_old(): # Add to sheets add_single_capacity(count, facility_id, percent, updated) + # New scraper from new API using CRC_URL_NEW def fetch_capacities(): """Fetch capacities from the new JSON API endpoint.""" try: - headers = { - "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0" - } + headers = {"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.9; rv:32.0) Gecko/20100101 Firefox/32.0"} response = requests.get(CRC_URL_NEW, headers=headers) facilities = response.json() @@ -82,6 +84,33 @@ def fetch_capacities(): percent = count / total_capacity if total_capacity > 0 else 0.0 updated = datetime.strptime(updated_str.split(".")[0], "%Y-%m-%dT%H:%M:%S") + gym_mapping = { + "HNH Fitness Center": CapacityReminderGym.HELENNEWMAN, + "Noyes Fitness Center": CapacityReminderGym.NOYES, + "Teagle Down Fitness Center": CapacityReminderGym.TEAGLEDOWN, + "Teagle Up Fitness Center": CapacityReminderGym.TEAGLEUP, + "Morrison Fitness Center": CapacityReminderGym.TONIMORRISON, + } + + last_capacity = Capacity.query.filter_by(facility_id=facility_id).first() + last_percent = last_capacity.percent if last_capacity else 0 + + if db_name in gym_mapping: + # Check if facility is closed + facility = Facility.query.filter_by(id=facility_id).first() + + if not facility or not facility.hours: + print(f"Warning: No hours found for facility ID {facility_id}") + continue + + current_time = int(time.time()) + + 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] + check_and_send_capacity_reminders(topic_enum.name, db_name, percent, last_percent) + add_single_capacity(count, facility_id, percent, updated) except Exception as e: @@ -91,6 +120,7 @@ def fetch_capacities(): print(f"Error fetching capacities: {str(e)}") raise + def add_single_capacity(count, facility_id, percent, updated): """ Add a single capacity to the database. @@ -136,7 +166,15 @@ def update_hourly_capacity(curDay, curHour): for capacity in currentCapacities: try: - hourly_average_capacity = db_session.query(HourlyAverageCapacity).filter(HourlyAverageCapacity.facility_id == capacity.facility_id, HourlyAverageCapacity.day_of_week == DayOfWeekEnum[curDay].value, HourlyAverageCapacity.hour_of_day == curHour).first() + hourly_average_capacity = ( + db_session.query(HourlyAverageCapacity) + .filter( + HourlyAverageCapacity.facility_id == capacity.facility_id, + HourlyAverageCapacity.day_of_week == DayOfWeekEnum[curDay].value, + HourlyAverageCapacity.hour_of_day == curHour, + ) + .first() + ) if hourly_average_capacity is not None: print("updating average") @@ -148,7 +186,7 @@ def update_hourly_capacity(curDay, curHour): average_percent=capacity.percent, hour_of_day=curHour, day_of_week=DayOfWeekEnum[curDay].value, - history=[capacity.percent] + history=[capacity.percent], ) db_session.merge(hourly_average_capacity) @@ -156,4 +194,25 @@ def update_hourly_capacity(curDay, curHour): except Exception as e: print(f"Error updating hourly average: {e}") - + + +def check_and_send_capacity_reminders(facility_name, readable_name, current_percent, last_percent): + """ + Send notifications to topic if the current capacity dips below relevant thresholds. + """ + + current_percent_int = int(current_percent * 100) # Convert to integer percentage + last_percent_int = int(last_percent * 100) + + current_day_name = datetime.now().strftime("%A").upper() + + # Define threshold levels + thresholds = [0, 10, 20, 30, 40, 50, 60, 70, 80, 90] + + # Find the thresholds that were crossed + crossed_thresholds = [p for p in thresholds if last_percent_int > p >= current_percent_int] + + for threshold in crossed_thresholds: + topic_name = f"{facility_name}_{current_day_name}_{threshold}" + print(f"Sending message to devices subscribed to {topic_name}") + send_capacity_reminder(topic_name, readable_name, threshold) diff --git a/src/scrapers/sp_hours_scraper.py b/src/scrapers/sp_hours_scraper.py index 01e2af0..645c981 100644 --- a/src/scrapers/sp_hours_scraper.py +++ b/src/scrapers/sp_hours_scraper.py @@ -102,6 +102,10 @@ def add_special_facility_hours(start_time, end_time, facility_id, court_type=Non # Convert datetime objects to Unix start_unix = unix_time(start_time) end_unix = unix_time(end_time) + + if start_unix == end_unix: + print(f"Skipping special hours because times are equal: start_unix={start_unix}, end_unix={end_unix}, facility_id={facility_id}") + return print(f"Adding special hours: start_unix={start_unix}, end_unix={end_unix}, facility_id={facility_id}, is_special=True") diff --git a/src/utils/messaging.py b/src/utils/messaging.py new file mode 100644 index 0000000..e23bee5 --- /dev/null +++ b/src/utils/messaging.py @@ -0,0 +1,24 @@ +import logging +from firebase_admin import messaging + + +def send_capacity_reminder(topic_name, facility_name, current_percent): + """ + Send a capacity reminder to the user. + Parameters: + - `topic_name`: The topic to send the notification to. + - `facility_name`: The gym facility's name. + - `current_percent`: The current capacity percentage. + """ + message = messaging.Message( + notification=messaging.Notification( + title="Gym Capacity Update", body=f"The capacity for {facility_name} is now below {current_percent}%." + ), + topic=topic_name, + ) + + try: + response = messaging.send(message) + logging.info(f"Message sent to {topic_name}: {response}") + except Exception as e: + logging.error(f"Error sending message to {topic_name}: {e}") \ No newline at end of file