commit 2a73cb77ed6e63f09f70fe90681d3946d7878c56 Author: Russell Ballestrini Date: Thu Dec 26 09:17:41 2024 -0500 init new file: .gitignore new file: app.py new file: requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..98e6ef6 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +*.db diff --git a/app.py b/app.py new file mode 100644 index 0000000..20200a4 --- /dev/null +++ b/app.py @@ -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""" + + +Daily Puzzle - Home + +

Daily Puzzle - Home

+ + + {% if user %} +

You are logged in as {{ user.username }}.

+ {% else %} +

You are not logged in.

+

Your guest name: {{ guest_name }}

+ {% endif %} + + +""" + +LOGIN_TEMPLATE = r""" + + +Login with Email + +

Login (Email Only)

+

Enter your email to receive a 6-digit code. If you're a new user, an account is created for you. If you already exist, we'll re-send a code.

+
+ + +
+

Home

+ + +""" + +VERIFY_TEMPLATE = r""" + + +Verify Code + +

Enter 6-digit code

+
+ + +
+

Home

+ + +""" + +CHANGE_USERNAME_TEMPLATE = r""" + + +Change Username + +

Change Username

+
+ + +
+

Home

+ + +""" + +UPLOAD_TEMPLATE = r""" + + +Upload Puzzle + +

Upload Puzzle Image / Schedule

+
+ +

+ + +

+ + +

+ + +
+

Back to Home

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

Today's Puzzle: {{ puzzle.title }}

+

Date: {{ puzzle.date }}

+

+ Up to 2 attempts per day.
+ Drag pieces between board/side.
+ Click a piece to rotate (+90°).
+ We track swaps, placements, rotations, and time. +

+ +
Board
+
+
Side Board
+
+ + + {% else %} +

No puzzle for today!

+ {% endif %} +

Back to Home

+ + +""" + +LEADERBOARD_INDEX_TEMPLATE = r""" + + +All Leaderboards + +

All Leaderboards

+

Click a puzzle date to see its full scoreboard.

+ + + + + + + {% for p in puzzles %} + + + + + + {% endfor %} +
Puzzle DateTitleActions
{{ p.date }}{{ p.title }}View Leaderboard
+

Back to Home

+ + +""" + +DAILY_LEADERBOARD_TEMPLATE = r""" + + +Daily Leaderboard + +

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

+ + + + + + + + + + {% for row in attempts %} + + + + + + + + + {% endfor %} +
Username/GuestAttempt #SwapsPlacementsRotationsTime (sec)
{{ row.user_display_name }}{{ row.attempt_number }}{{ row.swap_count }}{{ row.placement_count }}{{ row.rotation_count }}{{ "%.2f"|format(row.time_to_solve) }}
+

All Leaderboards

+

Back to Home

+ + +""" + + +############################################################################### +# 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).
" + f"Enter code" + ) + + +@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.
Home" + ) + + +@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"

Home" + ) + + +############################################################################### +# 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 = "

No puzzle for today!

Back to Home

" + + 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) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..616d694 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,9 @@ +plaster_pastedeploy +pyramid +pyramid_jinja2 +pyramid_debugtoolbar +waitress +pyramid_retry + +bcrypt +sqlalchemy