init
new file: .gitignore new file: app.py new file: requirements.txt
This commit is contained in:
commit
2a73cb77ed
3 changed files with 835 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.db
|
||||
825
app.py
Normal file
825
app.py
Normal file
|
|
@ -0,0 +1,825 @@
|
|||
###############################################################################
|
||||
# app.py - Single-file Pyramid puzzle app with:
|
||||
# - Anonymous random display names stored in session.
|
||||
# - Email-only login (code-based).
|
||||
# - Username changes (not allowed to conflict).
|
||||
###############################################################################
|
||||
import base64
|
||||
import datetime
|
||||
import json
|
||||
import random
|
||||
import time
|
||||
import secrets
|
||||
import string
|
||||
import bcrypt
|
||||
|
||||
from pyramid.config import Configurator
|
||||
from pyramid.view import view_config
|
||||
from pyramid.response import Response
|
||||
from pyramid.httpexceptions import HTTPFound
|
||||
from pyramid.session import SignedCookieSessionFactory
|
||||
|
||||
from sqlalchemy import (
|
||||
create_engine,
|
||||
Column,
|
||||
Integer,
|
||||
String,
|
||||
Float,
|
||||
Boolean,
|
||||
Date,
|
||||
DateTime,
|
||||
ForeignKey,
|
||||
func,
|
||||
)
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import sessionmaker, relationship
|
||||
|
||||
from waitress import serve
|
||||
|
||||
from jinja2 import Template # Import Jinja2 Template for manual rendering
|
||||
|
||||
###############################################################################
|
||||
# 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)
|
||||
|
||||
|
||||
class Puzzle(Base):
|
||||
__tablename__ = "puzzles"
|
||||
id = Column(Integer, primary_key=True)
|
||||
date = Column(Date, unique=True, nullable=False)
|
||||
image_b64 = Column(String, nullable=False)
|
||||
shuffle_json = Column(String, default="")
|
||||
title = Column(String, default="Daily Puzzle")
|
||||
|
||||
|
||||
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)
|
||||
swap_count = Column(Integer, default=0)
|
||||
placement_count = Column(Integer, default=0)
|
||||
rotation_count = Column(Integer, default=0)
|
||||
time_to_solve = Column(Float, default=9999.0)
|
||||
is_solved = Column(Boolean, default=False)
|
||||
|
||||
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")
|
||||
|
||||
###############################################################################
|
||||
# Inline Templates
|
||||
###############################################################################
|
||||
HOME_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Daily Puzzle - Home</title></head>
|
||||
<body>
|
||||
<h1>Daily Puzzle - Home</h1>
|
||||
<ul>
|
||||
<li><a href="{{ request.route_url('upload') }}">Upload a new puzzle (admin)</a></li>
|
||||
<li><a href="{{ request.route_url('daily_puzzle') }}">Today's Puzzle</a></li>
|
||||
<li><a href="{{ request.route_url('leaderboard_index') }}">All Leaderboards</a></li>
|
||||
<li><a href="{{ request.route_url('login') }}">Login with Email</a></li>
|
||||
<li><a href="{{ request.route_url('change_username') }}">Change Username</a></li>
|
||||
</ul>
|
||||
|
||||
{% if user %}
|
||||
<p>You are logged in as <strong>{{ user.username }}</strong>.</p>
|
||||
{% else %}
|
||||
<p>You are not logged in.</p>
|
||||
<p>Your guest name: <strong>{{ guest_name }}</strong></p>
|
||||
{% endif %}
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
LOGIN_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Login with Email</title></head>
|
||||
<body>
|
||||
<h1>Login (Email Only)</h1>
|
||||
<p>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.</p>
|
||||
<form method="POST">
|
||||
<label>Email: <input type="email" name="email" required></label>
|
||||
<button type="submit">Get Code</button>
|
||||
</form>
|
||||
<p><a href="{{ request.route_url('home') }}">Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
VERIFY_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Verify Code</title></head>
|
||||
<body>
|
||||
<h1>Enter 6-digit code</h1>
|
||||
<form method="POST">
|
||||
<label>Code: <input type="text" name="code" required></label>
|
||||
<button type="submit">Verify</button>
|
||||
</form>
|
||||
<p><a href="{{ request.route_url('home') }}">Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
CHANGE_USERNAME_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Change Username</title></head>
|
||||
<body>
|
||||
<h1>Change Username</h1>
|
||||
<form method="POST">
|
||||
<label>New Username: <input type="text" name="new_username" required></label>
|
||||
<button type="submit">Change</button>
|
||||
</form>
|
||||
<p><a href="{{ request.route_url('home') }}">Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
UPLOAD_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Upload Puzzle</title></head>
|
||||
<body>
|
||||
<h1>Upload Puzzle Image / Schedule</h1>
|
||||
<form method="POST" enctype="multipart/form-data">
|
||||
<label for="puzzle_date">Puzzle Date (YYYY-MM-DD):</label>
|
||||
<input type="text" name="puzzle_date" required><br><br>
|
||||
|
||||
<label for="title">Puzzle Title:</label>
|
||||
<input type="text" name="title" value="AI Puzzle"><br><br>
|
||||
|
||||
<label for="image_file">Choose Image:</label>
|
||||
<input type="file" name="image_file" accept="image/*" required><br><br>
|
||||
|
||||
<button type="submit">Upload</button>
|
||||
</form>
|
||||
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
DAILY_PUZZLE_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>{{ puzzle.title }} - {{ puzzle.date }}</title>
|
||||
<style>
|
||||
body { font-family: sans-serif; }
|
||||
#boardContainer, #sideBoard {
|
||||
position: relative;
|
||||
width: 400px;
|
||||
height: 400px;
|
||||
border: 2px solid #333;
|
||||
display: inline-block;
|
||||
margin: 0 20px 20px 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
.piece {
|
||||
width: 100px;
|
||||
height: 100px;
|
||||
position: absolute;
|
||||
border: 1px solid #999;
|
||||
box-sizing: border-box;
|
||||
background-image: url("data:image/png;base64,{{ puzzle.image_b64 }}");
|
||||
background-size: 400px 400px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if puzzle %}
|
||||
<h1>Today's Puzzle: {{ puzzle.title }}</h1>
|
||||
<p>Date: {{ puzzle.date }}</p>
|
||||
<p>
|
||||
Up to 2 attempts per day.<br>
|
||||
Drag pieces between board/side.<br>
|
||||
Click a piece to rotate (+90°).<br>
|
||||
We track <em>swaps</em>, <em>placements</em>, <em>rotations</em>, and <em>time</em>.
|
||||
</p>
|
||||
|
||||
<div>Board</div>
|
||||
<div id="boardContainer"></div>
|
||||
<div>Side Board</div>
|
||||
<div id="sideBoard"></div>
|
||||
|
||||
<script>
|
||||
const puzzleData = JSON.parse('{{ puzzle.shuffle_json|replace("'", "\"") }}');
|
||||
const gridSize = 4;
|
||||
const pieceSize = 100;
|
||||
let startTime = null;
|
||||
let isSolved = false;
|
||||
|
||||
let swapCount = 0;
|
||||
let placementCount = 0;
|
||||
let rotationCount = 0;
|
||||
|
||||
const boardContainer = document.getElementById("boardContainer");
|
||||
const sideBoard = document.getElementById("sideBoard");
|
||||
|
||||
puzzleData.forEach(p => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "piece";
|
||||
div.dataset.correctIndex = p.index;
|
||||
div.dataset.rotation = p.rot;
|
||||
div.dataset.inSide = p.inSideBoard;
|
||||
div.dataset.position = p.pos;
|
||||
placePiece(div);
|
||||
|
||||
// Rotate on click
|
||||
div.addEventListener("click", () => {
|
||||
if (!startTime) startTime = Date.now();
|
||||
if (isSolved) return;
|
||||
let curRot = parseInt(div.dataset.rotation, 10) || 0;
|
||||
let newRot = (curRot + 90) % 360;
|
||||
div.dataset.rotation = newRot;
|
||||
div.style.transform = `rotate(${newRot}deg)`;
|
||||
rotationCount++;
|
||||
checkSolved();
|
||||
});
|
||||
|
||||
// Drag
|
||||
div.draggable = true;
|
||||
div.addEventListener("dragstart", dragStart);
|
||||
div.addEventListener("dragover", dragOver);
|
||||
div.addEventListener("drop", drop);
|
||||
div.addEventListener("dragend", dragEnd);
|
||||
|
||||
// Append
|
||||
if (p.inSideBoard) sideBoard.appendChild(div);
|
||||
else boardContainer.appendChild(div);
|
||||
});
|
||||
|
||||
let draggedEl = null;
|
||||
|
||||
function placePiece(div) {
|
||||
const pos = parseInt(div.dataset.position, 10);
|
||||
const inSide = (div.dataset.inSide === "true");
|
||||
const rot = parseInt(div.dataset.rotation, 10) || 0;
|
||||
div.style.transform = `rotate(${rot}deg)`;
|
||||
let cIndex = parseInt(div.dataset.correctIndex, 10);
|
||||
let cRow = Math.floor(cIndex / gridSize);
|
||||
let cCol = cIndex % gridSize;
|
||||
div.style.backgroundPosition = (-cCol * pieceSize) + "px " + (-cRow * pieceSize) + "px";
|
||||
|
||||
let row = Math.floor(pos / gridSize);
|
||||
let col = pos % gridSize;
|
||||
div.style.top = (row * pieceSize) + "px";
|
||||
div.style.left = (col * pieceSize) + "px";
|
||||
}
|
||||
|
||||
function dragStart(e) {
|
||||
if (!startTime) startTime = Date.now();
|
||||
if (isSolved) return;
|
||||
draggedEl = e.target;
|
||||
e.dataTransfer.setData("text/plain", "");
|
||||
}
|
||||
function dragOver(e) {
|
||||
e.preventDefault();
|
||||
}
|
||||
function drop(e) {
|
||||
e.preventDefault();
|
||||
if (isSolved || !draggedEl) return;
|
||||
const dropTarget = e.target;
|
||||
if (dropTarget.classList.contains("piece") && dropTarget !== draggedEl) {
|
||||
// Swap
|
||||
swapPieces(draggedEl, dropTarget);
|
||||
swapCount++;
|
||||
} else if (dropTarget.id === "boardContainer" || dropTarget.id === "sideBoard") {
|
||||
// Move => placement
|
||||
movePiece(draggedEl, dropTarget);
|
||||
placementCount++;
|
||||
}
|
||||
draggedEl = null;
|
||||
checkSolved();
|
||||
}
|
||||
function dragEnd(e) {
|
||||
draggedEl = null;
|
||||
}
|
||||
|
||||
function swapPieces(a, b) {
|
||||
const aPos = a.dataset.position;
|
||||
const aSide = a.dataset.inSide;
|
||||
const bPos = b.dataset.position;
|
||||
const bSide = b.dataset.inSide;
|
||||
a.dataset.position = bPos;
|
||||
a.dataset.inSide = bSide;
|
||||
b.dataset.position = aPos;
|
||||
b.dataset.inSide = aSide;
|
||||
placePiece(a);
|
||||
placePiece(b);
|
||||
}
|
||||
function movePiece(piece, container) {
|
||||
piece.dataset.position = "0";
|
||||
piece.dataset.inSide = (container.id === "sideBoard") ? "true" : "false";
|
||||
container.appendChild(piece);
|
||||
placePiece(piece);
|
||||
}
|
||||
|
||||
function checkSolved() {
|
||||
const boardPieces = boardContainer.querySelectorAll(".piece");
|
||||
if (boardPieces.length < 16) return;
|
||||
for (let piece of boardPieces) {
|
||||
if (piece.dataset.inSide === "true") return;
|
||||
if (piece.dataset.position !== piece.dataset.correctIndex) return;
|
||||
if (piece.dataset.rotation !== "0") return;
|
||||
}
|
||||
isSolved = true;
|
||||
let endTime = Date.now();
|
||||
let totalTime = (endTime - startTime) / 1000.0;
|
||||
submitResult(totalTime);
|
||||
}
|
||||
|
||||
function submitResult(timeSec) {
|
||||
const formData = new FormData();
|
||||
formData.append("time_to_solve", timeSec);
|
||||
formData.append("swaps", swapCount);
|
||||
formData.append("placements", placementCount);
|
||||
formData.append("rotations", rotationCount);
|
||||
|
||||
fetch("{{ request.route_url('submit_attempt') }}", {
|
||||
method: "POST",
|
||||
body: formData
|
||||
})
|
||||
.then(res => {
|
||||
if (res.redirected) {
|
||||
window.location.href = res.url;
|
||||
} else {
|
||||
return res.text();
|
||||
}
|
||||
})
|
||||
.then(data => console.log(data))
|
||||
.catch(err => console.error(err));
|
||||
}
|
||||
</script>
|
||||
{% else %}
|
||||
<h1>No puzzle for today!</h1>
|
||||
{% endif %}
|
||||
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
LEADERBOARD_INDEX_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>All Leaderboards</title></head>
|
||||
<body>
|
||||
<h1>All Leaderboards</h1>
|
||||
<p>Click a puzzle date to see its full scoreboard.</p>
|
||||
<table border="1" cellpadding="5" cellspacing="0">
|
||||
<tr>
|
||||
<th>Puzzle Date</th>
|
||||
<th>Title</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
{% for p in puzzles %}
|
||||
<tr>
|
||||
<td>{{ p.date }}</td>
|
||||
<td>{{ p.title }}</td>
|
||||
<td><a href="{{ request.route_url('daily_leaderboard', puzzle_id=p.id) }}">View Leaderboard</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
DAILY_LEADERBOARD_TEMPLATE = r"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head><title>Daily Leaderboard</title></head>
|
||||
<body>
|
||||
<h1>Leaderboard for {{ puzzle.title }} ({{ puzzle.date }})</h1>
|
||||
<table border="1" cellpadding="5" cellspacing="0">
|
||||
<tr>
|
||||
<th>Username/Guest</th>
|
||||
<th>Attempt #</th>
|
||||
<th>Swaps</th>
|
||||
<th>Placements</th>
|
||||
<th>Rotations</th>
|
||||
<th>Time (sec)</th>
|
||||
</tr>
|
||||
{% for row in attempts %}
|
||||
<tr>
|
||||
<td>{{ row.user_display_name }}</td>
|
||||
<td>{{ row.attempt_number }}</td>
|
||||
<td>{{ row.swap_count }}</td>
|
||||
<td>{{ row.placement_count }}</td>
|
||||
<td>{{ row.rotation_count }}</td>
|
||||
<td>{{ "%.2f"|format(row.time_to_solve) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<p><a href="{{ request.route_url('leaderboard_index') }}">All Leaderboards</a></p>
|
||||
<p><a href="{{ request.route_url('home') }}">Back to Home</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Helper / Auth Functions
|
||||
###############################################################################
|
||||
def get_current_user(request):
|
||||
"""Return the currently logged-in user (if any) from session, and ensure is_verified=True."""
|
||||
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") # Removed renderer='string'
|
||||
def home_view(request):
|
||||
user = get_current_user(request)
|
||||
ensure_guest_name_in_session(request)
|
||||
guest_name = request.session["guest_name"]
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(HOME_TEMPLATE)
|
||||
rendered = template.render(request=request, user=user, guest_name=guest_name)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@view_config(route_name="login", request_method="GET") # Removed renderer='string'
|
||||
def login_get_view(request):
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(LOGIN_TEMPLATE)
|
||||
rendered = template.render(request=request)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@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)
|
||||
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()
|
||||
|
||||
# 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).<br>"
|
||||
f"<a href='{request.route_url('verify')}'>Enter code</a>"
|
||||
)
|
||||
|
||||
|
||||
@view_config(route_name="verify", request_method="GET") # Removed renderer='string'
|
||||
def verify_get_view(request):
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(VERIFY_TEMPLATE)
|
||||
rendered = template.render(request=request)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@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 Response(
|
||||
f"Verified! Logged in. <br><a href='{request.route_url('home')}'>Home</a>"
|
||||
)
|
||||
|
||||
|
||||
@view_config(
|
||||
route_name="change_username", request_method="GET"
|
||||
) # Removed renderer='string'
|
||||
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)
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(CHANGE_USERNAME_TEMPLATE)
|
||||
rendered = template.render(request=request)
|
||||
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 Response(
|
||||
f"Username changed to {new_username}. "
|
||||
f"<br><br><a href='{request.route_url('home')}'>Home</a>"
|
||||
)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Puzzle and Leaderboard
|
||||
###############################################################################
|
||||
@view_config(route_name="upload", request_method="GET") # Removed renderer='string'
|
||||
def upload_get_view(request):
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(UPLOAD_TEMPLATE)
|
||||
rendered = template.render(request=request)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@view_config(route_name='upload', request_method='POST')
|
||||
def upload_post_view(request):
|
||||
puzzle_date_str = request.POST.get('puzzle_date')
|
||||
title = request.POST.get('title') or "Daily Puzzle"
|
||||
image_file = request.POST.get('image_file')
|
||||
|
||||
# Updated condition to safely check image_file and its filename
|
||||
if not puzzle_date_str or image_file is None or not getattr(image_file, 'filename', '').strip():
|
||||
return Response("Missing date or file", status=400)
|
||||
|
||||
try:
|
||||
puzzle_date = datetime.datetime.strptime(puzzle_date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
return Response("Invalid date format. Use YYYY-MM-DD.", status=400)
|
||||
|
||||
raw_bytes = image_file.file.read()
|
||||
encoded_str = base64.b64encode(raw_bytes).decode('utf-8')
|
||||
|
||||
s = request.dbsession
|
||||
existing = s.query(Puzzle).filter_by(date=puzzle_date).first()
|
||||
if existing:
|
||||
return Response(f"Puzzle already exists for {puzzle_date}.", status=400)
|
||||
|
||||
puzzle = Puzzle(date=puzzle_date, image_b64=encoded_str, title=title, shuffle_json='')
|
||||
s.add(puzzle)
|
||||
s.commit()
|
||||
return HTTPFound(location=request.route_url('home'))
|
||||
|
||||
|
||||
@view_config(route_name="daily_puzzle") # Removed renderer='string'
|
||||
def daily_puzzle_view(request):
|
||||
s = request.dbsession
|
||||
today = datetime.date.today()
|
||||
puzzle = s.query(Puzzle).filter_by(date=today).first()
|
||||
|
||||
if puzzle and not puzzle.shuffle_json:
|
||||
# generate puzzle data
|
||||
arr = list(range(16))
|
||||
random.shuffle(arr)
|
||||
rotations = [0, 90, 180, 270]
|
||||
data = []
|
||||
for i, correct_idx in enumerate(arr):
|
||||
piece_info = {
|
||||
"index": correct_idx,
|
||||
"pos": i,
|
||||
"rot": random.choice(rotations),
|
||||
"inSideBoard": (i >= 8),
|
||||
}
|
||||
data.append(piece_info)
|
||||
puzzle.shuffle_json = json.dumps(data)
|
||||
s.commit()
|
||||
|
||||
if puzzle:
|
||||
# If puzzle exists, render the template
|
||||
template = Template(DAILY_PUZZLE_TEMPLATE)
|
||||
rendered = template.render(request=request, puzzle=puzzle)
|
||||
else:
|
||||
# If no puzzle exists, render a simple message
|
||||
rendered = "<h1>No puzzle for today!</h1><p><a href='/'>Back to Home</a></p>"
|
||||
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@view_config(route_name="submit_attempt", request_method="POST")
|
||||
def submit_attempt_view(request):
|
||||
s = request.dbsession
|
||||
user = get_current_user(request)
|
||||
ensure_guest_name_in_session(request)
|
||||
guest_name = request.session["guest_name"]
|
||||
|
||||
today = datetime.date.today()
|
||||
puzzle = s.query(Puzzle).filter_by(date=today).first()
|
||||
if not puzzle:
|
||||
return HTTPFound(location=request.route_url("daily_puzzle"))
|
||||
|
||||
# Check how many attempts so far
|
||||
user_id = user.id if user else None
|
||||
display_name = user.username if (user and user.username) else guest_name
|
||||
|
||||
attempts_count = (
|
||||
s.query(Attempt)
|
||||
.filter_by(user_id=user_id, puzzle_id=puzzle.id, attempt_date=today)
|
||||
.count()
|
||||
)
|
||||
|
||||
if attempts_count >= 2:
|
||||
return Response("You have no more attempts left for today!", status=403)
|
||||
|
||||
attempt_number = attempts_count + 1
|
||||
|
||||
def safe_int(x):
|
||||
try:
|
||||
return int(x)
|
||||
except:
|
||||
return 0
|
||||
|
||||
def safe_float(x):
|
||||
try:
|
||||
return float(x)
|
||||
except:
|
||||
return 9999.0
|
||||
|
||||
time_str = request.POST.get("time_to_solve")
|
||||
swaps_str = request.POST.get("swaps")
|
||||
placements_str = request.POST.get("placements")
|
||||
rots_str = request.POST.get("rotations")
|
||||
|
||||
new_attempt = Attempt(
|
||||
user_id=user_id,
|
||||
user_display_name=display_name,
|
||||
puzzle_id=puzzle.id,
|
||||
attempt_date=today,
|
||||
attempt_number=attempt_number,
|
||||
swap_count=safe_int(swaps_str),
|
||||
placement_count=safe_int(placements_str),
|
||||
rotation_count=safe_int(rots_str),
|
||||
time_to_solve=safe_float(time_str),
|
||||
is_solved=True,
|
||||
)
|
||||
s.add(new_attempt)
|
||||
s.commit()
|
||||
|
||||
return HTTPFound(
|
||||
location=request.route_url("daily_leaderboard", puzzle_id=puzzle.id)
|
||||
)
|
||||
|
||||
|
||||
@view_config(route_name="leaderboard_index") # Removed renderer='string'
|
||||
def leaderboard_index_view(request):
|
||||
s = request.dbsession
|
||||
puzzles = s.query(Puzzle).order_by(Puzzle.date.desc()).all()
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(LEADERBOARD_INDEX_TEMPLATE)
|
||||
rendered = template.render(request=request, puzzles=puzzles)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
@view_config(route_name="daily_leaderboard") # Removed renderer='string'
|
||||
def daily_leaderboard_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("No puzzle found for this ID.", status=404)
|
||||
|
||||
attempts = (
|
||||
s.query(Attempt)
|
||||
.filter_by(puzzle_id=puzzle.id, is_solved=True)
|
||||
.order_by(
|
||||
Attempt.time_to_solve.asc(),
|
||||
(
|
||||
Attempt.swap_count + Attempt.placement_count + Attempt.rotation_count
|
||||
).asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
# Manually render the template using Jinja2
|
||||
template = Template(DAILY_LEADERBOARD_TEMPLATE)
|
||||
rendered = template.render(request=request, puzzle=puzzle, attempts=attempts)
|
||||
return Response(rendered)
|
||||
|
||||
|
||||
###############################################################################
|
||||
# Main
|
||||
###############################################################################
|
||||
def main(global_config=None, **settings):
|
||||
if not settings:
|
||||
settings = {}
|
||||
settings["sqlalchemy.url"] = DB_URL
|
||||
|
||||
config = Configurator(settings=settings, session_factory=session_factory)
|
||||
config.include(
|
||||
"pyramid_jinja2"
|
||||
) # Included for potential future use with external templates
|
||||
|
||||
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("change_username", "/auth/change_username")
|
||||
|
||||
# Puzzle
|
||||
config.add_route("upload", "/upload")
|
||||
config.add_route("daily_puzzle", "/puzzle/today")
|
||||
config.add_route("submit_attempt", "/puzzle/submit")
|
||||
|
||||
# Leaderboards
|
||||
config.add_route("leaderboard_index", "/leaderboards")
|
||||
config.add_route("daily_leaderboard", "/leaderboard/{puzzle_id}")
|
||||
|
||||
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)
|
||||
9
requirements.txt
Normal file
9
requirements.txt
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
plaster_pastedeploy
|
||||
pyramid
|
||||
pyramid_jinja2
|
||||
pyramid_debugtoolbar
|
||||
waitress
|
||||
pyramid_retry
|
||||
|
||||
bcrypt
|
||||
sqlalchemy
|
||||
Loading…
Add table
Add a link
Reference in a new issue