From 847abbfe13b923b464dd83812e7f76079cfd6544 Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Thu, 26 Dec 2024 20:24:52 -0500 Subject: [PATCH] Ok we now have 3 modes per day modified: app.py --- app.py | 879 +++++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 704 insertions(+), 175 deletions(-) diff --git a/app.py b/app.py index ea25d69..7b582e6 100644 --- a/app.py +++ b/app.py @@ -1,9 +1,13 @@ ############################################################################### -# 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). +# app.py - Single-file Pyramid puzzle app with enhanced features: +# - Single leaderboard entry per user per puzzle. +# - Game stays solved with download button after solving. +# - King of the Mountain global leaderboard. +# - User profiles show all solves, scores, and streaks. +# - Admin can upload multiple puzzles per day (3x3, 4x4, 5x5). +# - Responsive design for phones and computers. +# - Enhanced win animation with modal and confetti. +# - Subsequent plays after first solve do not affect leaderboard. ############################################################################### import os @@ -19,7 +23,7 @@ import hashlib from pyramid.config import Configurator from pyramid.view import view_config from pyramid.response import Response, FileResponse -from pyramid.httpexceptions import HTTPFound, HTTPFound +from pyramid.httpexceptions import HTTPFound from pyramid.session import SignedCookieSessionFactory from sqlalchemy import ( @@ -32,6 +36,8 @@ from sqlalchemy import ( Date, DateTime, ForeignKey, + desc, + func, ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, relationship @@ -40,9 +46,9 @@ from waitress import serve from jinja2 import Template -############################################################################### +################################################################################ # Database Setup -############################################################################### +################################################################################ Base = declarative_base() @@ -54,19 +60,22 @@ 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 + enable_gravatar = Column(Boolean, default=False) # Gravatar support class Puzzle(Base): __tablename__ = "puzzles" id = Column(Integer, primary_key=True) - date = Column(Date, unique=True, nullable=False) + date = Column(Date, nullable=False) + size = Column(Integer, nullable=False) # 3, 4, or 5 image_b64 = Column(String, nullable=False) initial_state_json = Column( String, nullable=False - ) # Stores initial positions and rotations + ) # initial positions and rotations title = Column(String, default="Daily Puzzle") + __table_args__ = ({"sqlite_autoincrement": True},) + class Attempt(Base): __tablename__ = "attempts" @@ -83,7 +92,10 @@ class Attempt(Base): 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 + state_json = Column(String, nullable=False) # current game state + is_counted = Column( + Boolean, default=True + ) # whether attempt counts towards leaderboard puzzle = relationship("Puzzle", backref="attempts") user = relationship("User", backref="attempts") @@ -94,15 +106,15 @@ engine = create_engine(DB_URL, echo=False) Session = sessionmaker(bind=engine) Base.metadata.create_all(engine) -############################################################################### +################################################################################ # Session Factory -############################################################################### +################################################################################ session_factory = SignedCookieSessionFactory(secret="my_insecure_secret") -############################################################################### +################################################################################ # Helper Functions -############################################################################### +################################################################################ def slugify(text): text = text.lower() text = re.sub(r"\s+", "-", text) @@ -116,14 +128,17 @@ def get_gravatar_url(email, size=100): return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon" -############################################################################### +################################################################################ # Navigation Template -############################################################################### +################################################################################ NAVIGATION_TEMPLATE = r""" + """ -############################################################################### +################################################################################ # Inline Templates -############################################################################### +################################################################################ HOME_TEMPLATE = r""" -Daily Puzzle - Home + + Daily Puzzles - Home + + {{ navigation }} -

Daily Puzzle - Home

- - +

Daily Puzzles - Home

{% if user %}

You are logged in as {{ user.username }}.

{% else %}

You are not logged in.

Your guest name: {{ guest_name }}

{% endif %} +

Welcome to the Daily Puzzles! Test your skills with our daily challenges.

+

Play Today's Puzzles

""" @@ -174,7 +188,6 @@ LOGIN_TEMPLATE = r""" -

Home

""" @@ -190,7 +203,6 @@ VERIFY_TEMPLATE = r""" -

Home

""" @@ -206,20 +218,19 @@ CHANGE_USERNAME_TEMPLATE = r""" -

Home

""" PROFILE_TEMPLATE = r""" - Profile {{ navigation }} +

Your Profile

Email: {{ user.email }}

-

Username: {{ user.username }}

+

Username: {{ user.username or "Not set" }}

{% if user.enable_gravatar %} Gravatar Image {% else %} @@ -229,10 +240,60 @@ PROFILE_TEMPLATE = r""" +

+ +

+ -

Change Username

+ +

Stats

+

Total Puzzles Solved: {{ total_puzzles_solved }}

+

Current Streak: {{ current_streak }} days

+

Max Streak: {{ max_streak }} days

+

Your Solves

+ + + + + + + + + {% for attempt in solved_attempts %} + + + + + + + + {% endfor %} +
DateSizeMovesRotationsTime (sec)
{{ attempt.puzzle.date }}{{ attempt.puzzle.size }}x{{ attempt.puzzle.size }}{{ attempt.move_count }}{{ attempt.rotation_count }}{{ "%.2f"|format((attempt.time_completed - attempt.time_started).total_seconds()) }}
+ + +""" + +PUZZLE_MENU_TEMPLATE = r""" + + + + Today's Puzzles + + + {{ navigation }} +

Select a Puzzle Size

+ """ @@ -240,23 +301,22 @@ PROFILE_TEMPLATE = r""" UPLOAD_TEMPLATE = r""" -Upload Puzzle +Upload Puzzles {{ navigation }} -

Upload Puzzle Image / Schedule

+

Upload Puzzles for Today



- - -

- - -

- + {% for size in [3, 4, 5] %} +

{{ size }}x{{ size }} Puzzle

+ +

+ +

+ {% endfor %}
-

Back to Home

""" @@ -267,15 +327,14 @@ DAILY_PUZZLE_TEMPLATE = r""" {{ puzzle.title }} - {{ puzzle.date }} {{ navigation }} {% if puzzle %} -

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

+

{{ puzzle.title }}

Date: {{ puzzle.date }}

-

- Up to 2 attempts per day.
- Swap pieces by dragging and dropping.
- Click a piece to rotate (+90°).
- We track moves (swaps) and rotations. -

Moves: {{ move_count }}
Rotations: {{ rotation_count }}
@@ -319,11 +413,26 @@ DAILY_PUZZLE_TEMPLATE = r"""

- + + + +
+
+ × +

Congratulations!

+

You solved the puzzle!

+ {% if user_rank is not none %} +

Your Rank: {{ user_rank }}

+ {% else %} +

Your rank is not available.

+ {% endif %} + +
+
{% else %} -

No puzzle for today!

+

No puzzle available!

{% endif %} @@ -512,18 +681,19 @@ LEADERBOARD_INDEX_TEMPLATE = r""" + {% for p in puzzles %} + {% endfor %}
Puzzle DateSize Title Actions
{{ p.date }}{{ p.size }}x{{ p.size }} {{ p.title }} View Leaderboard
-

Back to Home

""" @@ -542,8 +712,8 @@ DAILY_LEADERBOARD_TEMPLATE = r"""

+ - @@ -551,8 +721,8 @@ DAILY_LEADERBOARD_TEMPLATE = r""" {% for row in attempts %} - - + + @@ -560,18 +730,63 @@ DAILY_LEADERBOARD_TEMPLATE = r""" {% endfor %}
Rank Username/GuestAttempt # Moves Rotations Total Actions
{{ row.user_display_name }}{{ row.attempt_number }}{{ loop.index }}{{ row.user_display_name }} {{ row.move_count }} {{ row.rotation_count }} {{ row.move_count + row.rotation_count }}
-

All Leaderboards

-

Back to Home

+ + +""" + +KING_OF_THE_MOUNTAIN_TEMPLATE = r""" + + + + King of the Mountain + + + + {{ navigation }} +

King of the Mountain - Global Leaderboard

+ + + + + + + + + + + {% for player in global_leaderboard %} + + + + + + + + + + {% endfor %} +
RankUsernameTotal Puzzles SolvedTotal Time (sec)Total MovesTotal RotationsTotal Actions
{{ loop.index }}{{ player.username }}{{ player.total_puzzles }}{{ "%.2f"|format(player.total_time) }}{{ player.total_moves }}{{ player.total_rotations }}{{ player.total_moves + player.total_rotations }}
""" -############################################################################### +################################################################################ # Helper / Auth Functions -############################################################################### +################################################################################ def get_current_user(request): - """Return the currently logged-in user (if any) from session, and ensure is_verified=True.""" + """Return the currently logged-in user (if any) from session.""" user_id = request.session.get("user_id") if not user_id: return None @@ -593,9 +808,11 @@ def get_navigation(request): return template.render(request=request, user=user) -############################################################################### +################################################################################ # Routes -############################################################################### +################################################################################ + + @view_config(route_name="home") def home_view(request): user = get_current_user(request) @@ -690,9 +907,13 @@ def verify_post_view(request): s.commit() request.session["user_id"] = user_matched.id - return Response( - f"Verified! Logged in.
Home" - ) + return HTTPFound(location=request.route_url("home")) + + +@view_config(route_name="logout") +def logout_view(request): + request.session.invalidate() + return HTTPFound(location=request.route_url("home")) @view_config(route_name="change_username", request_method="GET") @@ -725,21 +946,12 @@ def change_username_post_view(request): user.username = new_username s.commit() - return Response( - f"Username changed to {new_username}. " - f"

Home" - ) + return HTTPFound(location=request.route_url("profile")) -@view_config(route_name="logout") -def logout_view(request): - request.session.invalidate() - return HTTPFound(location=request.route_url("home")) - - -############################################################################### +################################################################################ # Profile and Gravatar -############################################################################### +################################################################################ @view_config(route_name="profile", request_method="GET") def profile_get_view(request): user = get_current_user(request) @@ -747,9 +959,47 @@ def profile_get_view(request): 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 "" + s = request.dbsession + + # Fetch user stats + solved_attempts = ( + s.query(Attempt) + .join(Puzzle) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .order_by(Attempt.time_completed.desc()) + .all() + ) + + total_puzzles_solved = len(solved_attempts) + + # Calculate streaks + solved_dates = sorted({a.puzzle.date for a in solved_attempts}) + current_streak = 0 + max_streak = 0 + last_date = None + for date in solved_dates: + if last_date and (date - last_date).days == 1: + current_streak += 1 + else: + current_streak = 1 + if current_streak > max_streak: + max_streak = current_streak + last_date = date + template = Template(PROFILE_TEMPLATE) rendered = template.render( - request=request, user=user, navigation=navigation, gravatar_url=gravatar_url + request=request, + user=user, + navigation=navigation, + gravatar_url=gravatar_url, + total_puzzles_solved=total_puzzles_solved, + current_streak=current_streak, + max_streak=max_streak, + solved_attempts=solved_attempts, ) return Response(rendered) @@ -762,13 +1012,101 @@ def profile_post_view(request): s = request.dbsession enable_gravatar = request.POST.get("enable_gravatar") == "on" user.enable_gravatar = enable_gravatar + + # Handle username update if provided + new_username = request.POST.get("new_username", "").strip() + if new_username: + # Check if the new username is already taken + existing = s.query(User).filter(User.username == new_username).first() + if existing and existing.id != user.id: + return Response("Username is already in use.", status=400) + user.username = new_username + s.commit() return HTTPFound(location=request.route_url("profile")) -############################################################################### -# Puzzle and Leaderboard -############################################################################### +@view_config(route_name="view_profile") +def view_profile(request): + s = request.dbsession + user_id = request.matchdict.get("user_id") + user = s.query(User).filter_by(id=user_id).first() + if not user: + return Response("User not found.", status=404) + + navigation = get_navigation(request) + gravatar_url = get_gravatar_url(user.email) if user.enable_gravatar else "" + + # Fetch user stats + solved_attempts = ( + s.query(Attempt) + .join(Puzzle) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .order_by(Attempt.time_completed.desc()) + .all() + ) + + total_puzzles_solved = len(solved_attempts) + + # Calculate streaks + solved_dates = sorted({a.puzzle.date for a in solved_attempts}) + current_streak = 0 + max_streak = 0 + last_date = None + for date in solved_dates: + if last_date and (date - last_date).days == 1: + current_streak += 1 + else: + current_streak = 1 + if current_streak > max_streak: + max_streak = current_streak + last_date = date + + template = Template(PROFILE_TEMPLATE) + rendered = template.render( + request=request, + user=user, + navigation=navigation, + gravatar_url=gravatar_url, + total_puzzles_solved=total_puzzles_solved, + current_streak=current_streak, + max_streak=max_streak, + solved_attempts=solved_attempts, + ) + return Response(rendered) + + +################################################################################ +# Puzzle Menu +################################################################################ +@view_config(route_name="puzzle_menu") +def puzzle_menu_view(request): + navigation = get_navigation(request) + s = request.dbsession + today = datetime.date.today() + puzzles = {} + puzzle_sizes = [3, 4, 5] + for size in puzzle_sizes: + puzzle = s.query(Puzzle).filter_by(date=today, size=size).first() + puzzles[size] = puzzle + + template = Template(PUZZLE_MENU_TEMPLATE) + rendered = template.render( + request=request, + navigation=navigation, + puzzles=puzzles, + puzzle_sizes=puzzle_sizes, + ) + return Response(rendered) + + +################################################################################ +# Puzzle Upload +################################################################################ @view_config(route_name="upload", request_method="GET") def upload_get_view(request): navigation = get_navigation(request) @@ -780,78 +1118,100 @@ def upload_get_view(request): @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") + puzzle_sizes = [3, 4, 5] + puzzles_uploaded = 0 - 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 - ) + if not puzzle_date_str: + return Response("Missing date.", 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() - 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, - initial_state_json=initial_state_json, - ) - s.add(puzzle) + for size in puzzle_sizes: + title = request.POST.get(f"title_{size}") or f"AI Puzzle {size}x{size}" + image_file = request.POST.get(f"image_file_{size}") + + if image_file is None or not getattr(image_file, "filename", "").strip(): + continue # Skip if no image uploaded for this size + + # 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( + f"Unsupported file type for {size}x{size} puzzle. Allowed types: png, jpg, jpeg, gif.", + status=400, + ) + + raw_bytes = image_file.file.read() + max_size = 5 * 1024 * 1024 # 5 MB + if len(raw_bytes) > max_size: + return Response( + f"File size exceeds the 5MB limit for {size}x{size} puzzle.", status=400 + ) + + encoded_str = base64.b64encode(raw_bytes).decode("utf-8") + + # Generate initial puzzle state (random configuration) + gridSize = size + 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) + + existing = s.query(Puzzle).filter_by(date=puzzle_date, size=size).first() + if existing: + return Response( + f"Puzzle of size {size}x{size} already exists for {puzzle_date}.", + status=400, + ) + + puzzle = Puzzle( + date=puzzle_date, + size=size, + image_b64=encoded_str, + title=title, + initial_state_json=initial_state_json, + ) + s.add(puzzle) + puzzles_uploaded += 1 + s.commit() + if puzzles_uploaded == 0: + return Response("No puzzles were uploaded.", status=400) + return HTTPFound(location=request.route_url("home")) +################################################################################ +# Daily Puzzle +################################################################################ @view_config(route_name="daily_puzzle") def daily_puzzle_view(request): s = request.dbsession + size = int(request.matchdict.get("size", 4)) today = datetime.date.today() - puzzle = s.query(Puzzle).filter_by(date=today).first() + puzzle = s.query(Puzzle).filter_by(date=today, size=size).first() + + navigation = get_navigation(request) if not puzzle: - navigation = get_navigation(request) - rendered = f"

No puzzle for today!

Back to Home

{navigation}" + rendered = "

No puzzle available!

" return Response(rendered) # Check if user has an existing attempt @@ -861,6 +1221,17 @@ def daily_puzzle_view(request): user_id = user.id if user else None display_name = user.username if (user and user.username) else guest_name + s = request.dbsession + + # Check if user has already solved this puzzle + existing_solve = ( + s.query(Attempt) + .filter_by( + user_id=user_id, puzzle_id=puzzle.id, is_solved=True, is_counted=True + ) + .first() + ) + attempt = ( s.query(Attempt) .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False) @@ -873,10 +1244,12 @@ def daily_puzzle_view(request): ) 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 + + # If the user already solved this puzzle, mark new attempts as not counted + is_counted = False if existing_solve else True + attempt = Attempt( user_id=user_id, user_display_name=display_name, @@ -888,6 +1261,7 @@ def daily_puzzle_view(request): is_solved=False, state_json=puzzle.initial_state_json, time_started=datetime.datetime.utcnow(), + is_counted=is_counted, ) s.add(attempt) s.commit() @@ -895,8 +1269,49 @@ def daily_puzzle_view(request): # Prepare the puzzle filename for the download button puzzle_filename = slugify(puzzle.title) + ".png" - navigation = get_navigation(request) + # Get user's rank if they have solved it + user_rank = None + if existing_solve: + attempts = ( + s.query(Attempt) + .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) + .order_by( + (Attempt.time_completed - Attempt.time_started).asc(), + (Attempt.move_count + Attempt.rotation_count).asc(), + ) + .all() + ) + for idx, att in enumerate(attempts): + if att.user_id == user_id: + user_rank = idx + 1 + break + # Render the puzzle page with the attempt state + puzzle_css = f""" + #boardContainer {{ + width: 100%; + max-width: 400px; + margin: 20px auto; + display: grid; + }} + .piece {{ + width: 100%; + height: 100%; + border: 1px solid #999; + box-sizing: border-box; + background-image: url("data:image/png;base64,{puzzle.image_b64}"); + background-size: cover; + cursor: pointer; + position: relative; + }} + .empty-cell {{ + width: 100%; + height: 100%; + border: 1px solid #ccc; + box-sizing: border-box; + }} + """ + template = Template(DAILY_PUZZLE_TEMPLATE) rendered = template.render( request=request, @@ -904,9 +1319,11 @@ def daily_puzzle_view(request): attempt_state_json=attempt.state_json, move_count=attempt.move_count, rotation_count=attempt.rotation_count, - attempt_is_solved=attempt.is_solved, + attempt_is_solved=attempt.is_solved or existing_solve is not None, puzzle_filename=puzzle_filename, navigation=navigation, + user_rank=user_rank, + puzzle_css=puzzle_css, ) return Response(rendered) @@ -932,8 +1349,9 @@ def record_action_view(request): ensure_guest_name_in_session(request) guest_name = request.session["guest_name"] + size = int(request.matchdict.get("size", 4)) today = datetime.date.today() - puzzle = s.query(Puzzle).filter_by(date=today).first() + puzzle = s.query(Puzzle).filter_by(date=today, size=size).first() if not puzzle: return Response( @@ -942,6 +1360,16 @@ def record_action_view(request): ) user_id = user.id if user else None + display_name = user.username if (user and user.username) else guest_name + + # Check if user has already solved this puzzle + existing_solve = ( + s.query(Attempt) + .filter_by( + user_id=user_id, puzzle_id=puzzle.id, is_solved=True, is_counted=True + ) + .first() + ) attempt = ( s.query(Attempt) @@ -1011,8 +1439,26 @@ def record_action_view(request): attempt.is_solved = True 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( + (Attempt.time_completed - 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"}), + json.dumps({"status": "win", "rank": user_rank}), content_type="application/json; charset=UTF-8", ) else: @@ -1022,10 +1468,13 @@ def record_action_view(request): ) +################################################################################ +# Leaderboards +################################################################################ @view_config(route_name="leaderboard_index") def leaderboard_index_view(request): s = request.dbsession - puzzles = s.query(Puzzle).order_by(Puzzle.date.desc()).all() + puzzles = s.query(Puzzle).order_by(Puzzle.date.desc(), Puzzle.size.asc()).all() navigation = get_navigation(request) template = Template(LEADERBOARD_INDEX_TEMPLATE) rendered = template.render(request=request, puzzles=puzzles, navigation=navigation) @@ -1045,7 +1494,7 @@ def daily_leaderboard_view(request): # Sort by least total actions (moves + rotations) attempts = ( s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True) + .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) .order_by( (Attempt.move_count + Attempt.rotation_count).asc(), (Attempt.time_completed - Attempt.time_started).asc(), @@ -1056,7 +1505,7 @@ def daily_leaderboard_view(request): # Default sort by fastest time attempts = ( s.query(Attempt) - .filter_by(puzzle_id=puzzle.id, is_solved=True) + .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) .order_by( (Attempt.time_completed - Attempt.time_started).asc(), (Attempt.move_count + Attempt.rotation_count).asc(), @@ -1072,18 +1521,95 @@ def daily_leaderboard_view(request): return Response(rendered) -############################################################################### +@view_config(route_name="king_of_the_mountain") +def king_of_the_mountain_view(request): + s = request.dbsession + + # Aggregate scores + users = s.query(User).all() + leaderboard = [] + for user in users: + total_puzzles = ( + s.query(func.count(Attempt.id)) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .scalar() + ) + + if total_puzzles == 0: + continue # Skip users with no solves + + total_time = ( + s.query( + func.sum( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ) + ) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .scalar() + * 86400 + ) # Convert days to seconds + + total_moves = ( + s.query(func.sum(Attempt.move_count)) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .scalar() + ) + + total_rotations = ( + s.query(func.sum(Attempt.rotation_count)) + .filter( + Attempt.user_id == user.id, + Attempt.is_solved == True, + Attempt.is_counted == True, + ) + .scalar() + ) + + leaderboard.append( + { + "user_id": user.id, + "username": user.username or user.email, + "total_puzzles": total_puzzles, + "total_time": total_time, + "total_moves": total_moves, + "total_rotations": total_rotations, + } + ) + + # Sort leaderboard + leaderboard.sort(key=lambda x: (-x["total_puzzles"], x["total_time"])) + + navigation = get_navigation(request) + template = Template(KING_OF_THE_MOUNTAIN_TEMPLATE) + rendered = template.render( + request=request, global_leaderboard=leaderboard, navigation=navigation + ) + return Response(rendered) + + +################################################################################ # Main -############################################################################### +################################################################################ def main(global_config=None, **settings): if not settings: settings = {} settings["sqlalchemy.url"] = DB_URL config = Configurator(settings=settings, session_factory=session_factory) - config.include( - "pyramid_jinja2" - ) # Included for potential future use with external templates + # config.include('pyramid_jinja2') # Included for potential future use with external templates def dbsession(request): return Session() @@ -1099,15 +1625,18 @@ def main(global_config=None, **settings): config.add_route("logout", "/auth/logout") config.add_route("change_username", "/auth/change_username") config.add_route("profile", "/auth/profile") + config.add_route("view_profile", "/user/{user_id}") # Puzzle config.add_route("upload", "/upload") - config.add_route("daily_puzzle", "/puzzle/today") - config.add_route("record_action", "/puzzle/record_action") + config.add_route("puzzle_menu", "/puzzle/menu") + config.add_route("daily_puzzle", "/puzzle/{size}") + config.add_route("record_action", "/puzzle/{size}/record_action") # Leaderboards config.add_route("leaderboard_index", "/leaderboards") config.add_route("daily_leaderboard", "/leaderboard/{puzzle_id}") + config.add_route("king_of_the_mountain", "/king_of_the_mountain") config.scan() return config.make_wsgi_app()