first commit
This commit is contained in:
20
README.md
Normal file
20
README.md
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
## NCD Secret Santa
|
||||||
|
|
||||||
|
You need to install aiohttp from your repos or make venv and install it via pip.
|
||||||
|
|
||||||
|
Before you run it, modify the BASE_URL var in the main file to wherever this will be publically accessable, for making the invite code urls.
|
||||||
|
|
||||||
|
When you first run it, it will make a `invite_urls.txt` file with 500 invite urls. They're one-time only and are bound to a user's cookie, so they can't
|
||||||
|
clear their cache or browse private mode or use a different device for checking later if they have drawn another person yet. You are manually
|
||||||
|
responsible for only giving out good invite codes.
|
||||||
|
|
||||||
|
There are two deadlines, one for registering, and the other for drawing. Those are configurable in the top of the file, but default to nov 15 and dec 15 respectively.
|
||||||
|
|
||||||
|
If it's past the drawing deadline, a log file should be created with a list of those who signed up but haven't drawn someone to send a gift to.
|
||||||
|
You are responsible for contacting them and reminding them, and if they cancel, tell the person who drew them that they are being reassigned.
|
||||||
|
|
||||||
|
To reassign, just take someone out of the `pool.json` and give the address to the person.
|
||||||
|
|
||||||
|
If there's some kind of weird bug and everyone needs to try again, `persistant.json` keeps a temporary record of everyone who signed up, but it will be
|
||||||
|
automatically deleted after Christmas.
|
||||||
|
|
601
main_invite_only.py
Normal file
601
main_invite_only.py
Normal file
@ -0,0 +1,601 @@
|
|||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from aiohttp import web
|
||||||
|
import random
|
||||||
|
import base64
|
||||||
|
|
||||||
|
# Constants and Config
|
||||||
|
BASE_URL = 'rpi.local:8080'
|
||||||
|
PERSISTENT_FILE = 'persistent.json'
|
||||||
|
POOL_FILE = 'pool.json'
|
||||||
|
LOG_FILE = 'secretsanta_noncompliant_log.txt'
|
||||||
|
TOKENS_FILE = 'tokens.json'
|
||||||
|
INVITE_URLS_FILE = 'invite_urls.txt'
|
||||||
|
COUPLE_HOURS = 2 # hours to wait before forcing assignment
|
||||||
|
DEFAULT_DOLLAR_LIMIT = 50
|
||||||
|
CHECK_INTERVAL = 12 * 3600 # 12 hours in seconds
|
||||||
|
|
||||||
|
# Deadlines default to current year
|
||||||
|
CURRENT_YEAR = datetime.utcnow().year
|
||||||
|
SIGNUP_DEADLINE = datetime(CURRENT_YEAR, 11, 15, 0, 0, 0)
|
||||||
|
DRAWING_DEADLINE = datetime(CURRENT_YEAR, 12, 15, 0, 0, 0)
|
||||||
|
|
||||||
|
# Calculate purge date: 7 days before Christmas of current year
|
||||||
|
CHRISTMAS = datetime(CURRENT_YEAR, 12, 25)
|
||||||
|
PURGE_DATE = CHRISTMAS - timedelta(days=7)
|
||||||
|
|
||||||
|
# Load data
|
||||||
|
def load_persistent():
|
||||||
|
if os.path.exists(PERSISTENT_FILE):
|
||||||
|
with open(PERSISTENT_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_pool():
|
||||||
|
if os.path.exists(POOL_FILE):
|
||||||
|
with open(POOL_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return []
|
||||||
|
|
||||||
|
def load_tokens():
|
||||||
|
if os.path.exists(TOKENS_FILE):
|
||||||
|
with open(TOKENS_FILE, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
persistent = load_persistent()
|
||||||
|
pool = load_pool() # list of ids available to be drawn (not yet assigned_by someone)
|
||||||
|
tokens = load_tokens() # dict of token -> {used: bool, used_by_cookie: str}
|
||||||
|
|
||||||
|
# Save data
|
||||||
|
def save_data():
|
||||||
|
with open(PERSISTENT_FILE, 'w') as f:
|
||||||
|
json.dump(persistent, f, indent=4, default=str)
|
||||||
|
with open(POOL_FILE, 'w') as f:
|
||||||
|
json.dump(pool, f, indent=4)
|
||||||
|
|
||||||
|
def save_tokens():
|
||||||
|
with open(TOKENS_FILE, 'w') as f:
|
||||||
|
json.dump(tokens, f, indent=4)
|
||||||
|
|
||||||
|
# Generate invite tokens
|
||||||
|
def generate_tokens(count=500, base_url=BASE_URL):
|
||||||
|
"""Generate invite tokens and save URLs to file"""
|
||||||
|
new_tokens = {}
|
||||||
|
urls = []
|
||||||
|
|
||||||
|
for _ in range(count):
|
||||||
|
token = base64.urlsafe_b64encode(uuid.uuid4().bytes).decode('utf-8').rstrip('=')
|
||||||
|
new_tokens[token] = {'used': False, 'used_by_cookie': None}
|
||||||
|
urls.append(f"{base_url}/invite?token={token}")
|
||||||
|
|
||||||
|
# Save tokens
|
||||||
|
tokens.update(new_tokens)
|
||||||
|
save_tokens()
|
||||||
|
|
||||||
|
# Save URLs to text file
|
||||||
|
with open(INVITE_URLS_FILE, 'w') as f:
|
||||||
|
f.write('\n'.join(urls))
|
||||||
|
|
||||||
|
print(f"Generated {count} invite tokens. URLs saved to {INVITE_URLS_FILE}")
|
||||||
|
|
||||||
|
# Check if we need to generate tokens on startup
|
||||||
|
if not os.path.exists(TOKENS_FILE) or not tokens:
|
||||||
|
generate_tokens()
|
||||||
|
|
||||||
|
# Purge data if past purge date
|
||||||
|
def purge_data():
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now > PURGE_DATE:
|
||||||
|
for file in [PERSISTENT_FILE, POOL_FILE]:
|
||||||
|
if os.path.exists(file):
|
||||||
|
os.remove(file)
|
||||||
|
print("Data purged due to purge date.")
|
||||||
|
|
||||||
|
# Log non-compliant (non-drawers) if past drawing deadline
|
||||||
|
def log_non_compliant():
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now > DRAWING_DEADLINE:
|
||||||
|
non_drawers = [entry for entry in persistent if entry.get('assigned_receiver') is None]
|
||||||
|
log_lines = []
|
||||||
|
for nd in non_drawers:
|
||||||
|
contact = nd.get('fedi') or nd.get('email') or 'No contact'
|
||||||
|
log_lines.append(f"Non-drawer: ID {nd['id']}, Contact: {contact}")
|
||||||
|
if nd.get('assigned_by'):
|
||||||
|
sender = next((e for e in persistent if e['id'] == nd['assigned_by']), None)
|
||||||
|
sender_contact = sender.get('fedi') or sender.get('email') or 'No contact' if sender else 'Unknown'
|
||||||
|
log_lines.append(f" Affected sender ID {nd['assigned_by']}, Contact: {sender_contact} - Needs reassignment")
|
||||||
|
if log_lines:
|
||||||
|
log_msg = "\n".join(log_lines) + "\n"
|
||||||
|
print(log_msg) # To console
|
||||||
|
with open(LOG_FILE, 'a') as f:
|
||||||
|
f.write(log_msg)
|
||||||
|
|
||||||
|
# Background task for periodic checks
|
||||||
|
async def background_checker():
|
||||||
|
while True:
|
||||||
|
purge_data()
|
||||||
|
log_non_compliant()
|
||||||
|
await asyncio.sleep(CHECK_INTERVAL)
|
||||||
|
|
||||||
|
# HTML Template for access denied
|
||||||
|
ACCESS_DENIED_HTML = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Access Denied - Secret Santa</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🚫</text></svg>">
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
background-color: #8B0000;
|
||||||
|
color: white;
|
||||||
|
font-family: 'Comic Sans MS', cursive;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
}}
|
||||||
|
h1 {{ color: #FFD700; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>🚫 Access Denied 🚫</h1>
|
||||||
|
<p>{message}</p>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# HTML Template with late 90s Christmas theme, inline CSS/JS
|
||||||
|
# Escaped curly braces for str.format()
|
||||||
|
HTML = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Secret Santa Coordinator - Ho Ho Ho!</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎁</text></svg>">
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
background-color: #228B22; /* Forest green */
|
||||||
|
color: white;
|
||||||
|
font-family: 'Comic Sans MS', cursive;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border: 20px solid;
|
||||||
|
border-image: linear-gradient(to right, #FF0000 50%, #FFFFFF 50%) 1; /* Alternating red/white for Christmas colors */
|
||||||
|
animation: twinkling 2s infinite;
|
||||||
|
}}
|
||||||
|
h1 {{ color: #FF0000; text-shadow: 2px 2px #FFD700; }}
|
||||||
|
form {{ background: rgba(255,255,255,0.2); padding: 20px; margin: 20px auto; width: 50%; border-radius: 10px; }}
|
||||||
|
input, textarea {{ width: 80%; padding: 10px; margin: 10px; background: #FFF; color: #000; border: 2px solid #FF0000; }}
|
||||||
|
button {{ background: #FF0000; color: white; padding: 10px 20px; border: none; cursor: pointer; }}
|
||||||
|
button:hover {{ background: #FFD700; color: #FF0000; }}
|
||||||
|
#snow {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
|
||||||
|
#candycanes {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
|
||||||
|
.agreement-div {{
|
||||||
|
background: rgba(255,255,255,0.2);
|
||||||
|
padding: 10px;
|
||||||
|
margin: 10px auto;
|
||||||
|
width: 80%;
|
||||||
|
border: 2px dashed #FFD700;
|
||||||
|
border-radius: 5px;
|
||||||
|
cursor: pointer;
|
||||||
|
}}
|
||||||
|
.agreement-div input {{ display: none; }}
|
||||||
|
.agreement-div.checked {{ background: rgba(0,255,0,0.3); }}
|
||||||
|
@keyframes twinkling {{ 0% {{ opacity: 1; }} 50% {{ opacity: 0.8; }} 100% {{ opacity: 1; }} }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="snow"></div>
|
||||||
|
<div id="candycanes"></div>
|
||||||
|
<h1>🎅 Secret Santa Anonymous Coordinator 🎄</h1>
|
||||||
|
<p>Welcome to our #darkfedi gift exchange! Enter your details to join the pool. We'll assign you someone to send a gift to anonymously.</p>
|
||||||
|
<p>Signup deadline: {signup_deadline} | Drawing deadline: {drawing_deadline}</p>
|
||||||
|
<b>{message}</b>
|
||||||
|
<form id="submit-form" method="POST" action="/submit">
|
||||||
|
<input type="text" name="name" placeholder="Name (optional)"><br>
|
||||||
|
<input type="text" name="fedi" placeholder="Fedi Handle (e.g., @user@mastodon.social, optional but required if no email)"><br>
|
||||||
|
<input type="email" name="email" placeholder="Email (optional but required if no Fedi)"><br>
|
||||||
|
<input type="number" name="dollar_limit" placeholder="Dollar Limit (default $50)"><br>
|
||||||
|
<textarea name="likes_dislikes" placeholder="Likes/Dislikes (optional)"></textarea><br>
|
||||||
|
<textarea name="address" placeholder="Address (required)" required></textarea><br>
|
||||||
|
<div class="agreement-div" onclick="toggleCheckbox()">
|
||||||
|
*CLICK* to agree to reach out to the organizer if you cannot send a gift in time.
|
||||||
|
<input type="checkbox" name="agreement" required>
|
||||||
|
</div><br>
|
||||||
|
<button type="submit">Join Secret Santa!</button>
|
||||||
|
</form>
|
||||||
|
<h2>Check Your Status</h2>
|
||||||
|
<form id="check-form" method="POST" action="/check">
|
||||||
|
<input type="text" name="code" placeholder="Your One-Time Code" required><br>
|
||||||
|
<button type="submit">Check / Draw</button>
|
||||||
|
</form>
|
||||||
|
<script>
|
||||||
|
// Toggle checkbox and class
|
||||||
|
function toggleCheckbox() {{
|
||||||
|
const div = document.querySelector('.agreement-div');
|
||||||
|
const checkbox = div.querySelector('input');
|
||||||
|
checkbox.checked = !checkbox.checked;
|
||||||
|
div.classList.toggle('checked', checkbox.checked);
|
||||||
|
}}
|
||||||
|
|
||||||
|
// Inline falling snow JS
|
||||||
|
function createSnowflake() {{
|
||||||
|
const snowflake = document.createElement('div');
|
||||||
|
snowflake.innerHTML = '❄';
|
||||||
|
snowflake.style.position = 'absolute';
|
||||||
|
snowflake.style.fontSize = Math.random() * 20 + 10 + 'px';
|
||||||
|
snowflake.style.color = 'white';
|
||||||
|
snowflake.style.left = Math.random() * window.innerWidth + 'px';
|
||||||
|
snowflake.style.top = '-50px';
|
||||||
|
snowflake.style.opacity = Math.random();
|
||||||
|
document.getElementById('snow').appendChild(snowflake);
|
||||||
|
let y = -50;
|
||||||
|
const speed = Math.random() * 3 + 1;
|
||||||
|
const sway = Math.random() * 20 - 10;
|
||||||
|
const interval = setInterval(() => {{
|
||||||
|
y += speed;
|
||||||
|
snowflake.style.top = y + 'px';
|
||||||
|
snowflake.style.left = (parseFloat(snowflake.style.left) + Math.sin(y / 50) * sway / 10) + 'px';
|
||||||
|
if (y > window.innerHeight) {{
|
||||||
|
snowflake.remove();
|
||||||
|
clearInterval(interval);
|
||||||
|
}}
|
||||||
|
}}, 50);
|
||||||
|
}}
|
||||||
|
setInterval(createSnowflake, 200);
|
||||||
|
|
||||||
|
// Candy cane sprinkling (assuming ncd.png is served at /candycane.png)
|
||||||
|
function createCandyCane() {{
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/candycane.png';
|
||||||
|
img.style.position = 'absolute';
|
||||||
|
img.style.width = Math.random() * 50 + 30 + 'px';
|
||||||
|
img.style.left = Math.random() * window.innerWidth + 'px';
|
||||||
|
img.style.top = '-100px';
|
||||||
|
img.style.opacity = Math.random() * 0.5 + 0.5;
|
||||||
|
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg)`;
|
||||||
|
document.getElementById('candycanes').appendChild(img);
|
||||||
|
let y = -100;
|
||||||
|
const speed = Math.random() * 2 + 1;
|
||||||
|
const shiftInterval = Math.random() * 2000 + 1000; // Flip/shift every 1-3s
|
||||||
|
let flipped = false;
|
||||||
|
const interval = setInterval(() => {{
|
||||||
|
y += speed;
|
||||||
|
img.style.top = y + 'px';
|
||||||
|
if (y > window.innerHeight) {{
|
||||||
|
img.remove();
|
||||||
|
clearInterval(interval);
|
||||||
|
}}
|
||||||
|
}}, 50);
|
||||||
|
setInterval(() => {{
|
||||||
|
flipped = !flipped;
|
||||||
|
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg) scaleX(${{flipped ? -1 : 1}})`;
|
||||||
|
}}, shiftInterval);
|
||||||
|
}}
|
||||||
|
setInterval(createCandyCane, 1000); // Slower than snow
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Simplified success HTML (removes forms and other elements)
|
||||||
|
SUCCESS_HTML = """
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Secret Santa Coordinator - Ho Ho Ho!</title>
|
||||||
|
<link rel="icon" href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🎁</text></svg>">
|
||||||
|
<style>
|
||||||
|
body {{
|
||||||
|
background-color: #228B22; /* Forest green */
|
||||||
|
color: white;
|
||||||
|
font-family: 'Comic Sans MS', cursive;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
padding: 20px;
|
||||||
|
border: 20px solid;
|
||||||
|
border-image: linear-gradient(to right, #FF0000 50%, #FFFFFF 50%) 1; /* Alternating red/white */
|
||||||
|
animation: twinkling 2s infinite;
|
||||||
|
}}
|
||||||
|
h1 {{ color: #FF0000; text-shadow: 2px 2px #FFD700; }}
|
||||||
|
#snow {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
|
||||||
|
#candycanes {{ position: fixed; top: 0; left: 0; width: 100%; height: 100%; pointer-events: none; }}
|
||||||
|
button {{ background: #FF0000; color: white; padding: 10px 20px; border: none; cursor: pointer; }}
|
||||||
|
button:hover {{ background: #FFD700; color: #FF0000; }}
|
||||||
|
@keyframes twinkling {{ 0% {{ opacity: 1; }} 50% {{ opacity: 0.8; }} 100% {{ opacity: 1; }} }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="snow"></div>
|
||||||
|
<div id="candycanes"></div>
|
||||||
|
<h1>🎅 Secret Santa Anonymous Coordinator 🎄</h1>
|
||||||
|
{message}
|
||||||
|
<button onclick="window.location.href='/'">Back to Main Page</button>
|
||||||
|
<script>
|
||||||
|
// Snow and candy cane scripts same as above
|
||||||
|
function createSnowflake() {{
|
||||||
|
const snowflake = document.createElement('div');
|
||||||
|
snowflake.innerHTML = '❄';
|
||||||
|
snowflake.style.position = 'absolute';
|
||||||
|
snowflake.style.fontSize = Math.random() * 20 + 10 + 'px';
|
||||||
|
snowflake.style.color = 'white';
|
||||||
|
snowflake.style.left = Math.random() * window.innerWidth + 'px';
|
||||||
|
snowflake.style.top = '-50px';
|
||||||
|
snowflake.style.opacity = Math.random();
|
||||||
|
document.getElementById('snow').appendChild(snowflake);
|
||||||
|
let y = -50;
|
||||||
|
const speed = Math.random() * 3 + 1;
|
||||||
|
const sway = Math.random() * 20 - 10;
|
||||||
|
const interval = setInterval(() => {{
|
||||||
|
y += speed;
|
||||||
|
snowflake.style.top = y + 'px';
|
||||||
|
snowflake.style.left = (parseFloat(snowflake.style.left) + Math.sin(y / 50) * sway / 10) + 'px';
|
||||||
|
if (y > window.innerHeight) {{
|
||||||
|
snowflake.remove();
|
||||||
|
clearInterval(interval);
|
||||||
|
}}
|
||||||
|
}}, 50);
|
||||||
|
}}
|
||||||
|
setInterval(createSnowflake, 200);
|
||||||
|
|
||||||
|
function createCandyCane() {{
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = '/ncd.png';
|
||||||
|
img.style.position = 'absolute';
|
||||||
|
img.style.width = Math.random() * 50 + 30 + 'px';
|
||||||
|
img.style.left = Math.random() * window.innerWidth + 'px';
|
||||||
|
img.style.top = '-100px';
|
||||||
|
img.style.opacity = Math.random() * 0.5 + 0.5;
|
||||||
|
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg)`;
|
||||||
|
document.getElementById('candycanes').appendChild(img);
|
||||||
|
let y = -100;
|
||||||
|
const speed = Math.random() * 2 + 1;
|
||||||
|
const shiftInterval = Math.random() * 2000 + 1000;
|
||||||
|
let flipped = false;
|
||||||
|
const interval = setInterval(() => {{
|
||||||
|
y += speed;
|
||||||
|
img.style.top = y + 'px';
|
||||||
|
if (y > window.innerHeight) {{
|
||||||
|
img.remove();
|
||||||
|
clearInterval(interval);
|
||||||
|
}}
|
||||||
|
}}, 50);
|
||||||
|
setInterval(() => {{
|
||||||
|
flipped = !flipped;
|
||||||
|
img.style.transform = `rotate(${{Math.random() * 40 - 20}}deg) scaleX(${{flipped ? -1 : 1}})`;
|
||||||
|
}}, shiftInterval);
|
||||||
|
}}
|
||||||
|
setInterval(createCandyCane, 1000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# Middleware to check authentication
|
||||||
|
@web.middleware
|
||||||
|
async def auth_middleware(request, handler):
|
||||||
|
# Allow access to invite endpoint and static resources
|
||||||
|
if request.path in ['/invite', '/candycane.png']:
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
# Check for auth cookie
|
||||||
|
auth_cookie = request.cookies.get('secretsanta_auth')
|
||||||
|
if not auth_cookie:
|
||||||
|
html = ACCESS_DENIED_HTML.format(message="You need a valid invite link to access this page.")
|
||||||
|
return web.Response(text=html, content_type='text/html', status=403)
|
||||||
|
|
||||||
|
# Verify the auth cookie is valid (exists in our tokens and is marked as used)
|
||||||
|
valid = False
|
||||||
|
for token, data in tokens.items():
|
||||||
|
if data.get('used_by_cookie') == auth_cookie:
|
||||||
|
valid = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not valid:
|
||||||
|
html = ACCESS_DENIED_HTML.format(message="Invalid or expired access token.")
|
||||||
|
return web.Response(text=html, content_type='text/html', status=403)
|
||||||
|
|
||||||
|
return await handler(request)
|
||||||
|
|
||||||
|
# Helper to get entry by id
|
||||||
|
def get_entry_by_id(entry_id):
|
||||||
|
return next((e for e in persistent if e['id'] == entry_id), None)
|
||||||
|
|
||||||
|
# Helper to get entry by code
|
||||||
|
def get_entry_by_code(code):
|
||||||
|
return next((e for e in persistent if e.get('code') == code), None)
|
||||||
|
|
||||||
|
# Routes
|
||||||
|
routes = web.RouteTableDef()
|
||||||
|
|
||||||
|
@routes.get('/invite')
|
||||||
|
async def invite(request):
|
||||||
|
token = request.query.get('token')
|
||||||
|
if not token:
|
||||||
|
html = ACCESS_DENIED_HTML.format(message="No invite token provided.")
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
token_data = tokens.get(token)
|
||||||
|
if not token_data:
|
||||||
|
html = ACCESS_DENIED_HTML.format(message="Invalid invite token.")
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
if token_data['used']:
|
||||||
|
html = ACCESS_DENIED_HTML.format(message="This invite token has already been used.")
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
# Mark token as used and create auth cookie
|
||||||
|
auth_cookie = str(uuid.uuid4())
|
||||||
|
token_data['used'] = True
|
||||||
|
token_data['used_by_cookie'] = auth_cookie
|
||||||
|
save_tokens()
|
||||||
|
|
||||||
|
# Redirect to main page with auth cookie
|
||||||
|
resp = web.Response(status=302)
|
||||||
|
resp.headers['Location'] = '/'
|
||||||
|
resp.set_cookie('secretsanta_auth', auth_cookie, max_age=3600*24*30) # 30 days
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@routes.get('/')
|
||||||
|
async def index(request):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
message = ""
|
||||||
|
cookie_code = request.cookies.get('secretsanta_code')
|
||||||
|
if cookie_code:
|
||||||
|
entry = get_entry_by_code(cookie_code)
|
||||||
|
if entry:
|
||||||
|
message = "<p>You've already joined! Use the check form below with your code to see status.</p>"
|
||||||
|
html = HTML.format(
|
||||||
|
signup_deadline=SIGNUP_DEADLINE.isoformat(),
|
||||||
|
drawing_deadline=DRAWING_DEADLINE.isoformat(),
|
||||||
|
message=message
|
||||||
|
)
|
||||||
|
resp = web.Response(text=html, content_type='text/html')
|
||||||
|
return resp
|
||||||
|
|
||||||
|
@routes.get('/candycane.png')
|
||||||
|
async def serve_candycane(request): # actually ncd icon
|
||||||
|
if os.path.exists('ncd.png'):
|
||||||
|
with open('ncd.png', 'rb') as f:
|
||||||
|
data = f.read()
|
||||||
|
return web.Response(body=data, content_type='image/png')
|
||||||
|
return web.Response(status=404)
|
||||||
|
|
||||||
|
@routes.post('/submit')
|
||||||
|
async def submit(request):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now > SIGNUP_DEADLINE:
|
||||||
|
message = "<p>Signup period is over!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
data = await request.post()
|
||||||
|
if not data.get('address'):
|
||||||
|
message = "<p>Address is required!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
if not data.get('fedi') and not data.get('email'):
|
||||||
|
message = "<p>Either Fedi handle or email is required for contact!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
if not data.get('agreement'):
|
||||||
|
message = "<p>You must agree to the terms!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
# Check for existing via cookie
|
||||||
|
cookie_code = request.cookies.get('secretsanta_code')
|
||||||
|
if cookie_code and get_entry_by_code(cookie_code):
|
||||||
|
message = "<p>You've already submitted! Check your status below.</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
entry_id = str(uuid.uuid4())[:8] # First 8 chars of UUID
|
||||||
|
code = str(uuid.uuid4())[:8] # Short code too
|
||||||
|
dollar_limit = data.get('dollar_limit') or DEFAULT_DOLLAR_LIMIT
|
||||||
|
try:
|
||||||
|
dollar_limit = float(dollar_limit)
|
||||||
|
except ValueError:
|
||||||
|
dollar_limit = DEFAULT_DOLLAR_LIMIT
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
'id': entry_id,
|
||||||
|
'name': data.get('name', ''),
|
||||||
|
'fedi': data.get('fedi', ''),
|
||||||
|
'email': data.get('email', ''),
|
||||||
|
'dollar_limit': dollar_limit,
|
||||||
|
'likes_dislikes': data.get('likes_dislikes', ''),
|
||||||
|
'address': data.get('address'),
|
||||||
|
'submission_time': now.isoformat(),
|
||||||
|
'code': code,
|
||||||
|
'assigned_receiver': None,
|
||||||
|
'assigned_by': None
|
||||||
|
}
|
||||||
|
persistent.append(entry)
|
||||||
|
pool.append(entry_id)
|
||||||
|
save_data()
|
||||||
|
print(f'Created Entry:\n{entry}\n\n')
|
||||||
|
|
||||||
|
# Attempt immediate assignment if pool >=3 (including this one)
|
||||||
|
assignment_msg = await try_assign(entry)
|
||||||
|
message = f"<p>Thanks for joining! Your one-time code: <strong>{code}</strong> (save it!)</p>{assignment_msg}<p>If not assigned, check back later with your code.</p>"
|
||||||
|
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
resp = web.Response(text=html, content_type='text/html')
|
||||||
|
resp.set_cookie('secretsanta_code', code, max_age=3600*24*30) # 30 days
|
||||||
|
return resp
|
||||||
|
|
||||||
|
async def try_assign(entry):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
if now > DRAWING_DEADLINE:
|
||||||
|
return "<p>Drawing period is over!</p>"
|
||||||
|
|
||||||
|
submission_time = datetime.fromisoformat(entry['submission_time'])
|
||||||
|
time_in_pool = (now - submission_time).total_seconds() / 3600
|
||||||
|
|
||||||
|
if entry['assigned_receiver'] is not None:
|
||||||
|
receiver = get_entry_by_id(entry['assigned_receiver'])
|
||||||
|
if receiver:
|
||||||
|
return f"<p>You've already been assigned! Send to: Name: {receiver['name']}, Address: {receiver['address']}, Limit: ${receiver['dollar_limit']}, Likes/Dislikes: {receiver['likes_dislikes']}</p>"
|
||||||
|
|
||||||
|
available = [eid for eid in pool if eid != entry['id']]
|
||||||
|
if not available:
|
||||||
|
return "<p>Pool is empty right now (or only you left). Check back later!</p>"
|
||||||
|
|
||||||
|
if len(pool) < 3 and time_in_pool <= COUPLE_HOURS:
|
||||||
|
return "<p>Pool is too small (<3 participants). Please check back in a couple hours.</p>"
|
||||||
|
|
||||||
|
# Assign random
|
||||||
|
receiver_id = random.choice(available)
|
||||||
|
receiver_entry = get_entry_by_id(receiver_id)
|
||||||
|
receiver_entry['assigned_by'] = entry['id']
|
||||||
|
entry['assigned_receiver'] = receiver_id
|
||||||
|
pool.remove(receiver_id)
|
||||||
|
save_data()
|
||||||
|
print(f'Assigned id {receiver_id}')
|
||||||
|
|
||||||
|
return f"<p>Assigned! Send a gift to: Name: {receiver_entry['name']}, Address: {receiver_entry['address']}, Limit: ${receiver_entry['dollar_limit']}, Likes/Dislikes: {receiver_entry['likes_dislikes']}</p>"
|
||||||
|
|
||||||
|
@routes.post('/check')
|
||||||
|
async def check(request):
|
||||||
|
now = datetime.utcnow()
|
||||||
|
data = await request.post()
|
||||||
|
code = data.get('code')
|
||||||
|
if not code:
|
||||||
|
message = "<p>Code required!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
entry = get_entry_by_code(code)
|
||||||
|
if not entry:
|
||||||
|
message = "<p>Invalid code!</p>"
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
return web.Response(text=html, content_type='text/html', status=400)
|
||||||
|
|
||||||
|
assignment_msg = await try_assign(entry)
|
||||||
|
message = f"<p>Your code is valid. Status: {assignment_msg}</p>"
|
||||||
|
|
||||||
|
html = SUCCESS_HTML.format(message=message)
|
||||||
|
resp = web.Response(text=html, content_type='text/html')
|
||||||
|
resp.set_cookie('secretsanta_code', code, max_age=3600*24*30)
|
||||||
|
return resp
|
||||||
|
|
||||||
|
# App setup
|
||||||
|
app = web.Application(middlewares=[auth_middleware])
|
||||||
|
app.add_routes(routes)
|
||||||
|
|
||||||
|
|
||||||
|
async def start_background_tasks(app):
|
||||||
|
app['background_task'] = asyncio.create_task(background_checker())
|
||||||
|
|
||||||
|
app.on_startup.append(start_background_tasks)
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
web.run_app(app, port=8080)
|
Reference in New Issue
Block a user