puzzle.unturf.com/app.py

1288 lines
No EOL
41 KiB
Python

import os
import base64
import datetime
import json
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
from pyramid.httpexceptions import HTTPFound, HTTPForbidden
from pyramid.session import SignedCookieSessionFactory
from sqlalchemy import (
create_engine,
Column,
Integer,
String,
Float,
Boolean,
Date,
DateTime,
ForeignKey,
desc,
func,
)
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import (
sessionmaker,
scoped_session,
relationship,
)
from waitress import serve
import sqlalchemy
import logging
import zope.sqlalchemy # Importing zope.sqlalchemy for transaction management
# Configure logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
logging.getLogger("sqlalchemy.engine").setLevel(logging.INFO)
logging.getLogger("transaction").setLevel(logging.INFO)
################################################################################
# Database Setup
################################################################################
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=True) # guests don't have an email
username = Column(String, unique=True, nullable=False) # user-chosen handle
code_hash = Column(String, nullable=True) # bcrypt hash of code
code_expires = Column(DateTime, nullable=True) # time limit for code
is_verified = Column(Boolean, default=False)
enable_gravatar = Column(Boolean, default=False) # Gravatar support
is_admin = Column(Boolean, default=False) # Admin flag
def get_mime_type(extension):
extension = extension.lower()
if extension in ["jpg", "jpeg"]:
return "image/jpeg"
elif extension == "png":
return "image/png"
elif extension == "gif":
return "image/gif"
else:
return "application/octet-stream"
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)
image_extension = Column(String, nullable=False)
initial_state_json = Column(String)
solution_state_json = Column(String)
title = Column(String, default="Daily Puzzle")
is_visible = Column(Boolean, default=True)
def __init__(
self,
date,
size,
image_b64,
image_extension,
title="Daily Puzzle",
is_visible=True,
):
self.date = date
self.size = size
self.image_b64 = image_b64
self.image_extension = image_extension
self.title = title
self.is_visible = is_visible
# Generate initial and solution states
self.generate_states()
@property
def filename_slug(self):
return f"{slugify(self.title)}.{self.image_extension}"
@property
def image_mime_type(self):
return get_mime_type(self.image_extension)
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):
__tablename__ = "attempts"
id = Column(Integer, primary_key=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
user_display_name = Column(String, default="anonymous")
puzzle_id = Column(Integer, ForeignKey("puzzles.id"))
attempt_date = Column(Date, default=datetime.date.today())
attempt_number = Column(Integer, default=1)
move_count = Column(Integer, default=0)
rotation_count = Column(Integer, default=0)
time_started = Column(DateTime, default=datetime.datetime.utcnow)
time_completed = Column(DateTime, nullable=True)
is_solved = Column(Boolean, default=False)
state_json = Column(String, nullable=False) # current game state
is_counted = Column(
Boolean, default=True
) # whether attempt counts towards leaderboard
puzzle = relationship("Puzzle", backref="attempts")
user = relationship("User", backref="attempts")
@property
def time_started_ms(self):
"""Returns time_started as milliseconds since epoch."""
if self.time_started:
return int(self.time_started.timestamp() * 1000)
return 0
def mark_as_solved(self, time_completed=None):
"""Mark the attempt as solved and set time_completed."""
self.is_solved = True
if self.time_completed is None:
self.time_completed = time_completed or datetime.datetime.utcnow()
@property
def total_elapsed_ms(self):
"""Calculate total elapsed time in milliseconds if solved."""
if self.time_completed and self.time_started:
delta = self.time_completed - self.time_started
return int(delta.total_seconds() * 1000)
return None
@property
def current_elapsed_ms(self):
"""Calculate current elapsed time in milliseconds."""
if self.time_started:
end_time = self.time_completed or datetime.datetime.utcnow()
delta = end_time - self.time_started
return int(delta.total_seconds() * 1000)
return None
# Configuration parameters
DATA_DIR = "data"
# Ensure the data directory exists
if not os.path.exists(DATA_DIR):
os.makedirs(DATA_DIR)
# Load or generate the session secret
SESSION_SECRET = os.environ.get("PYRAPUZZLE_SECRET")
SECRET_FILE_PATH = os.path.join(DATA_DIR, "secret.txt")
if not SESSION_SECRET:
if os.path.exists(SECRET_FILE_PATH):
with open(SECRET_FILE_PATH, "r") as f:
SESSION_SECRET = f.read().strip()
else:
# Generate a new 64-character random secret
SESSION_SECRET = "".join(
random.choices(string.ascii_letters + string.digits, k=64)
)
with open(SECRET_FILE_PATH, "w") as f:
f.write(SESSION_SECRET)
print("Generated new session secret and saved to data/secret.txt")
# Database URL
DB_URL = os.environ.get("PYRAPUZZLE_DB_URL", f"sqlite:///{DATA_DIR}/puzzle_game.db")
engine = create_engine(
DB_URL,
echo=False,
connect_args={
"check_same_thread": False
}, # Important for SQLite in multi-threaded apps
poolclass=sqlalchemy.pool.StaticPool, # Use StaticPool for SQLite
)
# Use scoped_session to manage sessions in a thread-safe way
SessionFactory = sessionmaker(bind=engine)
DBSession = scoped_session(SessionFactory)
# Register the session with zope.sqlalchemy
zope.sqlalchemy.register(DBSession)
Base.metadata.bind = engine
Base.metadata.create_all(engine)
################################################################################
# Helper Functions
################################################################################
def slugify(text):
text = text.lower()
text = re.sub(r"\s+", "-", text)
text = re.sub(r"[^\w\-]", "", text)
return text
def get_gravatar_url(email, size=100):
email = email.strip().lower()
hash_code = hashlib.md5(email.encode("utf-8")).hexdigest()
return f"https://www.gravatar.com/avatar/{hash_code}?s={size}&d=identicon"
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("==========================")
msg = MIMEText(body)
msg["Subject"] = subject
msg["From"] = "noreply@example.com"
msg["To"] = to_email
try:
smtp_host = os.environ.get("PYRAPUZZLE_SMTP_HOST", "localhost")
smtp_port = int(os.environ.get("PYRAPUZZLE_SMTP_PORT", 25))
s = smtplib.SMTP(smtp_host, smtp_port)
s.sendmail("noreply@example.com", [to_email], msg.as_string())
s.quit()
except Exception as e:
print(f"Error sending email: {e}")
def admin_required(view_func):
def wrapper(request):
user = request.user
if not user or not user.is_admin:
raise HTTPForbidden("You must be an admin to access this page.")
return view_func(request)
return wrapper
def get_current_user(request):
"""Return the current user (authenticated or guest) from session."""
user_id = request.session.get("user_id")
s = request.dbsession
if user_id:
# Try to get the user from the database
user = s.query(User).filter_by(id=user_id).first()
if user:
return user
else:
# User ID in session does not exist in the database; remove it
del request.session["user_id"]
# No valid user in session; create a guest user
# Generate a unique guest username
while True:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=8))
guest_username = f"Guest-{suffix}"
existing_user = s.query(User).filter_by(username=guest_username).first()
if not existing_user:
break # Unique username found
# Create a new guest user
guest_user = User(
email=guest_username,
username=guest_username,
is_verified=False,
)
s.add(guest_user)
s.flush() # Flush to assign an ID
# Store the user ID in the session
request.session["user_id"] = guest_user.id
return guest_user
def get_ordered_attempts(s, puzzle_id, sort_method="time"):
"""Retrieve and order attempts for a given puzzle based on the sort method."""
time_diff = func.strftime("%s", Attempt.time_completed) - func.strftime(
"%s", Attempt.time_started
)
total_actions = Attempt.move_count + Attempt.rotation_count
if sort_method == "moves":
# Sort by least total actions (moves + rotations), then time
attempts = (
s.query(Attempt)
.filter(
Attempt.puzzle_id == puzzle_id,
Attempt.is_solved == True,
Attempt.is_counted == True,
Attempt.attempt_number == 1,
)
.order_by(
total_actions.asc(),
time_diff.asc(),
)
.all()
)
else:
# Default sort by fastest time, then total actions
attempts = (
s.query(Attempt)
.filter(
Attempt.puzzle_id == puzzle_id,
Attempt.is_solved == True,
Attempt.is_counted == True,
Attempt.attempt_number == 1,
)
.order_by(
time_diff.asc(),
total_actions.asc(),
)
.all()
)
return attempts
################################################################################
# Routes
################################################################################
@view_config(route_name="home", renderer="home.html.j2")
def home_view(request):
return {
"request": request,
}
@view_config(route_name="login", request_method="GET", renderer="login.html.j2")
def login_get_view(request):
return {"request": request}
@view_config(route_name="login", request_method="POST")
def login_post_view(request):
email = request.POST.get("email", "").strip().lower()
if not email:
return Response("Email required.", status=400)
session = request.dbsession
user = session.query(User).filter_by(email=email).first()
if not user:
# Generate a unique starting username.
while True:
suffix = "".join(
random.choices(string.ascii_uppercase + string.digits, k=8)
)
new_tmp_username = f"User-{suffix}"
existing_user = (
session.query(User).filter_by(username=new_tmp_username).first()
)
if not existing_user:
break
# Create new user with temp username for now
user = User(email=email, username=new_tmp_username)
# 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.flush() # Flush to assign an ID
# Generate 6-digit code
code_str = f"{random.randint(0,999999):06d}"
code_hash = bcrypt.hashpw(code_str.encode("utf-8"), bcrypt.gensalt()).decode(
"utf-8"
)
user.code_hash = code_hash
user.code_expires = datetime.datetime.now() + datetime.timedelta(minutes=15)
user.is_verified = False
# Send code via email
email_body = f"Your verification code is: {code_str}"
send_email(user.email, "Your Verification Code", email_body)
# Store the email in the session for verification
request.session["login_email"] = email
return HTTPFound(location=request.route_url("verify"))
@view_config(route_name="verify", request_method="GET", renderer="verify.html.j2")
def verify_get_view(request):
return {"request": request}
@view_config(route_name="verify", request_method="POST")
def verify_post_view(request):
code_entered = request.POST.get("code", "").strip()
if not code_entered or len(code_entered) != 6:
return Response("Invalid code.", status=400)
email = request.session.get("login_email")
if not email:
return Response(
"No email found in session. Please start the login process again.",
status=400,
)
s = request.dbsession
user = (
s.query(User)
.filter(
User.email == email,
User.code_expires > datetime.datetime.now(),
User.code_hash != None,
)
.first()
)
if not user:
return Response("Code not found or expired.", status=400)
if not bcrypt.checkpw(code_entered.encode("utf-8"), user.code_hash.encode("utf-8")):
return Response("Invalid code.", status=400)
user.is_verified = True
user.code_hash = None
user.code_expires = None
# Remove the email from the session
del request.session["login_email"]
request.session["user_id"] = user.id
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"))
################################################################################
# Profile and Gravatar
################################################################################
@view_config(route_name="profile", request_method="GET", renderer="profile.html.j2")
def profile_get_view(request):
user = request.user
if not user:
return Response("You must be logged in to access your profile.", status=403)
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
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):
if not request.user:
return Response("You must be logged in to update your profile.", status=403)
if not request.user.is_verified:
return Response(
"This account is in guest mode, log in to update your profile.", status=403
)
s = request.dbsession
enable_gravatar = request.POST.get("enable_gravatar") == "on"
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 != request.user.id:
return Response("Username is already in use.", status=400)
request.user.username = new_username
return HTTPFound(location=request.route_url("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")
user = s.query(User).filter_by(id=user_id).first()
if not user:
return Response("User not found.", status=404)
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
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", renderer="puzzle_menu.html.j2")
def puzzle_menu_view(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, is_visible=True).first()
)
puzzles[size] = puzzle
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:
# Generate a unique starting username.
while True:
suffix = "".join(
random.choices(string.ascii_uppercase + string.digits, k=8)
)
new_tmp_username = f"User-{suffix}"
existing_user = s.query(User).filter_by(username=new_tmp_username).first()
if not existing_user:
break
# Create user record
user = User(email=email, username=new_tmp_username, is_admin=True)
s.add(user)
# 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
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
admins = s.query(User).filter_by(is_admin=True).all()
return {
"request": request,
"admins": admins,
"user": request.user,
}
@view_config(route_name="remove_admin", request_method="POST")
@admin_required
def remove_admin_view(request):
s = request.dbsession
current_user = request.user
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
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
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
return HTTPFound(location=request.route_url("admin_dashboard"))
################################################################################
# Puzzle Upload
################################################################################
@view_config(route_name="upload", request_method="GET", renderer="upload.html.j2")
@admin_required
def upload_get_view(request):
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]
puzzles_uploaded = 0
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)
s = request.dbsession
for size in puzzle_sizes:
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():
continue # Skip if no image uploaded for this size
# Validate file type
allowed_extensions = {"png", "jpg", "jpeg", "gif"}
filename = image_file.filename.lower()
file_extension = os.path.splitext(filename)[1][1:] # Get extension without dot
if file_extension not 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")
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,
)
# Create the puzzle instance; states are generated in the constructor
puzzle = Puzzle(
date=puzzle_date,
size=size,
image_b64=encoded_str,
image_extension=file_extension, # Pass the file extension here
title=title,
)
s.add(puzzle)
puzzles_uploaded += 1
if puzzles_uploaded == 0:
return Response("No puzzles were uploaded.", status=400)
return HTTPFound(location=request.route_url("admin_dashboard"))
################################################################################
# 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, is_visible=True).first()
if not puzzle:
return {"request": request, "puzzle": None}
# Get the current user
user = request.user
# Fetch the first attempt (attempt_number == 1) for the user and puzzle
attempt = (
s.query(Attempt)
.filter_by(user_id=user.id, puzzle_id=puzzle.id, attempt_number=1)
.first()
)
if not attempt:
# Create the first attempt
attempt = Attempt(
user_id=user.id,
user_display_name=user.username,
puzzle_id=puzzle.id,
attempt_date=today,
attempt_number=1,
move_count=0,
rotation_count=0,
is_solved=False,
state_json=puzzle.initial_state_json,
time_started=datetime.datetime.utcnow(),
is_counted=True,
)
s.add(attempt)
# Get user's rank if they have solved it
user_rank = None
if attempt.is_solved:
sort_method = "time" # You can change this to 'moves' if desired
attempts = get_ordered_attempts(s, puzzle.id, sort_method)
for idx, att in enumerate(attempts):
if att.user_id == user.id:
user_rank = idx + 1
break
# Calculate elapsed time
if attempt.is_solved:
# Use total elapsed time when solved
total_elapsed_ms = attempt.total_elapsed_ms or 0
else:
# Calculate current elapsed time
total_elapsed_ms = attempt.current_elapsed_ms or 0
return {
"request": request,
"puzzle": puzzle,
"attempt": attempt,
"attempt_state": json.loads(attempt.state_json),
"attempt_is_solved": attempt.is_solved,
"user_rank": user_rank,
"image_mime_type": puzzle.image_mime_type,
"total_elapsed_ms": total_elapsed_ms,
}
@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
solved_attempt = (
s.query(Attempt)
.filter_by(user_id=request.user.id, puzzle_id=puzzle.id, is_solved=True)
.first()
)
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")
def record_action_view(request):
s = request.dbsession
# Get request data
try:
action_data = request.json_body
except Exception as e:
logger.error(f"Failed to parse JSON: {e}")
return Response(
json.dumps({"status": "error", "message": "Invalid JSON data."}),
content_type="application/json; charset=UTF-8",
)
action = action_data.get("action")
data = action_data.get("data")
# Retrieve user, puzzle, and attempt
user = request.user
size = int(request.matchdict.get("size", 4))
today = datetime.date.today()
puzzle = s.query(Puzzle).filter_by(date=today, size=size, is_visible=True).first()
if not puzzle:
return Response(
json.dumps({"status": "error", "message": "No puzzle found."}),
content_type="application/json; charset=UTF-8",
)
attempt = (
s.query(Attempt)
.filter_by(user_id=user.id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
if not attempt:
return Response(
json.dumps({"status": "error", "message": "No active attempt found."}),
content_type="application/json; charset=UTF-8",
)
# Update attempt stats and state
state = json.loads(attempt.state_json)
try:
if action == "rotate":
attempt.rotation_count += 1
piece_uuid = data.get("pieceUUID")
rotation = int(data.get("rotation"))
# Update rotation in state
for piece in state:
if piece["uuid"] == piece_uuid:
piece["rotation"] = rotation
break
else:
return Response(
json.dumps({"status": "error", "message": "Invalid piece UUID."}),
content_type="application/json; charset=UTF-8",
)
elif action == "swap":
attempt.move_count += 1
piece_uuid_a = data.get("pieceUUIDA")
piece_uuid_b = data.get("pieceUUIDB")
# Find pieces by UUID
idxA = idxB = None
for idx, piece in enumerate(state):
if piece["uuid"] == piece_uuid_a:
idxA = idx
if piece["uuid"] == piece_uuid_b:
idxB = idx
if idxA is not None and idxB is not None:
# Swap currentIndex values
state[idxA]["currentIndex"], state[idxB]["currentIndex"] = (
state[idxB]["currentIndex"],
state[idxA]["currentIndex"],
)
else:
return Response(
json.dumps({"status": "error", "message": "Invalid piece UUIDs."}),
content_type="application/json; charset=UTF-8",
)
else:
return Response(
json.dumps({"status": "error", "message": "Invalid action."}),
content_type="application/json; charset=UTF-8",
)
# Save updated state
attempt.state_json = json.dumps(state)
# Check for win condition
if all(
piece["currentIndex"] == piece["correctIndex"]
and piece["rotation"] % 360 == 0
for piece in state
):
attempt.mark_as_solved()
# Get user's rank
attempts = get_ordered_attempts(s, puzzle.id, "time")
for idx, att in enumerate(attempts):
if att.user_id == user.id and att.id == attempt.id:
user_rank = idx + 1
break
else:
user_rank = "N/A"
return Response(
json.dumps({"status": "win", "rank": user_rank}),
content_type="application/json; charset=UTF-8",
)
else:
return Response(
json.dumps({"status": "ok"}),
content_type="application/json; charset=UTF-8",
)
except Exception as e:
logger.error(f"Error processing action '{action}': {e}")
return Response(
json.dumps({"status": "error", "message": "Server error."}),
content_type="application/json; charset=UTF-8",
)
################################################################################
# Leaderboards
################################################################################
@view_config(route_name="leaderboard_index", renderer="leaderboard_index.html.j2")
def leaderboard_index_view(request):
s = request.dbsession
today = datetime.date.today()
puzzles = (
s.query(Puzzle)
.filter(Puzzle.date <= today)
.order_by(Puzzle.date.desc(), Puzzle.size.asc())
.all()
)
return {"request": request, "puzzles": puzzles}
@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")
sort_method = request.params.get("sort", "time")
puzzle = s.query(Puzzle).filter_by(id=puzzle_id).first()
if not puzzle:
return Response("No puzzle found for this ID.", status=404)
attempts = get_ordered_attempts(s, puzzle.id, sort_method)
return {
"request": request,
"puzzle": puzzle,
"attempts": attempts,
}
@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
# 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.strftime("%s", Attempt.time_completed)
- func.strftime("%s", Attempt.time_started)
)
)
)
.filter(
Attempt.user_id == user.id,
Attempt.is_solved == True,
Attempt.is_counted == True,
)
.scalar()
) # Time in 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,
"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"]))
return {
"request": request,
"global_leaderboard": leaderboard,
}
################################################################################
# Main
################################################################################
def main(global_config=None, **settings):
from pyramid.decorator import reify
# Set up the session factory with the specified settings
session_factory = SignedCookieSessionFactory(
secret=SESSION_SECRET,
hashalg="sha512",
timeout=31104000, # Approx. one year in seconds
max_age=31104000, # Set Max-Age attribute on cookie
reissue_time=15552000, # Approx. six months
samesite=None, # Allows cross-site requests if needed
httponly=True, # Helps mitigate XSS attacks
secure=False, # Set to True if using HTTPS
)
if not settings:
settings = {}
settings["sqlalchemy.url"] = DB_URL
config = Configurator(settings=settings, session_factory=session_factory)
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")
# Include pyramid_tm for transaction management
config.include("pyramid_tm")
# Add user to all requests.
config.add_request_method(callable=get_current_user, name="user", reify=True)
def dbsession(request):
return DBSession
config.add_request_method(dbsession, "dbsession", reify=True)
# Routes
config.add_route("home", "/")
# Auth
config.add_route("login", "/auth/login")
config.add_route("verify", "/auth/verify")
config.add_route("logout", "/auth/logout")
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", "/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")
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()
uwsgi_app = main()
if __name__ == "__main__":
host = os.environ.get("PYRAPUZZLE_HOST", "0.0.0.0")
port = int(os.environ.get("PYRAPUZZLE_PORT", 6543))
print(f"Serving on http://{host}:{port}")
serve(uwsgi_app, host=host, port=port)