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 %}
-
- {% else %}
- Gravatar is disabled.
- {% endif %}
-
-
- Stats
- Total Puzzles Solved: {{ total_puzzles_solved }}
- Current Streak: {{ current_streak }} days
- Max Streak: {{ max_streak }} days
- Your Solves
-
-
- Date
- Size
- Moves
- Rotations
- Time (sec)
-
- {% for attempt in solved_attempts %}
-
- {{ 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()) }}
-
- {% endfor %}
-
-
-
-"""
-
-PUZZLE_MENU_TEMPLATE = r"""
-
-
-
- Today's Puzzles
-
-
- {{ navigation }}
- Select a Puzzle Size
-
- {% for size in puzzle_sizes %}
- {% if puzzles[size] %}
- {{ size }}x{{ size }} Puzzle
- {% else %}
- {{ size }}x{{ size }} Puzzle - Not Available Today
- {% endif %}
- {% endfor %}
-
-
-
-"""
-
-UPLOAD_TEMPLATE = r"""
-
-
-Upload Puzzles
-
- {{ navigation }}
- Upload Puzzles for Today
-
-
-
-"""
-
-DAILY_PUZZLE_TEMPLATE = r"""
-
-
-
- {{ puzzle.title }} - {{ puzzle.date }}
-
-
-
- {{ navigation }}
- {% if puzzle %}
- {{ puzzle.title }}
- Date: {{ puzzle.date }}
-
- Moves: {{ move_count }}
- Rotations: {{ rotation_count }}
-
-
-
-
- Download Image
-
-
-
-
-
×
-
Congratulations!
-
You solved the puzzle!
- {% if user_rank is not none %}
-
Your Rank: {{ user_rank }}
- {% else %}
-
Your rank is not available.
- {% endif %}
-
Download Image
-
-
-
-
- {% else %}
- No puzzle available!
- {% endif %}
-
-
-"""
-
-LEADERBOARD_INDEX_TEMPLATE = r"""
-
-
-All Leaderboards
-
- {{ navigation }}
- All Leaderboards
- Click a puzzle date to see its full scoreboard.
-
-
- Puzzle Date
- Size
- Title
- Actions
-
- {% for p in puzzles %}
-
- {{ p.date }}
- {{ p.size }}x{{ p.size }}
- {{ p.title }}
- View Leaderboard
-
- {% endfor %}
-
-
-
-"""
-
-DAILY_LEADERBOARD_TEMPLATE = r"""
-
-
-Daily Leaderboard
-
- {{ navigation }}
- Leaderboard for {{ puzzle.title }} ({{ puzzle.date }})
-
- Sort by:
- Top Speed |
- Top Moves
-
-
-
- Rank
- Username/Guest
- Moves
- Rotations
- Total Actions
- Time (sec)
-
- {% for row in attempts %}
-
- {{ 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) }}
-
- {% endfor %}
-
-
-
-"""
-
-KING_OF_THE_MOUNTAIN_TEMPLATE = r"""
-
-
-
- King of the Mountain
-
-
-
- {{ navigation }}
- King of the Mountain - Global Leaderboard
-
-
- Rank
- Username
- Total Puzzles Solved
- Total Time (sec)
- Total Moves
- Total Rotations
- Total Actions
-
- {% for player in global_leaderboard %}
-
- {{ loop.index }}
- {{ player.username }}
- {{ player.total_puzzles }}
- {{ "%.2f"|format(player.total_time) }}
- {{ player.total_moves }}
- {{ player.total_rotations }}
- {{ player.total_moves + player.total_rotations }}
-
- {% endfor %}
-
-
-
-"""
+ 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
+
+
+ Date
+ Size
+ Title
+ Actions
+
+ {% for p in puzzles %}
+
+ {{ p.date }}
+ {{ p.size }}x{{ p.size }}
+ {{ p.title }}
+
+ Edit
+ {% if p.is_visible %}
+ Hide
+ {% else %}
+ Show
+ {% endif %}
+
+
+ {% endfor %}
+
+{% 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.
+
+
+ Rank
+ Username/Guest
+ Moves
+ Rotations
+ Total Actions
+ Time (sec)
+
+ {% for row in attempts %}
+
+ {{ 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) }}
+
+ {% endfor %}
+
+{% 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 }}
+
+
+
+
+Download Image
+
+
+
+
+
×
+
Congratulations!
+
You solved the puzzle!
+ {% if user_rank is not none %}
+
Your Rank: {{ user_rank }}
+ {% else %}
+
Your rank is not available.
+ {% endif %}
+
Download Image
+
+
+
+
+
+
+
+
+{% 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
+
+
+ Rank
+ Username
+ Total Puzzles Solved
+ Total Time (sec)
+ Total Moves
+ Total Rotations
+ Total Actions
+
+ {% for player in global_leaderboard %}
+
+ {{ loop.index }}
+ {{ player.username }}
+ {{ player.total_puzzles }}
+ {{ "%.2f"|format(player.total_time) }}
+ {{ player.total_moves }}
+ {{ player.total_rotations }}
+ {{ player.total_moves + player.total_rotations }}
+
+ {% endfor %}
+
+{% 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.
+
+
+ Puzzle Date
+ Size
+ Title
+ Actions
+
+ {% for p in puzzles %}
+
+ {{ p.date }}
+ {{ p.size }}x{{ p.size }}
+ {{ p.title }}
+ View Leaderboard
+
+ {% endfor %}
+
+{% 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
+
+
+
+
+ Username
+ Email
+ Actions
+
+
+
+ {% for admin in admins %}
+
+ {{ admin.username }}
+ {{ admin.email }}
+
+ {% if admin.id != request.user.id %}
+
+ {% else %}
+ Current User
+ {% endif %}
+
+
+ {% endfor %}
+
+
+
+{% 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 %}
+
+{% else %}
+ Gravatar is disabled.
+{% endif %}
+
+
+Stats
+Total Puzzles Solved: {{ total_puzzles_solved }}
+Current Streak: {{ current_streak }} days
+Max Streak: {{ max_streak }} days
+Your Solves
+
+
+ Date
+ Size
+ Moves
+ Rotations
+ Time (sec)
+
+ {% for attempt in solved_attempts %}
+
+ {{ 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()) }}
+
+ {% endfor %}
+
+{% 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
+
+ {% for size in puzzle_sizes %}
+ {% if puzzles[size] %}
+ {{ size }}x{{ size }} Puzzle
+ {% else %}
+ {{ size }}x{{ size }} Puzzle - Not Available Today
+ {% endif %}
+ {% endfor %}
+
+{% 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
+
+{% 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 %}