puzzle.unturf.com/app.py
Russell Ballestrini bea0de1ecc modified: app.py
modified:   templates/base.html.j2
	modified:   templates/daily_puzzle.html.j2
2024-12-27 13:44:23 -05:00

1172 lines
37 KiB
Python

# app.py
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, relationship
from waitress import serve
# Import renderers for templates
from pyramid.renderers import render_to_response
################################################################################
# Database Setup
################################################################################
Base = declarative_base()
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True)
email = Column(String, unique=True, nullable=False) # used for login
username = Column(String, unique=True, nullable=True) # 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
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)
# 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):
__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")
DB_URL = "sqlite:///puzzle_game.db"
engine = create_engine(DB_URL, echo=False)
Session = sessionmaker(bind=engine)
Base.metadata.create_all(engine)
################################################################################
# Session Factory
################################################################################
session_factory = SignedCookieSessionFactory(secret="my_insecure_secret")
################################################################################
# Helper Functions
################################################################################
def slugify(text):
text = text.lower()
text = re.sub(r"\s+", "-", text)
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:
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}")
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)
return wrapper
################################################################################
# Helper / Auth Functions
################################################################################
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
return s.query(User).filter_by(id=user_id, is_verified=True).first()
def ensure_guest_name_in_session(request):
"""If no guest_name is in session, generate one like 'Guest-ABCD'."""
if "guest_name" not in request.session:
suffix = "".join(random.choices(string.ascii_uppercase + string.digits, k=4))
request.session["guest_name"] = f"Guest-{suffix}"
################################################################################
# Routes
################################################################################
@view_config(route_name="home", renderer="home.html.j2")
def home_view(request):
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
return {
"request": request,
"guest_name": guest_name,
}
@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:
# 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()
# 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
session.commit()
# 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", 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)
s = request.dbsession
# Find any user with code_expires > now, is_verified=False, matching code
potential_users = (
s.query(User)
.filter(
# User.is_verified == False,
User.code_expires > datetime.datetime.now(),
User.code_hash != None,
)
.all()
)
user_matched = None
for u in potential_users:
if bcrypt.checkpw(code_entered.encode("utf-8"), u.code_hash.encode("utf-8")):
user_matched = u
break
if not user_matched:
return Response("Code not found or expired.", status=400)
user_matched.is_verified = True
user_matched.code_hash = None
user_matched.code_expires = None
s.commit()
request.session["user_id"] = user_matched.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)
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
s.commit()
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:
# 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", 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"}
if not any(
image_file.filename.lower().endswith(f".{ext}")
for ext in allowed_extensions
):
return Response(
f"Unsupported file type for {size}x{size} puzzle. Allowed types: png, jpg, jpeg, gif.",
status=400,
)
raw_bytes = image_file.file.read()
max_size = 5 * 1024 * 1024 # 5 MB
if len(raw_bytes) > max_size:
return Response(
f"File size exceeds the 5MB limit for {size}x{size} puzzle.", status=400
)
encoded_str = base64.b64encode(raw_bytes).decode("utf-8")
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,
title=title,
)
s.add(puzzle)
puzzles_uploaded += 1
s.commit()
if puzzles_uploaded == 0:
return Response("No puzzles were uploaded.", status=400)
return HTTPFound(location=request.route_url("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}
# Check if user has an existing attempt
user = get_current_user(request)
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
user_id = user.id if user else None
display_name = user.username if (user and user.username) else guest_name
# 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)
.order_by(Attempt.attempt_number.desc())
.first()
)
attempts_count = (
s.query(Attempt).filter_by(user_id=user_id, puzzle_id=puzzle.id).count()
)
if not attempt:
# Create a new attempt
attempt_number = attempts_count + 1
# If the user already solved this puzzle, mark new attempts as not counted
is_counted = False if existing_solve else True
# 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,
puzzle_id=puzzle.id,
attempt_date=today,
attempt_number=attempt_number,
move_count=0,
rotation_count=0,
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"
# Get user's rank if they have solved it
user_rank = None
if existing_solve:
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
.order_by(
(
func.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
).asc(),
(Attempt.move_count + Attempt.rotation_count).asc(),
)
.all()
)
for idx, att in enumerate(attempts):
if att.user_id == user_id:
user_rank = idx + 1
break
return {
"request": request,
"puzzle": puzzle,
"attempt_state": json.loads(attempt.state_json),
"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,
"user_rank": user_rank,
}
@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()
)
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:
return Response(
json.dumps({"status": "error", "message": "Invalid JSON data."}),
content_type="application/json; charset=UTF-8",
)
action = action_data.get("action")
data = action_data.get("data")
# Retrieve user, puzzle, and attempt
user = get_current_user(request)
ensure_guest_name_in_session(request)
guest_name = request.session["guest_name"]
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",
)
user_id = user.id if user else None
display_name = user.username if (user and user.username) else guest_name
attempt = (
s.query(Attempt)
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
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)
solution_state = json.loads(puzzle.solution_state_json)
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.is_solved = True
attempt.time_completed = datetime.datetime.utcnow()
s.commit()
# Get user's rank
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
.order_by(
(
func.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
).asc(),
(Attempt.move_count + Attempt.rotation_count).asc(),
)
.all()
)
for idx, att in enumerate(attempts):
if att.user_id == user_id and att.id == attempt.id:
user_rank = idx + 1
break
else:
user_rank = "N/A"
return Response(
json.dumps({"status": "win", "rank": user_rank}),
content_type="application/json; charset=UTF-8",
)
else:
s.commit()
return Response(
json.dumps({"status": "ok"}), 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
puzzles = s.query(Puzzle).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)
if sort_method == "moves":
# Sort by least total actions (moves + rotations)
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
.order_by(
(Attempt.move_count + Attempt.rotation_count).asc(),
(
func.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
).asc(),
)
.all()
)
else:
# Default sort by fastest time
attempts = (
s.query(Attempt)
.filter_by(puzzle_id=puzzle.id, is_solved=True, is_counted=True)
.order_by(
(
func.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
).asc(),
(Attempt.move_count + Attempt.rotation_count).asc(),
)
.all()
)
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.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
)
* 86400
)
)
.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 or user.email,
"total_puzzles": total_puzzles,
"total_time": total_time,
"total_moves": total_moves,
"total_rotations": total_rotations,
}
)
# Sort leaderboard
leaderboard.sort(key=lambda x: (-x["total_puzzles"], x["total_time"]))
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")
# 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()
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()
if __name__ == "__main__":
app = main()
print("Serving on http://localhost:6543")
serve(app, host="0.0.0.0", port=6543)