From 18bd6d219ac48fd0cc288ad48386df5ab60d3090 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 26 Dec 2024 18:17:37 -0500 Subject: [PATCH] It's basically working now to mix in everyone's ideas modified: app.py modified: requirements.txt --- app.py | 798 ++++++++++++++++++++++++++++++++--------------- requirements.txt | 1 + 2 files changed, 547 insertions(+), 252 deletions(-) diff --git a/app.py b/app.py index 20200a4..ea25d69 100644 --- a/app.py +++ b/app.py @@ -1,22 +1,25 @@ ############################################################################### -# app.py - Single-file Pyramid puzzle app with: -# - Anonymous random display names stored in session. -# - Email-only login (code-based). -# - Username changes (not allowed to conflict). +# app.py - Single-file Pyramid puzzle app with additional features: +# - Logout functionality. +# - Leaderboard supports sorting by top speed and top moves. +# - Navigation links are displayed on all screens. +# - Optional profile page to enable Gravatar (off by default). ############################################################################### + +import os import base64 import datetime import json import random -import time -import secrets import string import bcrypt +import re +import hashlib from pyramid.config import Configurator from pyramid.view import view_config -from pyramid.response import Response -from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response, FileResponse +from pyramid.httpexceptions import HTTPFound, HTTPFound from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( @@ -29,14 +32,13 @@ from sqlalchemy import ( Date, DateTime, ForeignKey, - func, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, relationship from waitress import serve -from jinja2 import Template # Import Jinja2 Template for manual rendering +from jinja2 import Template ############################################################################### # Database Setup @@ -52,6 +54,7 @@ class User(Base): code_hash = Column(String, nullable=True) # bcrypt hash of code code_expires = Column(DateTime, nullable=True) # time limit for code is_verified = Column(Boolean, default=False) + enable_gravatar = Column(Boolean, default=False) # New field for Gravatar class Puzzle(Base): @@ -59,7 +62,9 @@ class Puzzle(Base): id = Column(Integer, primary_key=True) date = Column(Date, unique=True, nullable=False) image_b64 = Column(String, nullable=False) - shuffle_json = Column(String, default="") + initial_state_json = Column( + String, nullable=False + ) # Stores initial positions and rotations title = Column(String, default="Daily Puzzle") @@ -73,11 +78,12 @@ class Attempt(Base): attempt_date = Column(Date, default=datetime.date.today()) attempt_number = Column(Integer, default=1) - swap_count = Column(Integer, default=0) - placement_count = Column(Integer, default=0) + move_count = Column(Integer, default=0) rotation_count = Column(Integer, default=0) - time_to_solve = Column(Float, default=9999.0) + time_started = Column(DateTime, default=datetime.datetime.utcnow) + time_completed = Column(DateTime, nullable=True) is_solved = Column(Boolean, default=False) + state_json = Column(String, nullable=False) # Stores the current game state puzzle = relationship("Puzzle", backref="attempts") user = relationship("User", backref="attempts") @@ -93,6 +99,40 @@ Base.metadata.create_all(engine) ############################################################################### session_factory = SignedCookieSessionFactory(secret="my_insecure_secret") + +############################################################################### +# Helper Functions +############################################################################### +def slugify(text): + text = text.lower() + text = re.sub(r"\s+", "-", text) + text = re.sub(r"[^\w\-]", "", text) + return text + + +def get_gravatar_url(email, size=100): + email = email.strip().lower() + hash_code = hashlib.md5(email.encode("utf-8")).hexdigest() + return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon" + + +############################################################################### +# Navigation Template +############################################################################### +NAVIGATION_TEMPLATE = r""" + +""" + ############################################################################### # Inline Templates ############################################################################### @@ -101,6 +141,7 @@ HOME_TEMPLATE = r""" Daily Puzzle - Home + {{ navigation }}

Daily Puzzle - Home

{% if user %} @@ -125,6 +167,7 @@ LOGIN_TEMPLATE = r""" Login with Email + {{ navigation }}

Login (Email Only)

Enter your email to receive a 6-digit code. If you're a new user, an account is created for you. If you already exist, we'll re-send a code.

@@ -141,6 +184,7 @@ VERIFY_TEMPLATE = r""" Verify Code + {{ navigation }}

Enter 6-digit code

@@ -156,6 +200,7 @@ CHANGE_USERNAME_TEMPLATE = r""" Change Username + {{ navigation }}

Change Username

@@ -166,11 +211,38 @@ CHANGE_USERNAME_TEMPLATE = r""" """ +PROFILE_TEMPLATE = r""" + + +Profile + + {{ navigation }} +

Your Profile

+

Email: {{ user.email }}

+

Username: {{ user.username }}

+ {% if user.enable_gravatar %} + Gravatar Image + {% else %} +

Gravatar is disabled.

+ {% endif %} + + + +
+

Change Username

+ + +""" + UPLOAD_TEMPLATE = r""" Upload Puzzle + {{ navigation }}

Upload Puzzle Image / Schedule

@@ -196,196 +268,235 @@ DAILY_PUZZLE_TEMPLATE = r""" {{ puzzle.title }} - {{ puzzle.date }} + {{ navigation }} {% if puzzle %}

Today's Puzzle: {{ puzzle.title }}

Date: {{ puzzle.date }}

Up to 2 attempts per day.
- Drag pieces between board/side.
+ Swap pieces by dragging and dropping.
Click a piece to rotate (+90°).
- We track swaps, placements, rotations, and time. + We track moves (swaps) and rotations. +

+

+ Moves: {{ move_count }}
+ Rotations: {{ rotation_count }}

-
Board
-
Side Board
-
+ + {% else %}

No puzzle for today!

{% endif %} -

Back to Home

""" @@ -395,6 +506,7 @@ LEADERBOARD_INDEX_TEMPLATE = r""" All Leaderboards + {{ navigation }}

All Leaderboards

Click a puzzle date to see its full scoreboard.

@@ -421,24 +533,30 @@ DAILY_LEADERBOARD_TEMPLATE = r""" Daily Leaderboard + {{ navigation }}

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

+

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

- - + + {% for row in attempts %} - - + - + + {% endfor %}
Username/Guest Attempt #SwapsPlacementsMoves RotationsTotal Actions Time (sec)
{{ row.user_display_name }} {{ row.attempt_number }}{{ row.swap_count }}{{ row.placement_count }}{{ row.move_count }} {{ row.rotation_count }}{{ "%.2f"|format(row.time_to_solve) }}{{ row.move_count + row.rotation_count }}{{ "%.2f"|format((row.time_completed - row.time_started).total_seconds() if row.time_completed else 0) }}
@@ -468,25 +586,34 @@ def ensure_guest_name_in_session(request): request.session["guest_name"] = f"Guest-{suffix}" +def get_navigation(request): + """Render the navigation template.""" + user = get_current_user(request) + template = Template(NAVIGATION_TEMPLATE) + return template.render(request=request, user=user) + + ############################################################################### # Routes ############################################################################### -@view_config(route_name="home") # Removed renderer='string' +@view_config(route_name="home") def home_view(request): user = get_current_user(request) ensure_guest_name_in_session(request) guest_name = request.session["guest_name"] - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(HOME_TEMPLATE) - rendered = template.render(request=request, user=user, guest_name=guest_name) + rendered = template.render( + request=request, user=user, guest_name=guest_name, navigation=navigation + ) return Response(rendered) -@view_config(route_name="login", request_method="GET") # Removed renderer='string' +@view_config(route_name="login", request_method="GET") def login_get_view(request): - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(LOGIN_TEMPLATE) - rendered = template.render(request=request) + rendered = template.render(request=request, navigation=navigation) return Response(rendered) @@ -522,11 +649,11 @@ def login_post_view(request): ) -@view_config(route_name="verify", request_method="GET") # Removed renderer='string' +@view_config(route_name="verify", request_method="GET") def verify_get_view(request): - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(VERIFY_TEMPLATE) - rendered = template.render(request=request) + rendered = template.render(request=request, navigation=navigation) return Response(rendered) @@ -568,16 +695,14 @@ def verify_post_view(request): ) -@view_config( - route_name="change_username", request_method="GET" -) # Removed renderer='string' +@view_config(route_name="change_username", request_method="GET") def change_username_get_view(request): user = get_current_user(request) if not user: return Response("You must be logged in to change your username.", status=403) - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(CHANGE_USERNAME_TEMPLATE) - rendered = template.render(request=request) + rendered = template.render(request=request, navigation=navigation) return Response(rendered) @@ -606,177 +731,344 @@ def change_username_post_view(request): ) +@view_config(route_name="logout") +def logout_view(request): + request.session.invalidate() + return HTTPFound(location=request.route_url("home")) + + ############################################################################### -# Puzzle and Leaderboard +# Profile and Gravatar ############################################################################### -@view_config(route_name="upload", request_method="GET") # Removed renderer='string' -def upload_get_view(request): - # Manually render the template using Jinja2 - template = Template(UPLOAD_TEMPLATE) - rendered = template.render(request=request) +@view_config(route_name="profile", request_method="GET") +def profile_get_view(request): + user = get_current_user(request) + if not user: + return Response("You must be logged in to access your profile.", status=403) + navigation = get_navigation(request) + gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else "" + template = Template(PROFILE_TEMPLATE) + rendered = template.render( + request=request, user=user, navigation=navigation, gravatar_url=gravatar_url + ) return Response(rendered) -@view_config(route_name='upload', request_method='POST') -def upload_post_view(request): - puzzle_date_str = request.POST.get('puzzle_date') - title = request.POST.get('title') or "Daily Puzzle" - image_file = request.POST.get('image_file') +@view_config(route_name="profile", request_method="POST") +def profile_post_view(request): + user = get_current_user(request) + if not user: + return Response("You must be logged in to update your profile.", status=403) + s = request.dbsession + enable_gravatar = request.POST.get("enable_gravatar") == "on" + user.enable_gravatar = enable_gravatar + s.commit() + return HTTPFound(location=request.route_url("profile")) - # Updated condition to safely check image_file and its filename - if not puzzle_date_str or image_file is None or not getattr(image_file, 'filename', '').strip(): + +############################################################################### +# Puzzle and Leaderboard +############################################################################### +@view_config(route_name="upload", request_method="GET") +def upload_get_view(request): + navigation = get_navigation(request) + template = Template(UPLOAD_TEMPLATE) + rendered = template.render(request=request, navigation=navigation) + return Response(rendered) + + +@view_config(route_name="upload", request_method="POST") +def upload_post_view(request): + puzzle_date_str = request.POST.get("puzzle_date") + title = request.POST.get("title") or "AI Puzzle" + image_file = request.POST.get("image_file") + + if ( + not puzzle_date_str + or image_file is None + or not getattr(image_file, "filename", "").strip() + ): return Response("Missing date or file", status=400) + # Validate file type + allowed_extensions = {"png", "jpg", "jpeg", "gif"} + if not any( + image_file.filename.lower().endswith(f".{ext}") for ext in allowed_extensions + ): + return Response( + "Unsupported file type. Allowed types: png, jpg, jpeg, gif.", status=400 + ) + try: puzzle_date = datetime.datetime.strptime(puzzle_date_str, "%Y-%m-%d").date() except ValueError: return Response("Invalid date format. Use YYYY-MM-DD.", status=400) raw_bytes = image_file.file.read() - encoded_str = base64.b64encode(raw_bytes).decode('utf-8') + max_size = 5 * 1024 * 1024 # 5 MB + if len(raw_bytes) > max_size: + return Response("File size exceeds the 5MB limit.", status=400) + + encoded_str = base64.b64encode(raw_bytes).decode("utf-8") + + # Generate initial puzzle state (random configuration) + gridSize = 4 + total_pieces = gridSize * gridSize + indices = list(range(total_pieces)) + random.shuffle(indices) + rotations = [0, 90, 180, 270] + initial_state = [] + for idx, current_idx in enumerate(indices): + piece_info = { + "correctIndex": idx, + "currentIndex": current_idx, + "rotation": random.choice(rotations), + } + initial_state.append(piece_info) + initial_state_json = json.dumps(initial_state) s = request.dbsession existing = s.query(Puzzle).filter_by(date=puzzle_date).first() if existing: return Response(f"Puzzle already exists for {puzzle_date}.", status=400) - puzzle = Puzzle(date=puzzle_date, image_b64=encoded_str, title=title, shuffle_json='') + puzzle = Puzzle( + date=puzzle_date, + image_b64=encoded_str, + title=title, + initial_state_json=initial_state_json, + ) s.add(puzzle) s.commit() - return HTTPFound(location=request.route_url('home')) + return HTTPFound(location=request.route_url("home")) -@view_config(route_name="daily_puzzle") # Removed renderer='string' +@view_config(route_name="daily_puzzle") def daily_puzzle_view(request): s = request.dbsession today = datetime.date.today() puzzle = s.query(Puzzle).filter_by(date=today).first() - if puzzle and not puzzle.shuffle_json: - # generate puzzle data - arr = list(range(16)) - random.shuffle(arr) - rotations = [0, 90, 180, 270] - data = [] - for i, correct_idx in enumerate(arr): - piece_info = { - "index": correct_idx, - "pos": i, - "rot": random.choice(rotations), - "inSideBoard": (i >= 8), - } - data.append(piece_info) - puzzle.shuffle_json = json.dumps(data) + if not puzzle: + navigation = get_navigation(request) + rendered = f"

No puzzle for today!

Back to Home

{navigation}" + return Response(rendered) + + # Check if user has an existing attempt + user = get_current_user(request) + ensure_guest_name_in_session(request) + guest_name = request.session["guest_name"] + user_id = user.id if user else None + display_name = user.username if (user and user.username) else guest_name + + attempt = ( + s.query(Attempt) + .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False) + .order_by(Attempt.attempt_number.desc()) + .first() + ) + + attempts_count = ( + s.query(Attempt).filter_by(user_id=user_id, puzzle_id=puzzle.id).count() + ) + + if not attempt: + if attempts_count >= 2: + return Response("You have no more attempts left for today!", status=403) + # Create a new attempt + attempt_number = attempts_count + 1 + attempt = Attempt( + user_id=user_id, + user_display_name=display_name, + puzzle_id=puzzle.id, + attempt_date=today, + attempt_number=attempt_number, + move_count=0, + rotation_count=0, + is_solved=False, + state_json=puzzle.initial_state_json, + time_started=datetime.datetime.utcnow(), + ) + s.add(attempt) s.commit() - if puzzle: - # If puzzle exists, render the template - template = Template(DAILY_PUZZLE_TEMPLATE) - rendered = template.render(request=request, puzzle=puzzle) - else: - # If no puzzle exists, render a simple message - rendered = "

No puzzle for today!

Back to Home

" + # Prepare the puzzle filename for the download button + puzzle_filename = slugify(puzzle.title) + ".png" + navigation = get_navigation(request) + # Render the puzzle page with the attempt state + template = Template(DAILY_PUZZLE_TEMPLATE) + rendered = template.render( + request=request, + puzzle=puzzle, + attempt_state_json=attempt.state_json, + move_count=attempt.move_count, + rotation_count=attempt.rotation_count, + attempt_is_solved=attempt.is_solved, + puzzle_filename=puzzle_filename, + navigation=navigation, + ) return Response(rendered) -@view_config(route_name="submit_attempt", request_method="POST") -def submit_attempt_view(request): +@view_config(route_name="record_action", request_method="POST") +def record_action_view(request): s = request.dbsession + + # Get request data + try: + action_data = request.json_body + except: + return Response( + json.dumps({"status": "error", "message": "Invalid JSON data."}), + content_type="application/json; charset=UTF-8", + ) + + action = action_data.get("action") + data = action_data.get("data") + + # Retrieve user, puzzle, and attempt user = get_current_user(request) ensure_guest_name_in_session(request) guest_name = request.session["guest_name"] today = datetime.date.today() puzzle = s.query(Puzzle).filter_by(date=today).first() + if not puzzle: - return HTTPFound(location=request.route_url("daily_puzzle")) + return Response( + json.dumps({"status": "error", "message": "No puzzle found."}), + content_type="application/json; charset=UTF-8", + ) - # Check how many attempts so far user_id = user.id if user else None - display_name = user.username if (user and user.username) else guest_name - attempts_count = ( + attempt = ( s.query(Attempt) - .filter_by(user_id=user_id, puzzle_id=puzzle.id, attempt_date=today) - .count() + .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False) + .order_by(Attempt.attempt_number.desc()) + .first() ) - if attempts_count >= 2: - return Response("You have no more attempts left for today!", status=403) + if not attempt: + return Response( + json.dumps({"status": "error", "message": "No active attempt found."}), + content_type="application/json; charset=UTF-8", + ) - attempt_number = attempts_count + 1 + # Update attempt stats and state + state = json.loads(attempt.state_json) - def safe_int(x): - try: - return int(x) - except: - return 0 + if action == "rotate": + attempt.rotation_count += 1 + pieceIndex = int(data.get("pieceIndex")) + rotation = int(data.get("rotation")) + # Update rotation in state + for piece in state: + if int(piece["currentIndex"]) == pieceIndex: + piece["rotation"] = rotation + break + elif action == "swap": + attempt.move_count += 1 + pieceAIndex = int(data.get("pieceAIndex")) + pieceBIndex = int(data.get("pieceBIndex")) + # Swap positions in state + idxA = idxB = None + for idx, piece in enumerate(state): + if int(piece["currentIndex"]) == pieceAIndex: + idxA = idx + if int(piece["currentIndex"]) == pieceBIndex: + idxB = idx + if idxA is not None and idxB is not None: + state[idxA]["currentIndex"], state[idxB]["currentIndex"] = ( + state[idxB]["currentIndex"], + state[idxA]["currentIndex"], + ) + else: + return Response( + json.dumps({"status": "error", "message": "Invalid swap indices."}), + content_type="application/json; charset=UTF-8", + ) + else: + return Response( + json.dumps({"status": "error", "message": "Invalid action."}), + content_type="application/json; charset=UTF-8", + ) - def safe_float(x): - try: - return float(x) - except: - return 9999.0 + # Save updated state + attempt.state_json = json.dumps(state) - time_str = request.POST.get("time_to_solve") - swaps_str = request.POST.get("swaps") - placements_str = request.POST.get("placements") - rots_str = request.POST.get("rotations") - - new_attempt = Attempt( - user_id=user_id, - user_display_name=display_name, - puzzle_id=puzzle.id, - attempt_date=today, - attempt_number=attempt_number, - swap_count=safe_int(swaps_str), - placement_count=safe_int(placements_str), - rotation_count=safe_int(rots_str), - time_to_solve=safe_float(time_str), - is_solved=True, - ) - s.add(new_attempt) - s.commit() - - return HTTPFound( - location=request.route_url("daily_leaderboard", puzzle_id=puzzle.id) - ) + # Check for win condition + solved = True + for piece in state: + if ( + int(piece["currentIndex"]) != int(piece["correctIndex"]) + or int(piece["rotation"]) % 360 != 0 + ): + solved = False + break + if solved: + attempt.is_solved = True + attempt.time_completed = datetime.datetime.utcnow() + s.commit() + return Response( + json.dumps({"status": "win"}), + content_type="application/json; charset=UTF-8", + ) + else: + s.commit() + return Response( + json.dumps({"status": "ok"}), content_type="application/json; charset=UTF-8" + ) -@view_config(route_name="leaderboard_index") # Removed renderer='string' +@view_config(route_name="leaderboard_index") def leaderboard_index_view(request): s = request.dbsession puzzles = s.query(Puzzle).order_by(Puzzle.date.desc()).all() - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(LEADERBOARD_INDEX_TEMPLATE) - rendered = template.render(request=request, puzzles=puzzles) + rendered = template.render(request=request, puzzles=puzzles, navigation=navigation) return Response(rendered) -@view_config(route_name="daily_leaderboard") # Removed renderer='string' +@view_config(route_name="daily_leaderboard") def daily_leaderboard_view(request): s = request.dbsession puzzle_id = request.matchdict.get("puzzle_id") + sort_method = request.params.get("sort", "time") puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first() if not puzzle: return Response("No puzzle found for this ID.", status=404) - attempts = ( - s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True) - .order_by( - Attempt.time_to_solve.asc(), - ( - Attempt.swap_count + Attempt.placement_count + Attempt.rotation_count - ).asc(), + if sort_method == "moves": + # Sort by least total actions (moves + rotations) + attempts = ( + s.query(Attempt) + .filter_by(puzzle_id=puzzle.id, is_solved=True) + .order_by( + (Attempt.move_count + Attempt.rotation_count).asc(), + (Attempt.time_completed - Attempt.time_started).asc(), + ) + .all() + ) + else: + # Default sort by fastest time + attempts = ( + s.query(Attempt) + .filter_by(puzzle_id=puzzle.id, is_solved=True) + .order_by( + (Attempt.time_completed - Attempt.time_started).asc(), + (Attempt.move_count + Attempt.rotation_count).asc(), + ) + .all() ) - .all() - ) - # Manually render the template using Jinja2 + navigation = get_navigation(request) template = Template(DAILY_LEADERBOARD_TEMPLATE) - rendered = template.render(request=request, puzzle=puzzle, attempts=attempts) + rendered = template.render( + request=request, puzzle=puzzle, attempts=attempts, navigation=navigation + ) return Response(rendered) @@ -804,12 +1096,14 @@ def main(global_config=None, **settings): # Auth config.add_route("login", "/auth/login") config.add_route("verify", "/auth/verify") + config.add_route("logout", "/auth/logout") config.add_route("change_username", "/auth/change_username") + config.add_route("profile", "/auth/profile") # Puzzle config.add_route("upload", "/upload") config.add_route("daily_puzzle", "/puzzle/today") - config.add_route("submit_attempt", "/puzzle/submit") + config.add_route("record_action", "/puzzle/record_action") # Leaderboards config.add_route("leaderboard_index", "/leaderboards") diff --git a/requirements.txt b/requirements.txt index 616d694..43e487a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -7,3 +7,4 @@ pyramid_retry bcrypt sqlalchemy +werkzeug