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/.github/workflows/deploy-dev.yml b/.github/workflows/deploy-dev.yml index da074eb..a062369 100644 --- a/.github/workflows/deploy-dev.yml +++ b/.github/workflows/deploy-dev.yml @@ -40,10 +40,9 @@ 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 -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 - sleep 60s - yes | docker system prune -a + 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 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 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 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/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 + ) 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/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 ### diff --git a/migrations/versions/b09a4d8151d6_merge_diverging_branches.py b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py new file mode 100644 index 0000000..b2c3382 --- /dev/null +++ b/migrations/versions/b09a4d8151d6_merge_diverging_branches.py @@ -0,0 +1,24 @@ +"""Merge diverging branches + +Revision ID: b09a4d8151d6 +Revises: eb948c31a342, 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 = ('eb948c31a342', '48923aecacb0') +branch_labels = None +depends_on = None + + +def upgrade(): + pass + + +def downgrade(): + pass diff --git a/migrations/versions/eb948c31a342_create_gear_table.py b/migrations/versions/eb948c31a342_create_gear_table.py new file mode 100644 index 0000000..28a8392 --- /dev/null +++ b/migrations/versions/eb948c31a342_create_gear_table.py @@ -0,0 +1,42 @@ +"""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 +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision = 'eb948c31a342' +down_revision = '6fb4a21a1201' +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.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") diff --git a/requirements.txt b/requirements.txt index c6219de..7e8c8ce 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/schema.graphql b/schema.graphql index a161ce6..70d5f9d 100644 --- a/schema.graphql +++ b/schema.graphql @@ -187,8 +187,6 @@ type HourlyAverageCapacity { history: [Float]! } -scalar JSONString - type LoginUser { accessToken: String refreshToken: String @@ -218,12 +216,13 @@ 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(userId: Int!, workoutGoal: Int!): User logWorkout(facilityId: Int!, userId: Int!, workoutTime: DateTime!): Workout loginUser(netId: String!): LoginUser 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 @@ -269,8 +268,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 @@ -307,15 +304,21 @@ type User { email: String netId: String! name: String! - activeStreak: Int - maxStreak: Int - workoutGoal: [DayOfWeekGraphQLEnum] + activeStreak: Int! + maxStreak: Int! + workoutGoal: Int + lastGoalChange: DateTime + lastStreak: Int! encodedImage: String giveaways: [Giveaway] + goalHistory: [WorkoutGoalHistory] friendRequestsSent: [Friendship] friendRequestsReceived: [Friendship] friendships: [Friendship] friends: [User] + totalGymDays: Int! + streakStart: DateTime + workoutHistory: [Workout] } type Workout { @@ -323,4 +326,13 @@ type Workout { workoutTime: DateTime! userId: Int! facilityId: Int! + gymName: String! +} + +type WorkoutGoalHistory { + id: ID! + userId: Int! + workoutGoal: Int! + effectiveAt: DateTime! + user: User } 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/models/user.py b/src/models/user.py index 98ffb39..42fbde8 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,11 +31,15 @@ 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) + 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") @@ -41,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/user_workout_goal_history.py b/src/models/user_workout_goal_history.py new file mode 100644 index 0000000..31408a5 --- /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, timezone + +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(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 946b6f8..2b5224e 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): @@ -8,7 +9,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 @@ -17,6 +18,8 @@ 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) + + user = relationship("User", back_populates='workouts') diff --git a/src/schema.py b/src/schema.py index d17ca30..f1bd193 100644 --- a/src/schema.py +++ b/src/schema.py @@ -1,8 +1,11 @@ +import binascii + 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 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 @@ -24,18 +27,72 @@ 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.utils.constants import get_digital_ocean_s3_endpoint_url from src.database import db_session import requests -import json -import os from firebase_admin import messaging import logging +from zoneinfo import ZoneInfo +from sqlalchemy import func, cast, Date +import boto3 +from botocore.config import Config +local_tz = ZoneInfo("America/New_York") 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(local_tz) + + +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 @@ -163,7 +220,6 @@ def resolve_class_instances(self, info): # MARK: - Class Instance - class ClassInstance(SQLAlchemyObjectType): class Meta: model = ClassInstanceModel @@ -194,6 +250,14 @@ def resolve_pricing(self, info): return query +class WorkoutGoalHistory(SQLAlchemyObjectType): + class Meta: + model = UserWorkoutGoalHistoryModel + + def resolve_effective_at(self, info): + return to_local_time(self.effective_at) + + # MARK: - User @@ -201,9 +265,254 @@ class User(SQLAlchemyObjectType): class Meta: model = UserModel - workout_goal = graphene.List(DayOfWeekGraphQLEnum) 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." + ) + 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 ( + 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: + return self.active_streak + + 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] + + 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_hist: + if not self.workout_goal: + return 0 + goal_hist = [(self.workout_goal, datetime.min)] + + today = datetime.now(timezone.utc).date() + + day_pointer, total_workout_days = 0, len(workout_dates) + window_end = today + + streak = 0 + + while day_pointer < total_workout_days: + window_start = window_end - timedelta(days=6) + + 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 + + goal_days = goal_at(goal_hist, window_start) + + if count_in_window == 0: + break + elif count_in_window >= goal_days: + streak += 1 + else: + pass + + window_end -= timedelta(days=7) + day_pointer = day_iterator + + return streak + + def resolve_streak_start(self, info): + user = User.get_query(info).filter(UserModel.id == self.id).first() + if not user: + return None + + 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 None + + workout_dates = [row[0] for row in workout_date_rows] + if not workout_dates: + return None + + 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_hist: + return None + + goal_values = [goal for goal, _ in goal_hist] + goal_effective_dates = [] + for _, eff_at in goal_hist: + if eff_at.tzinfo is None: + eff_at = eff_at.replace(tzinfo=timezone.utc) + goal_effective_dates.append(eff_at.date()) + + if not goal_effective_dates: + return None + + goal_index = 0 + + def goal_for_window_start(ws_date): + nonlocal goal_index + + while goal_index < len(goal_values) - 1 and ws_date < goal_effective_dates[goal_index]: + goal_index += 1 + + if ws_date < goal_effective_dates[-1]: + return None + + return goal_values[goal_index] + + today = datetime.now(timezone.utc).date() + window_end = today + + day_pointer = 0 + total = len(workout_dates) + + idx_last_streak_start = None + + while day_pointer < total: + while day_pointer < total and workout_dates[day_pointer] > today: + day_pointer += 1 + + window_start = window_end - timedelta(days=6) + + window_goal = goal_for_window_start(window_start) + if window_goal is None: + break + + i = day_pointer + while i < total and workout_dates[i] >= window_start: + i += 1 + + count_in_window = i - day_pointer + + if count_in_window == 0: + break + + if count_in_window >= window_goal: + if i - 1 >= 0: + idx_last_streak_start = i - 1 + + window_end -= timedelta(days=7) + day_pointer = i + + if idx_last_streak_start is None: + return None + + last_streak_start_date = workout_dates[idx_last_streak_start] + 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() + if not user: + return self.max_streak + + 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] + + 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_hist: + if not self.workout_goal: + return 0 + goal_hist = [(self.workout_goal, datetime.min)] + + today = datetime.now(timezone.utc).date() + day_pointer, total_workout_dates = 0, len(workout_dates) + window_end = today + + run_met_goal = 0 + max_met_goal = 0 + + while day_pointer < total_workout_dates: + while day_pointer < total_workout_dates and workout_dates[day_pointer] > today: + day_pointer += 1 + + window_start = window_end - timedelta(days=6) + + 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 + + goal_days = goal_at(goal_hist, window_start) + + 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: + run_met_goal += 1 + else: + pass + + window_end -= timedelta(days=7) + + day_pointer = day_iterator + + 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 @@ -239,6 +548,7 @@ class UserInput(graphene.InputObjectType): # MARK: - Friendship + class Friendship(SQLAlchemyObjectType): class Meta: model = FriendshipModel @@ -254,6 +564,10 @@ 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 @@ -277,6 +591,20 @@ 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 + + def resolve_workout_time(self, info): + return to_local_time(self.workout_time) + # MARK: - Report @@ -291,6 +619,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 @@ -299,7 +630,7 @@ class CapacityReminder(SQLAlchemyObjectType): class Meta: model = CapacityReminderModel exclude_fields = ("fcm_token",) - + # MARK: - Query @@ -315,31 +646,16 @@ 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." ) 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) @@ -381,8 +697,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 = ( @@ -394,7 +710,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) @@ -402,54 +719,6 @@ 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} - 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: @@ -464,14 +733,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 +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() @@ -491,7 +764,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) @@ -564,25 +837,64 @@ 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.") + final_photo_url = 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"} + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{net_id}-profile.png" + region = "nyc3" + + 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, + bucket, + path, + ) + 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.") + 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: + logging.info("Attempting S3 put_object for new user profile picture...") + s3 = boto3.client( + "s3", + 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"}), + ) + s3.put_object( + Bucket=bucket, + Key=path, + Body=image_data, + ContentType="image/png", + 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 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) db_session.commit() @@ -590,49 +902,82 @@ 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.Int(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 int(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: - 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"} + final_photo_url = None + bucket = "appdev-upload" + path = f"uplift-dev/user-profile/{existing_user.net_id}-profile.png" + region = "nyc3" + + 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, + existing_user.net_id, + bucket, + path, + ) - print(f"Uploading image with payload: {payload}") + 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: - 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.") + logging.info("Attempting S3 put_object for edited user profile picture...") + s3 = boto3.client( + "s3", + 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"}), + ) + s3.put_object( + Bucket=bucket, + Key=path, + Body=image_data, + ContentType="image/png", + 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 requests.exceptions.RequestException as e: - print(f"Request failed: {e}") - raise GraphQLError("Failed to upload photo.") + 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 @@ -687,6 +1032,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 +1056,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,13 +1080,12 @@ 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.") - workout_goal = graphene.List( - graphene.String, - required=True, - description="The new workout goal for the user in terms of days of the week.", + workout_goal = graphene.Int( + required=True, description="The new workout goal for the user in terms of number of days per week." ) Output = User @@ -750,22 +1096,44 @@ def mutate(self, info, user_id, workout_goal): 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}") + if user.workout_goal == workout_goal: + return user + + 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 - user.workout_goal = validated_workout_goal + if last_change_dt is None and latest_history_entry is not None: + last_change_dt = latest_history_entry.effective_at - db_session.commit() + 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.") - return user + if not has_history: + effective_at = datetime.now(timezone.utc) + else: + 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 + user.workout_goal = workout_goal + db_session.add( + UserWorkoutGoalHistoryModel(user_id=user.id, workout_goal=workout_goal, effective_at=effective_at) + ) + + db_session.commit() + return user + class logWorkout(graphene.Mutation): class Arguments: workout_time = graphene.DateTime(required=True) @@ -775,7 +1143,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.") @@ -783,7 +1153,9 @@ def mutate(self, info, workout_time, user_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() @@ -813,24 +1185,89 @@ 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() + + 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) +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) 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() + if not user: raise GraphQLError("User with given ID does not exist.") + + if int(get_jwt_identity()) != user_id: + raise GraphQLError("Unauthorized operation") + + 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: + try: + logging.info("Attempting S3 delete...") + s3 = boto3.client( + "s3", + 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"}), + ) + s3.delete_object( + Bucket="appdev-upload", + Key=f"uplift-dev/user-profile/{user.net_id}-profile.png", + ) + logging.info("S3 delete succeeded") + except Exception as e: + 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.flush() + db_session.expunge(user) db_session.commit() return user @@ -927,11 +1364,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 +1380,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 +1414,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 +1425,7 @@ def mutate(self, info, reminder_id): return reminder + class AddFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1019,10 +1445,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 +1464,7 @@ def mutate(self, info, user_id, friend_id): return friendship + class AcceptFriendRequest(graphene.Mutation): class Arguments: friendship_id = graphene.Int(required=True) @@ -1053,11 +1484,12 @@ 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 + class RemoveFriend(graphene.Mutation): class Arguments: user_id = graphene.Int(required=True) @@ -1068,10 +1500,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 +1518,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 +1533,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) @@ -1107,7 +1545,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.") @@ -1115,6 +1553,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.") @@ -1125,7 +1564,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..d24d81f 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/" @@ -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" @@ -139,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