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