-
Notifications
You must be signed in to change notification settings - Fork 0
admin image upload via Cloudinary #10
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
9a99751
d4477b4
2528eab
2622ed9
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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') | ||
|
|
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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): | ||||||
|
Comment on lines
+794
to
+795
|
||||||
| # 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}, | ||||||
|
||||||
| context={"Latitude": str(lat), "Longitude": str(lon), "Place": place}, | |
| context={"latitude": str(lat), "longitude": str(lon), "place": place}, |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
|
|
@@ -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" | ||||||||
|
Comment on lines
55
to
+59
|
||||||||
|
|
||||||||
| 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) | ||||||||
|
||||||||
| session.add(new_picture) | |
| session.add(new_picture) | |
| session.commit() |
There was a problem hiding this comment.
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.