Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The documentation states uploads are 'immediately eligible for daily rotation,' but doesn't clarify that the empty-table guard could cause a 'database error' state during the transition when the first image is added. Consider adding a note that at least one picture must exist before the game can function properly.

Suggested change
- New uploads are immediately eligible for daily rotation. Rotation uses the total count of pictures and contiguous `pictureid` assignment.
- New uploads are immediately eligible for daily rotation. Rotation uses the total count of pictures and contiguous `pictureid` assignment.
- **Important:** At least one image must exist in the database before the game can function properly. If the table is empty, the game may enter a database error state until an image is uploaded.

Copilot uses AI. Check for mistakes.
- 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.
30 changes: 30 additions & 0 deletions alembic/versions/5b7f4f0b6a1e_add_admin_column_to_users.py
Original file line number Diff line number Diff line change
@@ -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')

127 changes: 125 additions & 2 deletions app.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
import os
import dotenv
from sys import path
import cloudinary
import cloudinary.uploader

# Tiger Spot files
path.append("src")
Expand All @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -775,6 +790,114 @@ def submit2():
# -----------------------------------------------------------------------


# Admin guard for JSON APIs
def admin_required(fn):
def wrapper(*args, **kwargs):
Comment on lines +794 to +795
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The decorator doesn't use functools.wraps, which is the standard way to preserve function metadata. While __name__ is manually set on line 808, using @functools.wraps(fn) is more comprehensive and maintains other metadata like __doc__. Import functools and add @functools.wraps(fn) before the wrapper definition.

Copilot uses AI. Check for mistakes.
# 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},
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The context dictionary keys use PascalCase ('Latitude', 'Longitude', 'Place') which is inconsistent with Python naming conventions. Consider using lowercase keys ('latitude', 'longitude', 'place') unless PascalCase is specifically required by existing Cloudinary metadata conventions.

Suggested change
context={"Latitude": str(lat), "Longitude": str(lon), "Place": place},
context={"latitude": str(lat), "longitude": str(lon), "place": place},

Copilot uses AI. Check for mistakes.
)
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():
Expand All @@ -798,6 +921,7 @@ def versus_stats():
# -----------------------------------------------------------------------


# Lightweight health check endpoint for ops/monitoring
@app.route("/health", methods=["GET"])
def health_check():
try:
Expand All @@ -808,5 +932,4 @@ def health_check():
return f"Database connection error: {e}", 500


if __name__ == "__main__":
app.run(host="localhost", port=3000)
# -----------------------------------------------------------------------
44 changes: 42 additions & 2 deletions src/Databases/pictures_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

# -----------------------------------------------------------------------

Expand Down Expand Up @@ -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"
Comment on lines 55 to +59
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The empty-table guard is placed after the return statement in the exception handler. It should be moved before line 61 where picture_count is used in the modulo operation. The current placement makes this guard unreachable in the normal execution flow.

Copilot uses AI. Check for mistakes.

picture_id = (day_of_year - 1) % picture_count + 1
return picture_id
Expand All @@ -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)
Copy link

Copilot AI Nov 19, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing session.commit() after adding the new picture. Without committing, the transaction won't be persisted to the database. Add session.commit() after line 105.

Suggested change
session.add(new_picture)
session.add(new_picture)
session.commit()

Copilot uses AI. Check for mistakes.

# 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__":
Expand Down
20 changes: 20 additions & 0 deletions src/Databases/user_database.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__":
Expand Down
1 change: 1 addition & 0 deletions src/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<User(username={self.username}, points={self.points})>"
Expand Down