From 9a99751028e98d21a45316db046840a5520d1364 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Nov 2025 13:15:47 -0500 Subject: [PATCH 1/4] feat(api): admin image upload via Cloudinary; users.admin migration; empty-table guard; docs --- README.md | 45 ++++++ .../5b7f4f0b6a1e_add_admin_column_to_users.py | 30 ++++ app.py | 137 ++++++++++++++++-- src/Databases/pictures_database.py | 41 +++++- src/Databases/user_database.py | 20 +++ src/models.py | 2 +- 6 files changed, 258 insertions(+), 17 deletions(-) create mode 100644 alembic/versions/5b7f4f0b6a1e_add_admin_column_to_users.py diff --git a/README.md b/README.md index 0b7a5b3..9087acd 100644 --- a/README.md +++ b/README.md @@ -66,6 +66,51 @@ alembic upgrade head Run the development server with `python3 dev.py`. You can access the web app at `http://localhost:5173`. +### Admin Image Upload API + +This API allows admins to upload an image with associated location data. Images are stored in Cloudinary under the folder `TigerSpot/Checked`, consistent with the existing seeding flow. + +1) Add the `admin` column and migrate the database (first time only): + +```bash +alembic upgrade head +``` + +If you pulled or created a migration for the `admin` column, ensure it is applied. To promote a user to admin: + +```sql +UPDATE users SET admin = TRUE WHERE username = 'your_netid'; +``` + +2) Ensure Cloudinary env vars are set in `.env` (see `.env.example`). + +3) Upload an image (admin only) via curl: + +```bash +curl -X POST \ + -F "file=@/absolute/path/to/image.jpg" \ + -F "place=Frist Campus Center" \ + -F "latitude=40.349" \ + -F "longitude=-74.660" \ + http://localhost:5173/api/images +``` + +Response example: + +```json +{ + "id": 42, + "link": "http://res.cloudinary.com/.../image/upload/v.../tigerspot.jpg", + "place": "Frist Campus Center", + "coordinates": [40.349, -74.66] +} +``` + +Notes: +- New uploads are immediately eligible for daily rotation. Rotation uses the total count of pictures and contiguous `pictureid` assignment. +- The API sets Cloudinary `context` metadata (`Latitude`, `Longitude`, `Place`) to match the seeding script conventions. +- If moderation is needed later, add an `approved` column to `pictures` and change the daily selection to filter on approved images. + ## License This project is licensed under the BSD 3-Clause License - see the [LICENSE](LICENSE) file for details. diff --git a/alembic/versions/5b7f4f0b6a1e_add_admin_column_to_users.py b/alembic/versions/5b7f4f0b6a1e_add_admin_column_to_users.py new file mode 100644 index 0000000..00e7868 --- /dev/null +++ b/alembic/versions/5b7f4f0b6a1e_add_admin_column_to_users.py @@ -0,0 +1,30 @@ +"""Add admin column to users + +Revision ID: 5b7f4f0b6a1e +Revises: 2df915a8c6ec +Create Date: 2025-11-10 00:00:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '5b7f4f0b6a1e' +down_revision: Union[str, None] = '2df915a8c6ec' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # Add admin column with server default false for existing rows + op.add_column('users', sa.Column('admin', sa.Boolean(), nullable=False, server_default=sa.false())) + # Optional: drop the server default after backfilling to keep model default-only + op.alter_column('users', 'admin', server_default=None) + + +def downgrade() -> None: + op.drop_column('users', 'admin') + diff --git a/app.py b/app.py index 3d65cf8..f0249d1 100644 --- a/app.py +++ b/app.py @@ -10,6 +10,8 @@ import os import dotenv from sys import path +import cloudinary +import cloudinary.uploader # Tiger Spot files path.append("src") @@ -32,6 +34,13 @@ app = Flask(__name__, template_folder="./templates", static_folder="./static") app.secret_key = os.environ["APP_SECRET_KEY"] +# Configure Cloudinary (for image uploads) +cloudinary.config( + cloud_name=os.environ.get("CLOUDINARY_CLOUD_NAME"), + api_key=os.environ.get("CLOUDINARY_API_KEY"), + api_secret=os.environ.get("CLOUDINARY_API_SECRET"), +) + # ----------------------------------------------------------------------- # default value for id needed for daily reset @@ -156,6 +165,9 @@ def requests(): def game(): id = pictures_database.pic_of_day() + if id == "database error": + html_code = flask.render_template("contact_admin.html") + return flask.make_response(html_code) username = auth.authenticate() @@ -198,6 +210,9 @@ def game(): @app.route("/submit", methods=["POST"]) def submit(): id = pictures_database.pic_of_day() + if id == "database error": + html_code = flask.render_template("contact_admin.html") + return flask.make_response(html_code) username = auth.authenticate() user_played = daily_user_database.player_played(username) @@ -775,6 +790,114 @@ def submit2(): # ----------------------------------------------------------------------- +# Admin guard for JSON APIs +def admin_required(fn): + def wrapper(*args, **kwargs): + # Follow existing auth pattern: let CAS handle redirects if unauthenticated + username = auth.authenticate() + + if not user_database.is_admin(username): + return ( + flask.jsonify({"error": {"message": "Forbidden: Admins only"}}), + 403, + ) + + return fn(*args, **kwargs) + + # Preserve function name for Flask routing + wrapper.__name__ = fn.__name__ + return wrapper + + +# ----------------------------------------------------------------------- + + +# Admin-only API to upload an image with location data +@app.route("/api/images", methods=["POST"]) +@admin_required +def upload_image(): + # Validate form fields + if "file" not in flask.request.files: + return ( + flask.jsonify({"error": {"message": "Missing file in request"}}), + 400, + ) + file = flask.request.files["file"] + place = flask.request.form.get("place") + lat_raw = flask.request.form.get("latitude") + lon_raw = flask.request.form.get("longitude") + + if not file or file.filename == "": + return ( + flask.jsonify({"error": {"message": "Empty or missing file"}}), + 400, + ) + if not place or lat_raw is None or lon_raw is None: + return ( + flask.jsonify( + { + "error": { + "message": "Missing required fields: place, latitude, longitude", + } + } + ), + 400, + ) + + # Parse coordinates + try: + lat = float(lat_raw) + lon = float(lon_raw) + except ValueError: + return ( + flask.jsonify({"error": {"message": "Invalid latitude/longitude"}}), + 400, + ) + if not (-90.0 <= lat <= 90.0 and -180.0 <= lon <= 180.0): + return ( + flask.jsonify({"error": {"message": "Latitude/longitude out of range"}}), + 400, + ) + + # Upload to Cloudinary (use existing folder convention) + try: + upload_result = cloudinary.uploader.upload( + file, + folder="TigerSpot/Checked", + context={"Latitude": str(lat), "Longitude": str(lon), "Place": place}, + ) + except Exception as ex: + return ( + flask.jsonify({"error": {"message": f"Upload failed: {str(ex)}"}}), + 502, + ) + + link = upload_result.get("url") or upload_result.get("secure_url") + if not link: + return ( + flask.jsonify({"error": {"message": "Upload missing URL"}}), + 502, + ) + + # Persist to DB with contiguous pictureid (works for both providers) + created = pictures_database.create_picture([lat, lon], link, place) + if created == "database error": + return ( + flask.jsonify({"error": {"message": "Database insert failed"}}), + 500, + ) + + response = { + "id": created["pictureid"], + "link": created["link"], + "place": created["place"], + "coordinates": created["coordinates"], + } + return flask.jsonify(response), 201 + + +# ----------------------------------------------------------------------- + # Displays the results of a versus mode game @app.route("/versus_stats", methods=["POST"]) def versus_stats(): @@ -796,17 +919,3 @@ def versus_stats(): # ----------------------------------------------------------------------- - - -@app.route("/health", methods=["GET"]) -def health_check(): - try: - with get_session() as session: - session.execute("SELECT 1") - return "OK", 200 - except Exception as e: - return f"Database connection error: {e}", 500 - - -if __name__ == "__main__": - app.run(host="localhost", port=3000) diff --git a/src/Databases/pictures_database.py b/src/Databases/pictures_database.py index fdce8c3..7c09dfa 100644 --- a/src/Databases/pictures_database.py +++ b/src/Databases/pictures_database.py @@ -5,10 +5,9 @@ import datetime import pytz -from sqlalchemy import func - from src.db import get_session from src.models import Picture +from sqlalchemy import func # ----------------------------------------------------------------------- @@ -55,6 +54,9 @@ def pic_of_day(): except Exception as error: print(error) return 1 + # Guard against empty table to avoid modulo by zero + if not picture_count or int(picture_count) == 0: + return "database error" picture_id = (day_of_year - 1) % picture_count + 1 return picture_id @@ -80,6 +82,41 @@ def get_pic_info(col, id): return "database error" +# ----------------------------------------------------------------------- + +def create_picture(coordinates, link, place): + """ + Create a new picture row assigning the next contiguous pictureid. + Returns a dict with the created picture fields on success, + or "database error" on failure. + """ + try: + with get_session() as session: + # Determine next id (contiguous allocation) + current_max = session.query(func.max(Picture.pictureid)).scalar() + next_id = 1 if current_max is None else int(current_max) + 1 + + new_picture = Picture( + pictureid=next_id, + coordinates=coordinates, + link=link, + place=place, + ) + session.add(new_picture) + + # Build return value + return { + "pictureid": next_id, + "coordinates": coordinates, + "link": link, + "place": place, + } + + except Exception as error: + print(error) + return "database error" + + # ----------------------------------------------------------------------- if __name__ == "__main__": diff --git a/src/Databases/user_database.py b/src/Databases/user_database.py index 1f5f233..a1b9796 100644 --- a/src/Databases/user_database.py +++ b/src/Databases/user_database.py @@ -227,6 +227,26 @@ def get_top_player(): return "database error" +# ----------------------------------------------------------------------- + +# Returns whether the given username is an admin. + + +def is_admin(username): + try: + with get_session() as session: + user = session.query(User).filter_by(username=username).first() + + if user is None: + return False + + return bool(getattr(user, "admin", False)) + + except Exception as error: + print(error) + return False + + # ----------------------------------------------------------------------- if __name__ == "__main__": diff --git a/src/models.py b/src/models.py index 0185fe8..88f8d0f 100644 --- a/src/models.py +++ b/src/models.py @@ -18,6 +18,7 @@ class User(Base): username = Column(String(255), primary_key=True) points = Column(Integer, default=0) + admin = Column(Boolean, default=False) def __repr__(self): return f"" @@ -35,7 +36,6 @@ class UserDaily(Base): points = Column(Integer, default=0) distance = Column(Integer, default=0) played = Column(Boolean, default=False) - first_played = Column(Date, nullable=True) last_played = Column(Date, nullable=True) last_versus = Column(Date, nullable=True) current_streak = Column(Integer, default=0) From d4477b4c10d06d66211b0bff656f20d54f055263 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Nov 2025 19:51:55 -0500 Subject: [PATCH 2/4] fix(models): restore first_played on UserDaily to match insert code --- src/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/models.py b/src/models.py index 88f8d0f..32ee661 100644 --- a/src/models.py +++ b/src/models.py @@ -36,6 +36,7 @@ class UserDaily(Base): points = Column(Integer, default=0) distance = Column(Integer, default=0) played = Column(Boolean, default=False) + first_played = Column(Date, nullable=True) last_played = Column(Date, nullable=True) last_versus = Column(Date, nullable=True) current_streak = Column(Integer, default=0) From 2528eab48db9e1e415108d3eee049d9142b480fd Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Nov 2025 20:01:51 -0500 Subject: [PATCH 3/4] fix(pictures): serialize pictureid allocation with table lock to avoid MAX+1 race --- src/Databases/pictures_database.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/Databases/pictures_database.py b/src/Databases/pictures_database.py index 7c09dfa..40d4961 100644 --- a/src/Databases/pictures_database.py +++ b/src/Databases/pictures_database.py @@ -7,7 +7,7 @@ import pytz from src.db import get_session from src.models import Picture -from sqlalchemy import func +from sqlalchemy import func, text # ----------------------------------------------------------------------- @@ -92,7 +92,10 @@ def create_picture(coordinates, link, place): """ try: with get_session() as session: - # Determine next id (contiguous allocation) + # Serialize writers to avoid race on MAX(pictureid) + 1 + # Locking the table is acceptable here since admin uploads are infrequent. + session.execute(text("LOCK TABLE pictures IN EXCLUSIVE MODE")) + current_max = session.query(func.max(Picture.pictureid)).scalar() next_id = 1 if current_max is None else int(current_max) + 1 From 2622ed946c24d74d93ad23e4f59b2828e6e0e455 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 18 Nov 2025 20:03:57 -0500 Subject: [PATCH 4/4] chore(ops): restore /health endpoint for DB connectivity probe --- app.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/app.py b/app.py index f0249d1..dd20882 100644 --- a/app.py +++ b/app.py @@ -919,3 +919,17 @@ def versus_stats(): # ----------------------------------------------------------------------- + + +# Lightweight health check endpoint for ops/monitoring +@app.route("/health", methods=["GET"]) +def health_check(): + try: + with get_session() as session: + session.execute("SELECT 1") + return "OK", 200 + except Exception as e: + return f"Database connection error: {e}", 500 + + +# -----------------------------------------------------------------------