diff --git a/app.py b/app.py index 8467d0d..97cf9eb 100644 --- a/app.py +++ b/app.py @@ -32,11 +32,22 @@ from sqlalchemy import ( func, ) from sqlalchemy.ext.declarative import declarative_base -from sqlalchemy.orm import sessionmaker, relationship +from sqlalchemy.orm import ( + sessionmaker, + scoped_session, + relationship, +) from waitress import serve -# Import renderers for templates -from pyramid.renderers import render_to_response +import sqlalchemy +import logging +import zope.sqlalchemy # Importing zope.sqlalchemy for transaction management + +# Configure logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) +logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO) +logging.getLogger("transaction").setLevel(logging.INFO) ################################################################################ # Database Setup @@ -47,7 +58,7 @@ Base = declarative_base() class User(Base): __tablename__ = "users" id = Column(Integer, primary_key=True) - email = Column(String, unique=True, nullable=True) # guests don't have an email. + email = Column(String, unique=True, nullable=True) # guests don't have an email username = Column(String, unique=True, nullable=False) # user-chosen handle code_hash = Column(String, nullable=True) # bcrypt hash of code code_expires = Column(DateTime, nullable=True) # time limit for code @@ -58,14 +69,14 @@ class User(Base): def get_mime_type(extension): extension = extension.lower() - if extension == 'jpg' or extension == 'jpeg': - return 'image/jpeg' - elif extension == 'png': - return 'image/png' - elif extension == 'gif': - return 'image/gif' + if extension == "jpg" or extension == "jpeg": + return "image/jpeg" + elif extension == "png": + return "image/png" + elif extension == "gif": + return "image/gif" else: - return 'application/octet-stream' + return "application/octet-stream" class Puzzle(Base): @@ -76,14 +87,21 @@ class Puzzle(Base): image_b64 = Column(String, nullable=False) image_extension = Column(String, nullable=False) - # Remove the nullable=False constraint as we'll generate these after creation initial_state_json = Column(String) solution_state_json = Column(String) title = Column(String, default="Daily Puzzle") is_visible = Column(Boolean, default=True) - def __init__(self, date, size, image_b64, image_extension, title="Daily Puzzle", is_visible=True): + def __init__( + self, + date, + size, + image_b64, + image_extension, + title="Daily Puzzle", + is_visible=True, + ): self.date = date self.size = size self.image_b64 = image_b64 @@ -121,7 +139,7 @@ class Puzzle(Base): # Update currentIndex after shuffling for idx, piece in enumerate(pieces): - piece['currentIndex'] = idx + piece["currentIndex"] = idx # Save states to the database self.initial_state_json = json.dumps(pieces) @@ -131,7 +149,7 @@ class Puzzle(Base): "uuid": piece["uuid"], "correctIndex": piece["correctIndex"], "currentIndex": piece["correctIndex"], - "rotation": 0 + "rotation": 0, } for piece in pieces ] @@ -193,10 +211,26 @@ class Attempt(Base): DB_URL = "sqlite:///puzzle_game.db" -engine = create_engine(DB_URL, echo=False) -Session = sessionmaker(bind=engine) +engine = create_engine( + DB_URL, + echo=False, + connect_args={ + "check_same_thread": False + }, # Important for SQLite in multi-threaded apps + poolclass=sqlalchemy.pool.StaticPool, # Use StaticPool for SQLite +) + +# Use scoped_session to manage sessions in a thread-safe way +SessionFactory = sessionmaker(bind=engine) +DBSession = scoped_session(SessionFactory) + +# Register the session with zope.sqlalchemy +zope.sqlalchemy.register(DBSession) + +Base.metadata.bind = engine Base.metadata.create_all(engine) + ################################################################################ # Helper Functions ################################################################################ @@ -236,7 +270,7 @@ def send_email(to_email, subject, body): def admin_required(view_func): def wrapper(request): - user = get_current_user(request) + user = request.user if not user or not user.is_admin: raise HTTPForbidden("You must be an admin to access this page.") return view_func(request) @@ -245,47 +279,52 @@ def admin_required(view_func): def get_current_user(request): - """Return the current user (authenticated or guest) from session.""" - user_id = request.session.get("user_id") - s = request.dbsession + """Return the current user (authenticated or guest) from session.""" + user_id = request.session.get("user_id") + s = request.dbsession - if user_id: - # Try to get the user from the database - user = s.query(User).filter_by(id=user_id).first() - if user: - return user - else: - # User ID in session does not exist in the database; remove it - del request.session["user_id"] + if user_id: + # Try to get the user from the database + user = s.query(User).filter_by(id=user_id).first() + if user: + return user + else: + # User ID in session does not exist in the database; remove it + del request.session["user_id"] - # No valid user in session; create a guest user - # Generate a unique guest username - while True: - suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) - guest_username = f"Guest-{suffix}" - existing_user = s.query(User).filter_by(username=guest_username).first() - if not existing_user: - break # Unique username found + # No valid user in session; create a guest user + # Generate a unique guest username + while True: + suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) + guest_username = f"Guest-{suffix}" + existing_user = s.query(User).filter_by(username=guest_username).first() + if not existing_user: + break # Unique username found - # Create a new guest user - guest_user = User( - email=guest_username, - username=guest_username, - is_verified=False, - ) - s.add(guest_user) - s.commit() + # Create a new guest user + guest_user = User( + email=guest_username, + username=guest_username, + is_verified=False, + ) + s.add(guest_user) + s.flush() # Flush to assign an ID - # Store the user ID in the session - request.session["user_id"] = guest_user.id + # Store the user ID in the session + request.session["user_id"] = guest_user.id - return guest_user + return guest_user -def get_ordered_attempts(s, puzzle_id, sort_method='time'): +def get_ordered_attempts(s, puzzle_id, sort_method="time"): """Retrieve and order attempts for a given puzzle based on the sort method.""" + time_diff = func.strftime("%s", Attempt.time_completed) - func.strftime( + "%s", Attempt.time_started + ) + total_actions = Attempt.move_count + Attempt.rotation_count + if sort_method == "moves": - # Sort by least total actions (moves + rotations), only considering first attempts + # Sort by least total actions (moves + rotations), then time attempts = ( s.query(Attempt) .filter( @@ -295,16 +334,13 @@ def get_ordered_attempts(s, puzzle_id, sort_method='time'): Attempt.attempt_number == 1, ) .order_by( - (Attempt.move_count + Attempt.rotation_count).asc(), - ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) - ).asc(), + total_actions.asc(), + time_diff.asc(), ) .all() ) else: - # Default sort by fastest time, only considering first attempts + # Default sort by fastest time, then total actions attempts = ( s.query(Attempt) .filter( @@ -314,11 +350,8 @@ def get_ordered_attempts(s, puzzle_id, sort_method='time'): Attempt.attempt_number == 1, ) .order_by( - ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) - ).asc(), - (Attempt.move_count + Attempt.rotation_count).asc(), + time_diff.asc(), + total_actions.asc(), ) .all() ) @@ -350,18 +383,19 @@ def login_post_view(request): user = session.query(User).filter_by(email=email).first() if not user: - - # No valid user in session; create a new user # Generate a unique starting username. while True: - suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8)) + suffix = "".join( + random.choices(string.ascii_uppercase + string.digits, k=8) + ) new_tmp_username = f"User-{suffix}" - existing_user = s.query(User).filter_by(username=guest_username).first() + existing_user = ( + session.query(User).filter_by(username=new_tmp_username).first() + ) if not existing_user: - # Unique username found break - # Create new user with tmp username for now + # Create new user with temp username for now user = User(email=email, username=new_tmp_username) # First user becomes root admin @@ -370,7 +404,7 @@ def login_post_view(request): user.is_admin = True session.add(user) - session.commit() + session.flush() # Flush to assign an ID # Generate 6-digit code code_str = f"{random.randint(0,999999):06d}" @@ -380,7 +414,6 @@ def login_post_view(request): user.code_hash = code_hash user.code_expires = datetime.datetime.now() + datetime.timedelta(minutes=15) user.is_verified = False - session.commit() # Send code via email email_body = f"Your verification code is: {code_str}" @@ -405,14 +438,21 @@ def verify_post_view(request): email = request.session.get("login_email") if not email: - return Response("No email found in session. Please start the login process again.", status=400) + return Response( + "No email found in session. Please start the login process again.", + status=400, + ) s = request.dbsession - user = s.query(User).filter( - User.email == email, - User.code_expires > datetime.datetime.now(), - User.code_hash != None, - ).first() + user = ( + s.query(User) + .filter( + User.email == email, + User.code_expires > datetime.datetime.now(), + User.code_hash != None, + ) + .first() + ) if not user: return Response("Code not found or expired.", status=400) @@ -423,7 +463,6 @@ def verify_post_view(request): user.is_verified = True user.code_hash = None user.code_expires = None - s.commit() # Remove the email from the session del request.session["login_email"] @@ -494,7 +533,9 @@ def profile_post_view(request): if not request.user: return Response("You must be logged in to update your profile.", status=403) if not request.user.is_verified: - return Response("This account is in guest mode, log in to update your profile.", status=403) + return Response( + "This account is in guest mode, log in to update your profile.", status=403 + ) s = request.dbsession enable_gravatar = request.POST.get("enable_gravatar") == "on" request.user.enable_gravatar = enable_gravatar @@ -508,7 +549,6 @@ def profile_post_view(request): return Response("Username is already in use.", status=400) request.user.username = new_username - s.commit() return HTTPFound(location=request.route_url("profile")) @@ -617,17 +657,25 @@ def invite_admin_post_view(request): user = s.query(User).filter_by(email=email).first() if not user: + # Generate a unique starting username. + while True: + suffix = "".join( + random.choices(string.ascii_uppercase + string.digits, k=8) + ) + new_tmp_username = f"User-{suffix}" + existing_user = s.query(User).filter_by(username=new_tmp_username).first() + if not existing_user: + break + # Create user record - user = User(email=email, is_admin=True) + user = User(email=email, username=new_tmp_username, is_admin=True) s.add(user) - s.commit() # Send email notification email_body = "You have been invited as an admin. Please log in." send_email(email, "Admin Invitation", email_body) else: if not user.is_admin: user.is_admin = True - s.commit() email_body = "You have been granted admin access." send_email(email, "Admin Access Granted", email_body) @@ -646,7 +694,7 @@ def manage_admins_view(request): } -@view_config(route_name="remove_admin") +@view_config(route_name="remove_admin", request_method="POST") @admin_required def remove_admin_view(request): s = request.dbsession @@ -658,7 +706,6 @@ def remove_admin_view(request): if not user: return Response("User not found.", status=404) user.is_admin = False - s.commit() return HTTPFound(location=request.route_url("manage_admins")) @@ -708,7 +755,6 @@ def edit_puzzle_post_view(request): puzzle.date = puzzle_date puzzle.title = title or puzzle.title - s.commit() return HTTPFound(location=request.route_url("admin_dashboard")) @@ -722,7 +768,6 @@ def toggle_visibility_view(request): if not puzzle: return Response("Puzzle not found.", status=404) puzzle.is_visible = not puzzle.is_visible - s.commit() return HTTPFound(location=request.route_url("admin_dashboard")) @@ -797,7 +842,6 @@ def upload_post_view(request): s.add(puzzle) puzzles_uploaded += 1 - s.commit() if puzzles_uploaded == 0: return Response("No puzzles were uploaded.", status=400) @@ -817,7 +861,7 @@ def daily_puzzle_view(request): if not puzzle: return {"request": request, "puzzle": None} - # Get the current user using request.user + # Get the current user user = request.user # Fetch the first attempt (attempt_number == 1) for the user and puzzle @@ -843,12 +887,11 @@ def daily_puzzle_view(request): is_counted=True, ) s.add(attempt) - s.commit() # Get user's rank if they have solved it user_rank = None if attempt.is_solved: - sort_method = 'time' # You can change this to 'moves' if desired + sort_method = "time" # You can change this to 'moves' if desired attempts = get_ordered_attempts(s, puzzle.id, sort_method) for idx, att in enumerate(attempts): if att.user_id == user.id: @@ -904,7 +947,8 @@ def record_action_view(request): # Get request data try: action_data = request.json_body - except: + except Exception as e: + logger.error(f"Failed to parse JSON: {e}") return Response( json.dumps({"status": "error", "message": "Invalid JSON data."}), content_type="application/json; charset=UTF-8", @@ -926,8 +970,6 @@ def record_action_view(request): content_type="application/json; charset=UTF-8", ) - display_name = user.username - attempt = ( s.query(Attempt) .filter_by(user_id=user.id, puzzle_id=puzzle.id, is_solved=False) @@ -943,92 +985,86 @@ def record_action_view(request): # Update attempt stats and state state = json.loads(attempt.state_json) - solution_state = json.loads(puzzle.solution_state_json) - if action == "rotate": - attempt.rotation_count += 1 - piece_uuid = data.get("pieceUUID") - rotation = int(data.get("rotation")) - # Update rotation in state - for piece in state: - if piece["uuid"] == piece_uuid: - piece["rotation"] = rotation - break + try: + if action == "rotate": + attempt.rotation_count += 1 + piece_uuid = data.get("pieceUUID") + rotation = int(data.get("rotation")) + # Update rotation in state + for piece in state: + if piece["uuid"] == piece_uuid: + piece["rotation"] = rotation + break + else: + return Response( + json.dumps({"status": "error", "message": "Invalid piece UUID."}), + content_type="application/json; charset=UTF-8", + ) + elif action == "swap": + attempt.move_count += 1 + piece_uuid_a = data.get("pieceUUIDA") + piece_uuid_b = data.get("pieceUUIDB") + # Find pieces by UUID + idxA = idxB = None + for idx, piece in enumerate(state): + if piece["uuid"] == piece_uuid_a: + idxA = idx + if piece["uuid"] == piece_uuid_b: + idxB = idx + if idxA is not None and idxB is not None: + # Swap currentIndex values + state[idxA]["currentIndex"], state[idxB]["currentIndex"] = ( + state[idxB]["currentIndex"], + state[idxA]["currentIndex"], + ) + else: + return Response( + json.dumps({"status": "error", "message": "Invalid piece UUIDs."}), + content_type="application/json; charset=UTF-8", + ) else: return Response( - json.dumps({"status": "error", "message": "Invalid piece UUID."}), + json.dumps({"status": "error", "message": "Invalid action."}), content_type="application/json; charset=UTF-8", ) - elif action == "swap": - attempt.move_count += 1 - piece_uuid_a = data.get("pieceUUIDA") - piece_uuid_b = data.get("pieceUUIDB") - # Find pieces by UUID - idxA = idxB = None - for idx, piece in enumerate(state): - if piece["uuid"] == piece_uuid_a: - idxA = idx - if piece["uuid"] == piece_uuid_b: - idxB = idx - if idxA is not None and idxB is not None: - # Swap currentIndex values - state[idxA]["currentIndex"], state[idxB]["currentIndex"] = ( - state[idxB]["currentIndex"], - state[idxA]["currentIndex"], + + # Save updated state + attempt.state_json = json.dumps(state) + + # Check for win condition + if all( + piece["currentIndex"] == piece["correctIndex"] + and piece["rotation"] % 360 == 0 + for piece in state + ): + attempt.mark_as_solved() + + # Get user's rank + attempts = get_ordered_attempts(s, puzzle.id, "time") + for idx, att in enumerate(attempts): + if att.user_id == user.id and att.id == attempt.id: + user_rank = idx + 1 + break + else: + user_rank = "N/A" + + return Response( + json.dumps({"status": "win", "rank": user_rank}), + content_type="application/json; charset=UTF-8", ) else: return Response( - json.dumps({"status": "error", "message": "Invalid piece UUIDs."}), + json.dumps({"status": "ok"}), content_type="application/json; charset=UTF-8", ) - else: + except Exception as e: + logger.error(f"Error processing action '{action}': {e}") return Response( - json.dumps({"status": "error", "message": "Invalid action."}), + json.dumps({"status": "error", "message": "Server error."}), content_type="application/json; charset=UTF-8", ) - # Save updated state - attempt.state_json = json.dumps(state) - - # Check for win condition - if all( - piece["currentIndex"] == piece["correctIndex"] and piece["rotation"] % 360 == 0 - for piece in state - ): - attempt.mark_as_solved() - attempt.time_completed = datetime.datetime.utcnow() - s.commit() - - # Get user's rank - attempts = ( - s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) - .order_by( - ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) - ).asc(), - (Attempt.move_count + Attempt.rotation_count).asc(), - ) - .all() - ) - for idx, att in enumerate(attempts): - if att.user_id == user.id and att.id == attempt.id: - user_rank = idx + 1 - break - else: - user_rank = "N/A" - - return Response( - json.dumps({"status": "win", "rank": user_rank}), - content_type="application/json; charset=UTF-8", - ) - else: - s.commit() - return Response( - json.dumps({"status": "ok"}), content_type="application/json; charset=UTF-8" - ) - ################################################################################ # Leaderboards @@ -1055,34 +1091,7 @@ def daily_leaderboard_view(request): if not puzzle: return Response("No puzzle found for this ID.", status=404) - if sort_method == "moves": - # Sort by least total actions (moves + rotations) - attempts = ( - s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) - .order_by( - (Attempt.move_count + Attempt.rotation_count).asc(), - ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) - ).asc(), - ) - .all() - ) - else: - # Default sort by fastest time - attempts = ( - s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) - .order_by( - ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) - ).asc(), - (Attempt.move_count + Attempt.rotation_count).asc(), - ) - .all() - ) + attempts = get_ordered_attempts(s, puzzle.id, sort_method) return { "request": request, @@ -1116,10 +1125,9 @@ def king_of_the_mountain_view(request): s.query( func.sum( ( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) + func.strftime("%s", Attempt.time_completed) + - func.strftime("%s", Attempt.time_started) ) - * 86400 ) ) .filter( @@ -1179,13 +1187,13 @@ def main(global_config=None, **settings): # Set up the session factory with the specified settings session_factory = SignedCookieSessionFactory( secret="it-is-a-secret-you-must-change", - hashalg='sha512', - timeout=31104000, # Approx. one year in seconds - max_age=31104000, # Set Max-Age attribute on cookie - reissue_time=15552000, # Approx. six months - samesite=None, # Allows cross-site requests if needed - httponly=True, # Helps mitigate XSS attacks - secure=False # Set to True if using HTTPS + hashalg="sha512", + timeout=31104000, # Approx. one year in seconds + max_age=31104000, # Set Max-Age attribute on cookie + reissue_time=15552000, # Approx. six months + samesite=None, # Allows cross-site requests if needed + httponly=True, # Helps mitigate XSS attacks + secure=False, # Set to True if using HTTPS ) if not settings: @@ -1199,11 +1207,14 @@ def main(global_config=None, **settings): config.add_jinja2_renderer(".j2") config.add_jinja2_search_path("templates", name=".j2") + # Include pyramid_tm for transaction management + config.include("pyramid_tm") + # Add user to all requests. config.add_request_method(callable=get_current_user, name="user", reify=True) def dbsession(request): - return Session() + return DBSession config.add_request_method(dbsession, "dbsession", reify=True) diff --git a/requirements.txt b/requirements.txt index 43e487a..33b2db8 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,13 @@ plaster_pastedeploy pyramid pyramid_jinja2 +pyramid_tm pyramid_debugtoolbar waitress pyramid_retry +zope.sqlalchemy + bcrypt sqlalchemy werkzeug diff --git a/templates/daily_leaderboard.html.j2 b/templates/daily_leaderboard.html.j2 index c658b0a..65ddc95 100644 --- a/templates/daily_leaderboard.html.j2 +++ b/templates/daily_leaderboard.html.j2 @@ -3,8 +3,8 @@

Leaderboard for {{ puzzle.title }} ({{ puzzle.date }})

Sort by: - Top Speed | - Least Moves + Top Speed | + Least Moves

Players are ranked based on their total time and moves. Faster times and fewer moves result in better rankings.