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..dd20882 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(): @@ -798,6 +921,7 @@ def versus_stats(): # ----------------------------------------------------------------------- +# Lightweight health check endpoint for ops/monitoring @app.route("/health", methods=["GET"]) def health_check(): try: @@ -808,5 +932,4 @@ def health_check(): 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..40d4961 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, text # ----------------------------------------------------------------------- @@ -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,44 @@ 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: + # 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 + + 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..32ee661 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""