From e13b5ef5fa1db1f485b060dda2d14b6671cd1a2e Mon Sep 17 00:00:00 2001 From: Russell Ballestrini Date: Fri, 27 Dec 2024 12:17:52 -0500 Subject: [PATCH] refactor nfg modified: app.py new file: templates/admin_dashboard.html.j2 new file: templates/base.html.j2 new file: templates/daily_leaderboard.html.j2 new file: templates/daily_puzzle.html.j2 new file: templates/home.html.j2 new file: templates/invite_admin.html.j2 new file: templates/king_of_the_mountain.html.j2 new file: templates/leaderboard_index.html.j2 new file: templates/login.html.j2 new file: templates/manage_admins.html.j2 new file: templates/profile.html.j2 new file: templates/puzzle_menu.html.j2 new file: templates/upload.html.j2 new file: templates/verify.html.j2 --- app.py | 1335 ++++++++---------------- templates/admin_dashboard.html.j2 | 34 + templates/base.html.j2 | 49 + templates/daily_leaderboard.html.j2 | 30 + templates/daily_puzzle.html.j2 | 344 ++++++ templates/home.html.j2 | 12 + templates/invite_admin.html.j2 | 18 + templates/king_of_the_mountain.html.j2 | 26 + templates/leaderboard_index.html.j2 | 21 + templates/login.html.j2 | 10 + templates/manage_admins.html.j2 | 40 + templates/profile.html.j2 | 47 + templates/puzzle_menu.html.j2 | 13 + templates/upload.html.j2 | 16 + templates/verify.html.j2 | 9 + 15 files changed, 1101 insertions(+), 903 deletions(-) create mode 100644 templates/admin_dashboard.html.j2 create mode 100644 templates/base.html.j2 create mode 100644 templates/daily_leaderboard.html.j2 create mode 100644 templates/daily_puzzle.html.j2 create mode 100644 templates/home.html.j2 create mode 100644 templates/invite_admin.html.j2 create mode 100644 templates/king_of_the_mountain.html.j2 create mode 100644 templates/leaderboard_index.html.j2 create mode 100644 templates/login.html.j2 create mode 100644 templates/manage_admins.html.j2 create mode 100644 templates/profile.html.j2 create mode 100644 templates/puzzle_menu.html.j2 create mode 100644 templates/upload.html.j2 create mode 100644 templates/verify.html.j2 diff --git a/app.py b/app.py index 5e66b90..7e78168 100644 --- a/app.py +++ b/app.py @@ -1,14 +1,4 @@ -############################################################################### -# 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. -############################################################################### +# app.py import os import base64 @@ -18,14 +8,16 @@ import random import string import bcrypt import re +import uuid import hashlib +import smtplib +from email.mime.text import MIMEText from pyramid.config import Configurator from pyramid.view import view_config -from pyramid.response import Response, FileResponse -from pyramid.httpexceptions import HTTPFound +from pyramid.response import Response +from pyramid.httpexceptions import HTTPFound, HTTPForbidden from pyramid.session import SignedCookieSessionFactory - from sqlalchemy import ( create_engine, Column, @@ -41,10 +33,10 @@ from sqlalchemy import ( ) from sqlalchemy.ext.declarative import declarative_base from sqlalchemy.orm import sessionmaker, relationship - from waitress import serve -from jinja2 import Template +# Import renderers for templates +from pyramid.renderers import render_to_response ################################################################################ # Database Setup @@ -61,20 +53,69 @@ class User(Base): code_expires = Column(DateTime, nullable=True) # time limit for code is_verified = Column(Boolean, default=False) enable_gravatar = Column(Boolean, default=False) # Gravatar support + is_admin = Column(Boolean, default=False) # Admin flag +import uuid # Add this import at the top of your file + class Puzzle(Base): __tablename__ = "puzzles" id = Column(Integer, primary_key=True) 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 - ) # initial positions and rotations - title = Column(String, default="Daily Puzzle") - __table_args__ = ({"sqlite_autoincrement": True},) + # 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, title="Daily Puzzle", is_visible=True): + self.date = date + self.size = size + self.image_b64 = image_b64 + self.title = title + self.is_visible = is_visible + + # Generate initial and solution states + self.generate_states() + + def generate_states(self): + total_pieces = self.size * self.size + pieces = [] + + for idx in range(total_pieces): + piece_uuid = str(uuid.uuid4()) + piece = { + "uuid": piece_uuid, + "correctIndex": idx, + "currentIndex": idx, + "rotation": random.choice([0, 90, 180, 270]), # Random initial rotation + } + pieces.append(piece) + + # Shuffle the pieces to create the initial state + random.shuffle(pieces) + + # Update currentIndex after shuffling + for idx, piece in enumerate(pieces): + piece['currentIndex'] = idx + + # Save states to the database + self.initial_state_json = json.dumps(pieces) + # For solution, pieces are in correct order with rotation = 0 + solution_pieces = [ + { + "uuid": piece["uuid"], + "correctIndex": piece["correctIndex"], + "currentIndex": piece["correctIndex"], + "rotation": 0 + } + for piece in pieces + ] + self.solution_state_json = json.dumps(solution_pieces) class Attempt(Base): @@ -128,657 +169,35 @@ 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""" - -""" +def send_email(to_email, subject, body): + # For testing purposes, print the email content to the console + print("======= Email Sent =======") + print(f"To: {to_email}") + print(f"Subject: {subject}") + print(f"Body:\n{body}") + print("==========================") -################################################################################ -# Inline Templates -################################################################################ -HOME_TEMPLATE = r""" - - - - Daily Puzzles - Home - - - - {{ navigation }} -

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

- - -""" + msg = MIMEText(body) + msg["Subject"] = subject + msg["From"] = "noreply@example.com" + msg["To"] = to_email -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.

-
- - -
- - -""" + try: + s = smtplib.SMTP("localhost", 25) + s.sendmail("noreply@example.com", [to_email], msg.as_string()) + s.quit() + except Exception as e: + print(f"Error sending email: {e}") -VERIFY_TEMPLATE = r""" - - -Verify Code - - {{ navigation }} -

Enter 6-digit code

-
- - -
- - -""" -CHANGE_USERNAME_TEMPLATE = r""" - - -Change Username - - {{ navigation }} -

Change Username

-
- - -
- - -""" +def admin_required(view_func): + def wrapper(request): + user = get_current_user(request) + if not user or not user.is_admin: + raise HTTPForbidden("You must be an admin to access this page.") + return view_func(request) -PROFILE_TEMPLATE = r""" - -Profile - - {{ navigation }} - -

Your Profile

-

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

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

Gravatar is disabled.

- {% endif %} -
-

- -

- - -
- -

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

- - - -""" - -UPLOAD_TEMPLATE = r""" - - -Upload Puzzles - - {{ navigation }} -

Upload Puzzles for Today

-
- -

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

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

- -

- -

- {% endfor %} - -
- - -""" - -DAILY_PUZZLE_TEMPLATE = r""" - - - - {{ puzzle.title }} - {{ puzzle.date }} - - - - {{ navigation }} - {% if puzzle %} -

{{ puzzle.title }}

-

Date: {{ puzzle.date }}

-

- Moves: {{ move_count }}
- Rotations: {{ rotation_count }}
-

- -
- - - - -
-
- × -

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 available!

- {% endif %} - - -""" - -LEADERBOARD_INDEX_TEMPLATE = r""" - - -All Leaderboards - - {{ navigation }} -

All Leaderboards

-

Click a puzzle date to see its full scoreboard.

- - - - - - - - {% for p in puzzles %} - - - - - - - {% endfor %} -
Puzzle DateSizeTitleActions
{{ p.date }}{{ p.size }}x{{ p.size }}{{ p.title }}View Leaderboard
- - -""" - -DAILY_LEADERBOARD_TEMPLATE = r""" - - -Daily Leaderboard - - {{ navigation }} -

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

-

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

- - - - - - - - - - {% for row in attempts %} - - - - - - - - - {% endfor %} -
RankUsername/GuestMovesRotationsTotal ActionsTime (sec)
{{ loop.index }}{{ row.user_display_name }}{{ row.move_count }}{{ row.rotation_count }}{{ row.move_count + row.rotation_count }}{{ "%.2f"|format((row.time_completed - row.time_started).total_seconds() if row.time_completed else 0) }}
- - -""" - -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 }}
- - -""" + return wrapper ################################################################################ @@ -800,37 +219,24 @@ 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") +@view_config(route_name="home", renderer="home.html.j2") def home_view(request): - user = get_current_user(request) ensure_guest_name_in_session(request) guest_name = request.session["guest_name"] - navigation = get_navigation(request) - template = Template(HOME_TEMPLATE) - rendered = template.render( - request=request, user=user, guest_name=guest_name, navigation=navigation - ) - return Response(rendered) + return { + "request": request, + "guest_name": guest_name, + } -@view_config(route_name="login", request_method="GET") +@view_config(route_name="login", request_method="GET", renderer="login.html.j2") def login_get_view(request): - navigation = get_navigation(request) - template = Template(LOGIN_TEMPLATE) - rendered = template.render(request=request, navigation=navigation) - return Response(rendered) + return {"request": request} @view_config(route_name="login", request_method="POST") @@ -845,6 +251,12 @@ def login_post_view(request): if not user: # Create new user with null username for now user = User(email=email, username=None) + + # First user becomes root admin + user_count = session.query(func.count(User.id)).scalar() + if user_count == 0: + user.is_admin = True + session.add(user) session.commit() @@ -858,19 +270,16 @@ def login_post_view(request): user.is_verified = False session.commit() - # In a real app, you'd send code_str via email. We'll just display it for MVP. - return Response( - f"Your 6-digit code is: {code_str} (MVP, not real email).
" - f"Enter code" - ) + # Send code via email + email_body = f"Your verification code is: {code_str}" + send_email(user.email, "Your Verification Code", email_body) + + return HTTPFound(location=request.route_url("verify")) -@view_config(route_name="verify", request_method="GET") +@view_config(route_name="verify", request_method="GET", renderer="verify.html.j2") def verify_get_view(request): - navigation = get_navigation(request) - template = Template(VERIFY_TEMPLATE) - rendered = template.render(request=request, navigation=navigation) - return Response(rendered) + return {"request": request} @view_config(route_name="verify", request_method="POST") @@ -884,7 +293,7 @@ def verify_post_view(request): potential_users = ( s.query(User) .filter( - User.is_verified == False, + # User.is_verified == False, User.code_expires > datetime.datetime.now(), User.code_hash != None, ) @@ -915,48 +324,14 @@ def logout_view(request): return HTTPFound(location=request.route_url("home")) -@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) - navigation = get_navigation(request) - template = Template(CHANGE_USERNAME_TEMPLATE) - rendered = template.render(request=request, navigation=navigation) - return Response(rendered) - - -@view_config(route_name="change_username", request_method="POST") -def change_username_post_view(request): - user = get_current_user(request) - if not user: - return Response("You must be logged in to change your username.", status=403) - - new_username = request.POST.get("new_username", "").strip() - if not new_username: - return Response("Username cannot be empty.", status=400) - - s = request.dbsession - # Check if some other user has that username - existing = s.query(User).filter(User.username == new_username).first() - if existing and existing.id != user.id: - return Response("Username is taken by another user.", status=400) - - user.username = new_username - s.commit() - - return HTTPFound(location=request.route_url("profile")) - - ################################################################################ # Profile and Gravatar ################################################################################ -@view_config(route_name="profile", request_method="GET") +@view_config(route_name="profile", request_method="GET", renderer="profile.html.j2") def profile_get_view(request): - user = get_current_user(request) + user = request.user 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 "" s = request.dbsession @@ -989,43 +364,39 @@ def profile_get_view(request): 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) + return { + "request": request, + "user": user, + "gravatar_url": gravatar_url, + "total_puzzles_solved": total_puzzles_solved, + "current_streak": current_streak, + "max_streak": max_streak, + "solved_attempts": solved_attempts, + } @view_config(route_name="profile", request_method="POST") def profile_post_view(request): - user = get_current_user(request) - if not user: + if not request.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 + request.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: + if existing and existing.id != request.user.id: return Response("Username is already in use.", status=400) - user.username = new_username + request.user.username = new_username s.commit() return HTTPFound(location=request.route_url("profile")) -@view_config(route_name="view_profile") +@view_config(route_name="view_profile", renderer="profile.html.j2") def view_profile(request): s = request.dbsession user_id = request.matchdict.get("user_id") @@ -1033,7 +404,6 @@ def view_profile(request): 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 @@ -1065,56 +435,193 @@ def view_profile(request): 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) + return { + "request": request, + "user": user, + "gravatar_url": gravatar_url, + "total_puzzles_solved": total_puzzles_solved, + "current_streak": current_streak, + "max_streak": max_streak, + "solved_attempts": solved_attempts, + } ################################################################################ # Puzzle Menu ################################################################################ -@view_config(route_name="puzzle_menu") +@view_config(route_name="puzzle_menu", renderer="puzzle_menu.html.j2") 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() + puzzle = ( + s.query(Puzzle).filter_by(date=today, size=size, is_visible=True).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) + return { + "request": request, + "puzzles": puzzles, + "puzzle_sizes": puzzle_sizes, + } + + +################################################################################ +# Admin Views +################################################################################ +@view_config(route_name="admin_dashboard", renderer="admin_dashboard.html.j2") +@admin_required +def admin_dashboard_view(request): + s = request.dbsession + puzzles = s.query(Puzzle).order_by(Puzzle.date.desc(), Puzzle.size.asc()).all() + return { + "request": request, + "puzzles": puzzles, + } + + +@view_config( + route_name="invite_admin", request_method="GET", renderer="invite_admin.html.j2" +) +@admin_required +def invite_admin_get_view(request): + return {"request": request} + + +@view_config(route_name="invite_admin", request_method="POST") +@admin_required +def invite_admin_post_view(request): + email = request.POST.get("email", "").strip().lower() + if not email: + return Response("Email required.", status=400) + + s = request.dbsession + user = s.query(User).filter_by(email=email).first() + + if not user: + # Create user record + user = User(email=email, 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) + + return HTTPFound(location=request.route_url("admin_dashboard")) + + +@view_config(route_name="manage_admins", renderer="manage_admins.html.j2") +@admin_required +def manage_admins_view(request): + s = request.dbsession + user = get_current_user(request) + admins = s.query(User).filter_by(is_admin=True).all() + return { + "request": request, + "admins": admins, + "user": user, + } + + +@view_config(route_name="remove_admin") +@admin_required +def remove_admin_view(request): + s = request.dbsession + current_user = get_current_user(request) + user_id = int(request.matchdict.get("user_id")) + if user_id == current_user.id or user_id == 1: + return Response("Cannot remove root admin or yourself.", status=400) + user = s.query(User).filter_by(id=user_id).first() + if not user: + return Response("User not found.", status=404) + user.is_admin = False + s.commit() + return HTTPFound(location=request.route_url("manage_admins")) + + +@view_config( + route_name="edit_puzzle", request_method="GET", renderer="edit_puzzle.html.j2" +) +@admin_required +def edit_puzzle_get_view(request): + s = request.dbsession + puzzle_id = request.matchdict.get("puzzle_id") + puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first() + if not puzzle: + return Response("Puzzle not found.", status=404) + return { + "request": request, + "puzzle": puzzle, + } + + +@view_config(route_name="edit_puzzle", request_method="POST") +@admin_required +def edit_puzzle_post_view(request): + s = request.dbsession + puzzle_id = request.matchdict.get("puzzle_id") + puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first() + if not puzzle: + return Response("Puzzle not found.", status=404) + + puzzle_date_str = request.POST.get("puzzle_date") + title = request.POST.get("title") + + 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) + + image_file = request.POST.get("image_file") + if image_file and getattr(image_file, "filename", "").strip(): + 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 the puzzle.", status=400 + ) + encoded_str = base64.b64encode(raw_bytes).decode("utf-8") + puzzle.image_b64 = encoded_str + + puzzle.date = puzzle_date + puzzle.title = title or puzzle.title + s.commit() + + return HTTPFound(location=request.route_url("admin_dashboard")) + + +@view_config(route_name="toggle_visibility") +@admin_required +def toggle_visibility_view(request): + s = request.dbsession + puzzle_id = request.matchdict.get("puzzle_id") + puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first() + 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")) ################################################################################ # Puzzle Upload ################################################################################ -@view_config(route_name="upload", request_method="GET") +@view_config(route_name="upload", request_method="GET", renderer="upload.html.j2") +@admin_required def upload_get_view(request): - navigation = get_navigation(request) - template = Template(UPLOAD_TEMPLATE) - rendered = template.render(request=request, navigation=navigation) - return Response(rendered) + return {"request": request} @view_config(route_name="upload", request_method="POST") +@admin_required def upload_post_view(request): puzzle_date_str = request.POST.get("puzzle_date") puzzle_sizes = [3, 4, 5] @@ -1131,7 +638,7 @@ def upload_post_view(request): s = request.dbsession for size in puzzle_sizes: - title = request.POST.get(f"title_{size}") or f"AI Puzzle {size}x{size}" + title = request.POST.get(f"title_{size}") or f"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(): @@ -1157,22 +664,6 @@ def upload_post_view(request): 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( @@ -1180,12 +671,12 @@ def upload_post_view(request): status=400, ) + # Create the puzzle instance; states are generated in the constructor 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 @@ -1194,24 +685,21 @@ def upload_post_view(request): if puzzles_uploaded == 0: return Response("No puzzles were uploaded.", status=400) - return HTTPFound(location=request.route_url("home")) + return HTTPFound(location=request.route_url("admin_dashboard")) ################################################################################ # Daily Puzzle ################################################################################ -@view_config(route_name="daily_puzzle") +@view_config(route_name="daily_puzzle", renderer="daily_puzzle.html.j2") 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, size=size).first() - - navigation = get_navigation(request) + puzzle = s.query(Puzzle).filter_by(date=today, size=size, is_visible=True).first() if not puzzle: - rendered = "

No puzzle available!

" - return Response(rendered) + return {"request": request, "puzzle": None} # Check if user has an existing attempt user = get_current_user(request) @@ -1249,6 +737,11 @@ def daily_puzzle_view(request): # If the user already solved this puzzle, mark new attempts as not counted is_counted = False if existing_solve else True + # Use initial state or solution state based on whether the puzzle is solved + state_json = ( + existing_solve.state_json if existing_solve else puzzle.initial_state_json + ) + attempt = Attempt( user_id=user_id, user_display_name=display_name, @@ -1257,14 +750,21 @@ def daily_puzzle_view(request): attempt_number=attempt_number, move_count=0, rotation_count=0, - is_solved=False, - state_json=puzzle.initial_state_json, + is_solved=bool(existing_solve), + state_json=state_json, time_started=datetime.datetime.utcnow(), is_counted=is_counted, ) s.add(attempt) s.commit() + else: + # If the puzzle is already solved, ensure the attempt reflects that + if existing_solve: + attempt.is_solved = True + attempt.state_json = existing_solve.state_json + s.commit() + # Prepare the puzzle filename for the download button puzzle_filename = slugify(puzzle.title) + ".png" @@ -1275,7 +775,10 @@ def daily_puzzle_view(request): s.query(Attempt) .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) .order_by( - (Attempt.time_completed - Attempt.time_started).asc(), + ( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ).asc(), (Attempt.move_count + Attempt.rotation_count).asc(), ) .all() @@ -1285,46 +788,46 @@ def daily_puzzle_view(request): 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; - }} - """ + # Generate URLs for images + puzzle_image_url = request.route_url("puzzle_image", puzzle_id=puzzle.id) - 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 or existing_solve is not None, - puzzle_filename=puzzle_filename, - navigation=navigation, - user_rank=user_rank, - puzzle_css=puzzle_css, + return { + "request": request, + "puzzle": puzzle, + "attempt_state_json": attempt.state_json, # Now includes UUIDs + "move_count": attempt.move_count, + "rotation_count": attempt.rotation_count, + "attempt_is_solved": attempt.is_solved or existing_solve is not None, + "puzzle_filename": puzzle_filename, + "user_rank": user_rank, + "puzzle_image_url": puzzle_image_url, + } + + +@view_config(route_name="puzzle_image") +def puzzle_image_view(request): + s = request.dbsession + puzzle_id = request.matchdict.get("puzzle_id") + puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first() + if not puzzle: + return Response("Puzzle not found.", status=404) + + # Check if user has solved this puzzle + user = get_current_user(request) + ensure_guest_name_in_session(request) + user_id = user.id if user else None + + solved_attempt = ( + s.query(Attempt) + .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=True) + .first() ) - return Response(rendered) + + if not solved_attempt: + return Response("You must solve the puzzle to view the image.", status=403) + + image_data = base64.b64decode(puzzle.image_b64) + return Response(body=image_data, content_type="image/png") @view_config(route_name="record_action", request_method="POST") @@ -1350,7 +853,7 @@ def record_action_view(request): size = int(request.matchdict.get("size", 4)) today = datetime.date.today() - puzzle = s.query(Puzzle).filter_by(date=today, size=size).first() + puzzle = s.query(Puzzle).filter_by(date=today, size=size, is_visible=True).first() if not puzzle: return Response( @@ -1361,15 +864,6 @@ 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) .filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False) @@ -1385,35 +879,42 @@ 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 - pieceIndex = int(data.get("pieceIndex")) + piece_uuid = data.get("pieceUUID") rotation = int(data.get("rotation")) # Update rotation in state for piece in state: - if int(piece["currentIndex"]) == pieceIndex: + 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 - pieceAIndex = int(data.get("pieceAIndex")) - pieceBIndex = int(data.get("pieceBIndex")) - # Swap positions in state + 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 int(piece["currentIndex"]) == pieceAIndex: + if piece["uuid"] == piece_uuid_a: idxA = idx - if int(piece["currentIndex"]) == pieceBIndex: + 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 swap indices."}), + json.dumps({"status": "error", "message": "Invalid piece UUIDs."}), content_type="application/json; charset=UTF-8", ) else: @@ -1426,15 +927,10 @@ def record_action_view(request): attempt.state_json = json.dumps(state) # 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: + if all( + piece["currentIndex"] == piece["correctIndex"] and piece["rotation"] % 360 == 0 + for piece in state + ): attempt.is_solved = True attempt.time_completed = datetime.datetime.utcnow() s.commit() @@ -1444,7 +940,10 @@ def record_action_view(request): s.query(Attempt) .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) .order_by( - (Attempt.time_completed - Attempt.time_started).asc(), + ( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ).asc(), (Attempt.move_count + Attempt.rotation_count).asc(), ) .all() @@ -1470,17 +969,14 @@ def record_action_view(request): ################################################################################ # Leaderboards ################################################################################ -@view_config(route_name="leaderboard_index") +@view_config(route_name="leaderboard_index", renderer="leaderboard_index.html.j2") def leaderboard_index_view(request): s = request.dbsession 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) - return Response(rendered) + return {"request": request, "puzzles": puzzles} -@view_config(route_name="daily_leaderboard") +@view_config(route_name="daily_leaderboard", renderer="daily_leaderboard.html.j2") def daily_leaderboard_view(request): s = request.dbsession puzzle_id = request.matchdict.get("puzzle_id") @@ -1496,7 +992,10 @@ def daily_leaderboard_view(request): .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(), + ( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ).asc(), ) .all() ) @@ -1506,21 +1005,23 @@ def daily_leaderboard_view(request): s.query(Attempt) .filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True) .order_by( - (Attempt.time_completed - Attempt.time_started).asc(), + ( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ).asc(), (Attempt.move_count + Attempt.rotation_count).asc(), ) .all() ) - navigation = get_navigation(request) - template = Template(DAILY_LEADERBOARD_TEMPLATE) - rendered = template.render( - request=request, puzzle=puzzle, attempts=attempts, navigation=navigation - ) - return Response(rendered) + return { + "request": request, + "puzzle": puzzle, + "attempts": attempts, + } -@view_config(route_name="king_of_the_mountain") +@view_config(route_name="king_of_the_mountain", renderer="king_of_the_mountain.html.j2") def king_of_the_mountain_view(request): s = request.dbsession @@ -1544,8 +1045,11 @@ def king_of_the_mountain_view(request): total_time = ( s.query( func.sum( - func.julianday(Attempt.time_completed) - - func.julianday(Attempt.time_started) + ( + func.julianday(Attempt.time_completed) + - func.julianday(Attempt.time_started) + ) + * 86400 ) ) .filter( @@ -1554,8 +1058,7 @@ def king_of_the_mountain_view(request): Attempt.is_counted == True, ) .scalar() - * 86400 - ) # Convert days to seconds + ) # Time in seconds total_moves = ( s.query(func.sum(Attempt.move_count)) @@ -1591,24 +1094,41 @@ def king_of_the_mountain_view(request): # 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) + return { + "request": request, + "global_leaderboard": leaderboard, + } + + +def get_current_user(request): + """Return the currently logged-in user (if any) from session.""" + user_id = request.session.get("user_id") + if not user_id: + return None + s = request.dbsession + user = s.query(User).filter_by(id=user_id, is_verified=True).first() + return user ################################################################################ # Main ################################################################################ def main(global_config=None, **settings): + from pyramid.decorator import reify + 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") + # Add .html.j2 extension for Jinja2 templates + # Set up Jinja2 template search path + config.add_jinja2_renderer(".j2") + config.add_jinja2_search_path("templates", name=".j2") + + # Add user to all requests. + config.add_request_method(callable=get_current_user, name="user", reify=True) def dbsession(request): return Session() @@ -1622,15 +1142,24 @@ def main(global_config=None, **settings): 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") config.add_route("view_profile", "/user/{user_id}") + # Admin + config.add_route("admin_dashboard", "/admin") + config.add_route("invite_admin", "/admin/invite") + config.add_route("manage_admins", "/admin/manage") + config.add_route("remove_admin", "/admin/remove/{user_id}") + + config.add_route("edit_puzzle", "/admin/puzzle/{puzzle_id}/edit") + config.add_route("toggle_visibility", "/admin/puzzle/{puzzle_id}/toggle") + # Puzzle - config.add_route("upload", "/upload") + config.add_route("upload", "/admin/upload") config.add_route("puzzle_menu", "/puzzle/menu") config.add_route("daily_puzzle", "/puzzle/{size}") config.add_route("record_action", "/puzzle/{size}/record_action") + config.add_route("puzzle_image", "/puzzle/{puzzle_id}/image") # Leaderboards config.add_route("leaderboard_index", "/leaderboards") diff --git a/templates/admin_dashboard.html.j2 b/templates/admin_dashboard.html.j2 new file mode 100644 index 0000000..a01f5ad --- /dev/null +++ b/templates/admin_dashboard.html.j2 @@ -0,0 +1,34 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Admin Dashboard

+

Welcome, {{ request.user.username }}! Manage puzzles and admins here.

+

Puzzle Management

+

Upload/Schedule Puzzles

+

Admin Management

+

Invite Admin

+

Manage Admins

+

Scheduled Puzzles

+ + + + + + + + {% for p in puzzles %} + + + + + + + {% endfor %} +
DateSizeTitleActions
{{ p.date }}{{ p.size }}x{{ p.size }}{{ p.title }} + Edit + {% if p.is_visible %} + Hide + {% else %} + Show + {% endif %} +
+{% endblock %} diff --git a/templates/base.html.j2 b/templates/base.html.j2 new file mode 100644 index 0000000..5394f5f --- /dev/null +++ b/templates/base.html.j2 @@ -0,0 +1,49 @@ + + + + {% block title %}My Puzzle Game{% endblock %} + + + + + + +{% if request.session.peek_flash() %} + {% for message in request.session.pop_flash() %} +
{{ message }}
+ {% endfor %} +{% endif %} + +
+ {% block content %} + + {% endblock %} +
+ + diff --git a/templates/daily_leaderboard.html.j2 b/templates/daily_leaderboard.html.j2 new file mode 100644 index 0000000..c658b0a --- /dev/null +++ b/templates/daily_leaderboard.html.j2 @@ -0,0 +1,30 @@ +{% extends 'base.html.j2' %} +{% block content %} +

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

+

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

+

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

+ + + + + + + + + + {% for row in attempts %} + + + + + + + + + {% endfor %} +
RankUsername/GuestMovesRotationsTotal ActionsTime (sec)
{{ loop.index }}{{ row.user_display_name }}{{ row.move_count }}{{ row.rotation_count }}{{ row.move_count + row.rotation_count }}{{ "%.2f"|format((row.time_completed - row.time_started).total_seconds() if row.time_completed else 0) }}
+{% endblock %} diff --git a/templates/daily_puzzle.html.j2 b/templates/daily_puzzle.html.j2 new file mode 100644 index 0000000..a5db76d --- /dev/null +++ b/templates/daily_puzzle.html.j2 @@ -0,0 +1,344 @@ +{% extends 'base.html.j2' %} + +{% block content %} +{% if puzzle %} +

{{ puzzle.title }}

+

Date: {{ puzzle.date }}

+

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

+ +
+ + + + + + + + + + + +{% else %} +

No puzzle available!

+{% endif %} +{% endblock %} diff --git a/templates/home.html.j2 b/templates/home.html.j2 new file mode 100644 index 0000000..8ac6c81 --- /dev/null +++ b/templates/home.html.j2 @@ -0,0 +1,12 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Daily Puzzles - Home

+{% if request.user %} +

You are logged in as {{ request.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

+{% endblock %} diff --git a/templates/invite_admin.html.j2 b/templates/invite_admin.html.j2 new file mode 100644 index 0000000..fef188f --- /dev/null +++ b/templates/invite_admin.html.j2 @@ -0,0 +1,18 @@ +{% extends 'base.html.j2' %} + +{% block title %}Invite Admin{% endblock %} + +{% block content %} +

Invite a New Admin

+ +
+ + + +
+ +{% if message %} +

{{ message }}

+{% endif %} +{% endblock %} + diff --git a/templates/king_of_the_mountain.html.j2 b/templates/king_of_the_mountain.html.j2 new file mode 100644 index 0000000..eefc5e7 --- /dev/null +++ b/templates/king_of_the_mountain.html.j2 @@ -0,0 +1,26 @@ +{% extends 'base.html.j2' %} +{% block content %} +

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 }}
+{% endblock %} diff --git a/templates/leaderboard_index.html.j2 b/templates/leaderboard_index.html.j2 new file mode 100644 index 0000000..e43fa2d --- /dev/null +++ b/templates/leaderboard_index.html.j2 @@ -0,0 +1,21 @@ +{% extends 'base.html.j2' %} +{% block content %} +

All Leaderboards

+

Click a puzzle date to see its full scoreboard.

+ + + + + + + + {% for p in puzzles %} + + + + + + + {% endfor %} +
Puzzle DateSizeTitleActions
{{ p.date }}{{ p.size }}x{{ p.size }}{{ p.title }}View Leaderboard
+{% endblock %} diff --git a/templates/login.html.j2 b/templates/login.html.j2 new file mode 100644 index 0000000..5c8133a --- /dev/null +++ b/templates/login.html.j2 @@ -0,0 +1,10 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Login

+

Enter your email to receive a 6-digit code.

+
+ + + +
+{% endblock %} diff --git a/templates/manage_admins.html.j2 b/templates/manage_admins.html.j2 new file mode 100644 index 0000000..9e11acd --- /dev/null +++ b/templates/manage_admins.html.j2 @@ -0,0 +1,40 @@ +{% extends 'base.html.j2' %} + +{% block title %}Manage Admins{% endblock %} + +{% block content %} +

Manage Admins

+ + + + + + + + + + + {% for admin in admins %} + + + + + + {% endfor %} + +
UsernameEmailActions
{{ admin.username }}{{ admin.email }} + {% if admin.id != request.user.id %} +
+ +
+ {% else %} + Current User + {% endif %} +
+ +{% if message %} +

{{ message }}

+{% endif %} + +

Invite a New Admin

+{% endblock %} diff --git a/templates/profile.html.j2 b/templates/profile.html.j2 new file mode 100644 index 0000000..9907024 --- /dev/null +++ b/templates/profile.html.j2 @@ -0,0 +1,47 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Your Profile

+

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

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

Gravatar is disabled.

+{% endif %} +
+

+ +

+ + +
+ +

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()) }}
+{% endblock %} diff --git a/templates/puzzle_menu.html.j2 b/templates/puzzle_menu.html.j2 new file mode 100644 index 0000000..7260d5a --- /dev/null +++ b/templates/puzzle_menu.html.j2 @@ -0,0 +1,13 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Select a Puzzle Size

+ +{% endblock %} diff --git a/templates/upload.html.j2 b/templates/upload.html.j2 new file mode 100644 index 0000000..d4c6451 --- /dev/null +++ b/templates/upload.html.j2 @@ -0,0 +1,16 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Upload Puzzles

+
+ +

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

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

+ +

+ +

+ {% endfor %} + +
+{% endblock %} diff --git a/templates/verify.html.j2 b/templates/verify.html.j2 new file mode 100644 index 0000000..ab1bd0f --- /dev/null +++ b/templates/verify.html.j2 @@ -0,0 +1,9 @@ +{% extends 'base.html.j2' %} +{% block content %} +

Enter 6-digit code

+
+ + + +
+{% endblock %}