Add full SkinsPins application

- FastAPI + SQLite + Alpine.js + HTMX PWA for Bingo Bango Bongo golf scoring
- Passwordless magic link auth with email (aiosmtplib) and admin invite system
- Real-time score entry via WebSocket with drag-and-drop player ordering
- Course management with per-hole par and stroke index
- Scorecard with golf score markers (birdie, eagle, bogey, etc.)
- Two-step new game form with tee selection per player
- Dashboard with stats, live games social stream, and recent game history
- Game summary view (read-only scorecard with leaderboard)
- User profile pages with win stats
- PWA install support with generated PNG icons
- Admin panel for users, courses, games, and invitations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Rolf
2026-03-20 10:16:46 +01:00
parent 4b31b98f89
commit 3a1da1482c
51 changed files with 4964 additions and 1 deletions
+96
View File
@@ -0,0 +1,96 @@
"""Async email sending via SMTP. Falls back to logging if SMTP is not configured."""
import logging
import os
from email.mime.multipart import MIMEMultipart
from email.mime.text import MIMEText
import aiosmtplib
logger = logging.getLogger(__name__)
SMTP_HOST = os.getenv("SMTP_HOST", "")
SMTP_PORT = int(os.getenv("SMTP_PORT", "587"))
SMTP_USER = os.getenv("SMTP_USER", "")
SMTP_PASSWORD = os.getenv("SMTP_PASSWORD", "")
SMTP_FROM = os.getenv("SMTP_FROM", SMTP_USER)
SMTP_SSL = os.getenv("SMTP_SSL", "false").lower() == "true"
SMTP_TLS = os.getenv("SMTP_TLS", "true").lower() != "false"
SITE_NAME = "Skins & Pins"
def _configured() -> bool:
return bool(SMTP_HOST and SMTP_USER and SMTP_FROM)
async def _send(to: str, subject: str, body_text: str, body_html: str) -> None:
msg = MIMEMultipart("alternative")
msg["Subject"] = subject
msg["From"] = f"{SITE_NAME} <{SMTP_FROM}>"
msg["To"] = to
msg.attach(MIMEText(body_text, "plain"))
msg.attach(MIMEText(body_html, "html"))
await aiosmtplib.send(
msg,
hostname=SMTP_HOST,
port=SMTP_PORT,
username=SMTP_USER,
password=SMTP_PASSWORD,
use_tls=SMTP_SSL,
start_tls=(SMTP_TLS and not SMTP_SSL),
timeout=10,
)
async def send_magic_link(to: str, link: str) -> None:
subject = f"Your {SITE_NAME} login link"
body_text = f"Click the link below to log in (expires in 15 minutes):\n\n{link}\n\nIf you didn't request this, you can ignore this email."
body_html = f"""
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
<p>Click the button below to log in. This link expires in 15 minutes.</p>
<p style="margin:2rem 0;">
<a href="{link}"
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
Log in to {SITE_NAME}
</a>
</p>
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{link}</p>
<p style="color:#888;font-size:0.85rem;">If you didn't request this, you can ignore this email.</p>
</div>"""
if _configured():
try:
await _send(to, subject, body_text, body_html)
logger.info("Magic link email sent to %s", to)
except Exception as e:
logger.error("Failed to send magic link email to %s: %s", to, e)
else:
logger.info("Magic link for %s: %s", to, link)
async def send_invitation(to: str, invited_by_name: str, login_url: str) -> None:
subject = f"You've been invited to {SITE_NAME}"
body_text = f"{invited_by_name} has invited you to join {SITE_NAME}.\n\nLog in here:\n{login_url}"
body_html = f"""
<div style="font-family:sans-serif;max-width:480px;margin:auto;">
<h2 style="color:#2d6a4f;">⛳ {SITE_NAME}</h2>
<p><strong>{invited_by_name}</strong> has invited you to join {SITE_NAME} — a golf score tracking app.</p>
<p style="margin:2rem 0;">
<a href="{login_url}"
style="background:#2d6a4f;color:#fff;padding:0.75rem 1.5rem;border-radius:0.5rem;text-decoration:none;font-weight:600;">
Accept invitation
</a>
</p>
<p style="color:#888;font-size:0.85rem;">If the button doesn't work, copy this link:<br>{login_url}</p>
</div>"""
if _configured():
try:
await _send(to, subject, body_text, body_html)
logger.info("Invitation email sent to %s", to)
except Exception as e:
logger.error("Failed to send invitation email to %s: %s", to, e)
else:
logger.info("Invitation for %s — login at: %s", to, login_url)