auto create guest users

modified:   app.py
	modified:   templates/base.html.j2
	modified:   templates/daily_puzzle.html.j2
This commit is contained in:
Russell Ballestrini 2024-12-28 14:44:29 -05:00
parent 98a1b02b87
commit db81a7aa8e
3 changed files with 249 additions and 197 deletions

315
app.py
View file

@ -47,8 +47,8 @@ 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
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)
@ -168,6 +168,29 @@ class Attempt(Base):
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
DB_URL = "sqlite:///puzzle_game.db"
engine = create_engine(DB_URL, echo=False)
@ -227,37 +250,94 @@ def admin_required(view_func):
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()
"""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.commit()
# Store the user ID in the session
request.session["user_id"] = guest_user.id
return guest_user
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}"
def get_ordered_attempts(s, puzzle_id, sort_method='time'):
"""Retrieve and order attempts for a given puzzle based on the sort method."""
if sort_method == "moves":
# Sort by least total actions (moves + rotations), only considering first attempts
attempts = (
s.query(Attempt)
.filter(
Attempt.puzzle_id == puzzle_id,
Attempt.is_solved == True,
Attempt.is_counted == True,
Attempt.attempt_number == 1,
)
.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, only considering first attempts
attempts = (
s.query(Attempt)
.filter(
Attempt.puzzle_id == puzzle_id,
Attempt.is_solved == True,
Attempt.is_counted == True,
Attempt.attempt_number == 1,
)
.order_by(
(
func.julianday(Attempt.time_completed)
- func.julianday(Attempt.time_started)
).asc(),
(Attempt.move_count + Attempt.rotation_count).asc(),
)
.all()
)
return attempts
################################################################################
# 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,
}
@ -276,8 +356,19 @@ def login_post_view(request):
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)
# No valid user in session; create a new 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=guest_username).first()
if not existing_user:
# Unique username found
break
# Create new user with tmp 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()
@ -301,6 +392,9 @@ def login_post_view(request):
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"))
@ -315,33 +409,32 @@ def verify_post_view(request):
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
# 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 = s.query(User).filter(
User.email == email,
User.code_expires > datetime.datetime.now(),
User.code_hash != None,
).first()
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:
if not user:
return Response("Code not found or expired.", status=400)
user_matched.is_verified = True
user_matched.code_hash = None
user_matched.code_expires = None
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
s.commit()
request.session["user_id"] = user_matched.id
# Remove the email from the session
del request.session["login_email"]
request.session["user_id"] = user.id
return HTTPFound(location=request.route_url("home"))
@ -406,6 +499,8 @@ def profile_get_view(request):
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
@ -549,12 +644,11 @@ def invite_admin_post_view(request):
@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,
"user": request.user,
}
@ -562,7 +656,7 @@ def manage_admins_view(request):
@admin_required
def remove_admin_view(request):
s = request.dbsession
current_user = get_current_user(request)
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)
@ -729,107 +823,61 @@ def daily_puzzle_view(request):
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()
)
# Get the current user using request.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, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.filter_by(user_id=user.id, puzzle_id=puzzle.id, attempt_number=1)
.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
)
# Create the first attempt
attempt = Attempt(
user_id=user_id,
user_display_name=display_name,
user_id=user.id,
user_display_name=user.username,
puzzle_id=puzzle.id,
attempt_date=today,
attempt_number=attempt_number,
attempt_number=1,
move_count=0,
rotation_count=0,
is_solved=bool(existing_solve),
state_json=state_json,
is_solved=False,
state_json=puzzle.initial_state_json,
time_started=datetime.datetime.utcnow(),
is_counted=is_counted,
is_counted=True,
)
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()
# 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()
)
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:
if att.user_id == user.id:
user_rank = idx + 1
break
# Calculate server's current time in milliseconds
server_time = datetime.datetime.utcnow()
server_time_ms = int(server_time.timestamp() * 1000)
# Calculate initial elapsed time in milliseconds
attempt_start_time_ms = attempt.time_started_ms
initial_elapsed_ms = server_time_ms - attempt_start_time_ms
# 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, # Pass the entire attempt object
"attempt": attempt,
"attempt_state": json.loads(attempt.state_json),
"attempt_is_solved": attempt.is_solved or existing_solve is not None,
"attempt_is_solved": attempt.is_solved,
"user_rank": user_rank,
"image_mime_type": puzzle.image_mime_type,
"attempt_start_time_ms": attempt_start_time_ms,
"server_time_ms": server_time_ms,
"initial_elapsed_ms": initial_elapsed_ms,
"total_elapsed_ms": total_elapsed_ms,
}
@ -842,13 +890,9 @@ def puzzle_image_view(request):
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)
.filter_by(user_id=request.user.id, puzzle_id=puzzle.id, is_solved=True)
.first()
)
@ -876,9 +920,7 @@ def record_action_view(request):
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"]
user = request.user
size = int(request.matchdict.get("size", 4))
today = datetime.date.today()
@ -890,12 +932,11 @@ def record_action_view(request):
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
display_name = user.username
attempt = (
s.query(Attempt)
.filter_by(user_id=user_id, puzzle_id=puzzle.id, is_solved=False)
.filter_by(user_id=user.id, puzzle_id=puzzle.id, is_solved=False)
.order_by(Attempt.attempt_number.desc())
.first()
)
@ -960,7 +1001,7 @@ def record_action_view(request):
piece["currentIndex"] == piece["correctIndex"] and piece["rotation"] % 360 == 0
for piece in state
):
attempt.is_solved = True
attempt.mark_as_solved()
attempt.time_completed = datetime.datetime.utcnow()
s.commit()
@ -978,7 +1019,7 @@ def record_action_view(request):
.all()
)
for idx, att in enumerate(attempts):
if att.user_id == user_id and att.id == attempt.id:
if att.user_id == user.id and att.id == attempt.id:
user_rank = idx + 1
break
else:
@ -1112,7 +1153,7 @@ def king_of_the_mountain_view(request):
leaderboard.append(
{
"user_id": user.id,
"username": user.username or user.email,
"username": user.username,
"total_puzzles": total_puzzles,
"total_time": total_time,
"total_moves": total_moves,
@ -1129,16 +1170,6 @@ def king_of_the_mountain_view(request):
}
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
################################################################################

View file

@ -29,6 +29,7 @@ nav ul {
border: none;
font-size: 1.5em;
cursor: pointer;
color: inherit;
}
@media (max-width: 768px) {
@ -90,6 +91,11 @@ nav ul {
border: none;
font-size: 1.5em;
cursor: pointer;
color: inherit;
}
#mobile-menu-close:hover {
color: var(--primary);
}
/* Ensure links are vertical */
@ -111,7 +117,7 @@ nav ul {
<li><a href="{{ request.route_url('puzzle_menu') }}">Today's Puzzles</a></li>
<li><a href="{{ request.route_url('leaderboard_index') }}">Leaderboards</a></li>
<li><a href="{{ request.route_url('king_of_the_mountain') }}">King of the Mountain</a></li>
{% if request.user %}
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
{% else %}
@ -136,7 +142,7 @@ nav ul {
<li><a href="{{ request.route_url('puzzle_menu') }}">Today's Puzzles</a></li>
<li><a href="{{ request.route_url('leaderboard_index') }}">Leaderboards</a></li>
<li><a href="{{ request.route_url('king_of_the_mountain') }}">King of the Mountain</a></li>
{% if request.user %}
{% if request.user and request.user.is_verified %}
<li><a href="{{ request.route_url('profile') }}">Profile</a></li>
<li><a href="{{ request.route_url('logout') }}">Logout</a></li>
{% else %}

View file

@ -8,9 +8,9 @@
<h1>{{ puzzle.title }}</h1>
<p>Date: {{ puzzle.date }}</p>
<p>
Moves: <span id="moveCount">{{ attempt.move_count }}</span><br>
Moves: <span id="moveCount">{{ attempt.move_count }}</span>
Rotations: <span id="rotationCount">{{ attempt.rotation_count }}</span><br>
Time Elapsed: <span id="timerDisplay">00:00.00</span><br>
Time Elapsed: <span id="timerDisplay">0.00 seconds</span><br>
</p>
<button id="downloadButton" hidden>Download Image</button>
</div>
@ -21,14 +21,21 @@
<article>
<header>
<h2>Congratulations!</h2>
<a href="#close" aria-label="Close" class="close" id="winModalClose"></a>
<button aria-label="Close" id="winModalClose">✕</button>
</header>
<p>You solved the puzzle!</p>
{% if user_rank is not none %}
<p>Your Rank: {{ user_rank }}</p>
{% else %}
<p>Your rank is not available.</p>
{% endif %}
<p>You solved the puzzle! 🎉🎉🎉</p>
<div id="stats">
<p>
Date: {{ puzzle.date }}<br>
Moves: {{ attempt.move_count }} &nbsp;&nbsp; Rotations: {{ attempt.rotation_count }}<br>
Time Elapsed: {{ '%.2f'|format(total_elapsed_ms / 1000) }} seconds
</p>
{% if user_rank is not none %}
<p>Your Rank: {{ user_rank }}</p>
{% else %}
<p>Your rank is not available.</p>
{% endif %}
</div>
<footer>
<button id="modalDownloadButton">Download Image</button>
</footer>
@ -85,20 +92,30 @@
padding: 0;
}
/* Confetti styles */
.confetti {
position: fixed;
width: 10px;
height: 10px;
top: 0;
animation: confetti-fall 5s linear infinite;
#winModalClose {
background: none;
border: none;
font-size: 1.5em;
cursor: pointer;
color: inherit;
}
/* Confetti animation */
@keyframes confetti-fall {
0% { transform: translateY(0); }
100% { transform: translateY(100vh); }
#winModalClose:hover {
color: var(--primary);
}
/* Modal header styles */
#winModal article header {
display: flex;
justify-content: space-between;
align-items: center;
}
#winModal article header h2 {
margin: 0;
}
</style>
<!-- Scripts -->
@ -109,10 +126,8 @@ document.addEventListener("DOMContentLoaded", function() {
let isSolved = {{ 'true' if attempt_is_solved else 'false' }};
let moveCount = {{ attempt.move_count }};
let rotationCount = {{ attempt.rotation_count }};
let elapsedMs = {{ total_elapsed_ms | default(0) }};
// Use the initial elapsed time and increment it
let elapsedMs = {{ initial_elapsed_ms }};
// DOM elements
const puzzleContainer = document.getElementById("puzzle-container");
const moveCountDisplay = document.getElementById("moveCount");
@ -226,7 +241,6 @@ document.addEventListener("DOMContentLoaded", function() {
if (isSolved) return;
const touchTarget = event.target;
touchStartPieceUuid = touchTarget.dataset.pieceUuid;
// Add a visual cue if desired
}
function touchEnd(event) {
@ -308,7 +322,10 @@ document.addEventListener("DOMContentLoaded", function() {
.then(response => {
if (response.status === 'win') {
// Handle win condition
showWinModal(response.rank);
isSolved = true;
setTimeout(function() {
showWinModal(response.rank);
}, 100); // Delay to ensure modal displays properly
lockBoard();
} else if (response.status !== 'ok') {
console.error('Error recording action:', response.message);
@ -332,12 +349,16 @@ document.addEventListener("DOMContentLoaded", function() {
}
function showWinModal(rank) {
if (rank !== null) {
const rankDisplay = document.createElement('p');
rankDisplay.textContent = 'Your Rank: ' + rank;
const modalContent = winModal.querySelector('article');
modalContent.insertBefore(rankDisplay, modalContent.querySelector('footer'));
}
// Update stats in modal
const statsDiv = winModal.querySelector('#stats');
statsDiv.innerHTML = `
<p>Date: {{ puzzle.date }}</p>
<p>
Moves: ${moveCount} &nbsp;&nbsp; Rotations: ${rotationCount}<br>
Time Elapsed: ${formatTime(elapsedMs)}
</p>
${rank !== null ? `<p>Your Rank: ${rank}</p>` : `<p>Your rank is not available.</p>`}
`;
winModal.showModal();
@ -345,21 +366,11 @@ document.addEventListener("DOMContentLoaded", function() {
event.preventDefault();
winModal.close();
};
// Confetti effect
for (let i = 0; i < 100; i++) {
createConfetti();
}
}
function createConfetti() {
const confetti = document.createElement('div');
confetti.className = 'confetti';
confetti.style.left = Math.random() * 100 + '%';
confetti.style.backgroundColor = 'hsl(' + Math.random() * 360 + ', 100%, 50%)';
confetti.style.animationDelay = Math.random() * 3 + 's';
document.body.appendChild(confetti);
setTimeout(() => { confetti.remove(); }, 3000);
function formatTime(ms) {
const seconds = ms / 1000;
return `${seconds.toFixed(2)} seconds`;
}
// Download button functionality
@ -381,19 +392,13 @@ document.addEventListener("DOMContentLoaded", function() {
function updateTimer() {
if (isSolved) {
clearInterval(timerInterval); // Stop the timer when the puzzle is solved
updateTimerDisplay(); // Ensure the final time is displayed
return;
}
// Increment elapsedMs by 10 milliseconds
elapsedMs += 10;
const minutes = Math.floor(elapsedMs / 60000);
const seconds = Math.floor((elapsedMs % 60000) / 1000);
const centiseconds = Math.floor((elapsedMs % 1000) / 10); // Get centiseconds
timerDisplay.textContent = `${padZero(minutes)}:${padZero(seconds)}.${padZero(centiseconds)}`;
}
function padZero(num) {
return num.toString().padStart(2, '0');
updateTimerDisplay();
}
// Update the timer every 10 milliseconds
@ -401,13 +406,23 @@ document.addEventListener("DOMContentLoaded", function() {
timerInterval = setInterval(updateTimer, 10); // Update every 10 milliseconds
}
function updateTimerDisplay() {
timerDisplay.textContent = formatTime(elapsedMs);
}
// Initialize everything
initBoard();
updateScores();
startTimer();
if (isSolved) {
lockBoard();
updateTimerDisplay();
// Delay the modal show call to ensure it works across all browsers
setTimeout(function() {
showWinModal({{ user_rank | tojson | default('null') }});
}, 100); // Delay by 100 milliseconds
} else {
startTimer();
}
});
</script>