golfgame/server/models/user.py
Aaron D. Lee bea85e6b28 Huge v2 uplift, now deployable with real user management and tooling!
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-27 11:32:15 -05:00

288 lines
11 KiB
Python

"""
User-related models for Golf game authentication.
Defines user accounts, sessions, and guest tracking for the V2 auth system.
"""
from dataclasses import dataclass, field
from datetime import datetime, timezone
from enum import Enum
from typing import Optional, Any
import json
class UserRole(str, Enum):
"""User role levels."""
GUEST = "guest"
USER = "user"
ADMIN = "admin"
@dataclass
class User:
"""
A registered user account.
Attributes:
id: UUID primary key.
username: Unique display name.
email: Optional email address.
password_hash: bcrypt hash of password.
role: User role (guest, user, admin).
email_verified: Whether email has been verified.
verification_token: Token for email verification.
verification_expires: When verification token expires.
reset_token: Token for password reset.
reset_expires: When reset token expires.
guest_id: Guest session ID if converted from guest.
deleted_at: Soft delete timestamp.
preferences: User preferences as JSON.
created_at: When account was created.
last_login: Last login timestamp.
last_seen_at: Last activity timestamp.
is_active: Whether account is active.
is_banned: Whether user is banned.
ban_reason: Reason for ban (if banned).
force_password_reset: Whether user must reset password on next login.
"""
id: str
username: str
password_hash: str
email: Optional[str] = None
role: UserRole = UserRole.USER
email_verified: bool = False
verification_token: Optional[str] = None
verification_expires: Optional[datetime] = None
reset_token: Optional[str] = None
reset_expires: Optional[datetime] = None
guest_id: Optional[str] = None
deleted_at: Optional[datetime] = None
preferences: dict = field(default_factory=dict)
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_login: Optional[datetime] = None
last_seen_at: Optional[datetime] = None
is_active: bool = True
is_banned: bool = False
ban_reason: Optional[str] = None
force_password_reset: bool = False
def is_admin(self) -> bool:
"""Check if user has admin role."""
return self.role == UserRole.ADMIN
def is_guest(self) -> bool:
"""Check if user has guest role."""
return self.role == UserRole.GUEST
def can_login(self) -> bool:
"""Check if user can log in."""
return self.is_active and self.deleted_at is None and not self.is_banned
def to_dict(self, include_sensitive: bool = False) -> dict:
"""
Serialize user to dictionary.
Args:
include_sensitive: Include password hash and tokens.
"""
d = {
"id": self.id,
"username": self.username,
"email": self.email,
"role": self.role.value,
"email_verified": self.email_verified,
"preferences": self.preferences,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_login": self.last_login.isoformat() if self.last_login else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"is_active": self.is_active,
"is_banned": self.is_banned,
"ban_reason": self.ban_reason,
"force_password_reset": self.force_password_reset,
}
if include_sensitive:
d["password_hash"] = self.password_hash
d["verification_token"] = self.verification_token
d["verification_expires"] = (
self.verification_expires.isoformat() if self.verification_expires else None
)
d["reset_token"] = self.reset_token
d["reset_expires"] = (
self.reset_expires.isoformat() if self.reset_expires else None
)
d["guest_id"] = self.guest_id
d["deleted_at"] = self.deleted_at.isoformat() if self.deleted_at else None
return d
@classmethod
def from_dict(cls, d: dict) -> "User":
"""Deserialize user from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
username=d["username"],
password_hash=d.get("password_hash", ""),
email=d.get("email"),
role=UserRole(d.get("role", "user")),
email_verified=d.get("email_verified", False),
verification_token=d.get("verification_token"),
verification_expires=parse_dt(d.get("verification_expires")),
reset_token=d.get("reset_token"),
reset_expires=parse_dt(d.get("reset_expires")),
guest_id=d.get("guest_id"),
deleted_at=parse_dt(d.get("deleted_at")),
preferences=d.get("preferences", {}),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_login=parse_dt(d.get("last_login")),
last_seen_at=parse_dt(d.get("last_seen_at")),
is_active=d.get("is_active", True),
is_banned=d.get("is_banned", False),
ban_reason=d.get("ban_reason"),
force_password_reset=d.get("force_password_reset", False),
)
@dataclass
class UserSession:
"""
An active user session.
Session tokens are hashed before storage for security.
Attributes:
id: UUID primary key.
user_id: Reference to user.
token_hash: SHA256 hash of session token.
device_info: Device/browser information.
ip_address: Client IP address.
created_at: When session was created.
expires_at: When session expires.
last_used_at: Last activity timestamp.
revoked_at: When session was revoked (if any).
"""
id: str
user_id: str
token_hash: str
device_info: dict = field(default_factory=dict)
ip_address: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_used_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
revoked_at: Optional[datetime] = None
def is_valid(self) -> bool:
"""Check if session is still valid."""
now = datetime.now(timezone.utc)
return (
self.revoked_at is None
and self.expires_at > now
)
def to_dict(self) -> dict:
"""Serialize session to dictionary."""
return {
"id": self.id,
"user_id": self.user_id,
"token_hash": self.token_hash,
"device_info": self.device_info,
"ip_address": self.ip_address,
"created_at": self.created_at.isoformat() if self.created_at else None,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
"last_used_at": self.last_used_at.isoformat() if self.last_used_at else None,
"revoked_at": self.revoked_at.isoformat() if self.revoked_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "UserSession":
"""Deserialize session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
user_id=d["user_id"],
token_hash=d["token_hash"],
device_info=d.get("device_info", {}),
ip_address=d.get("ip_address"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
last_used_at=parse_dt(d.get("last_used_at")) or datetime.now(timezone.utc),
revoked_at=parse_dt(d.get("revoked_at")),
)
@dataclass
class GuestSession:
"""
A guest session for tracking anonymous users.
Guests can play games without registering. Their session
can later be converted to a full user account.
Attributes:
id: Guest session ID (stored in client).
display_name: Display name for the guest.
created_at: When session was created.
last_seen_at: Last activity timestamp.
games_played: Number of games played as guest.
converted_to_user_id: User ID if converted to account.
expires_at: When guest session expires.
"""
id: str
display_name: Optional[str] = None
created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
last_seen_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
games_played: int = 0
converted_to_user_id: Optional[str] = None
expires_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
def is_converted(self) -> bool:
"""Check if guest has been converted to user."""
return self.converted_to_user_id is not None
def is_expired(self) -> bool:
"""Check if guest session has expired."""
return datetime.now(timezone.utc) > self.expires_at
def to_dict(self) -> dict:
"""Serialize guest session to dictionary."""
return {
"id": self.id,
"display_name": self.display_name,
"created_at": self.created_at.isoformat() if self.created_at else None,
"last_seen_at": self.last_seen_at.isoformat() if self.last_seen_at else None,
"games_played": self.games_played,
"converted_to_user_id": self.converted_to_user_id,
"expires_at": self.expires_at.isoformat() if self.expires_at else None,
}
@classmethod
def from_dict(cls, d: dict) -> "GuestSession":
"""Deserialize guest session from dictionary."""
def parse_dt(val: Any) -> Optional[datetime]:
if val is None:
return None
if isinstance(val, datetime):
return val
return datetime.fromisoformat(val)
return cls(
id=d["id"],
display_name=d.get("display_name"),
created_at=parse_dt(d.get("created_at")) or datetime.now(timezone.utc),
last_seen_at=parse_dt(d.get("last_seen_at")) or datetime.now(timezone.utc),
games_played=d.get("games_played", 0),
converted_to_user_id=d.get("converted_to_user_id"),
expires_at=parse_dt(d.get("expires_at")) or datetime.now(timezone.utc),
)