Simple cache debouncer, better logging, multi-room check optimizations

Added a simple cache refresh debouncer
Made the update_cache method return a boolean if the cache needed to update
Moved logging info to Sanic.log.logger
Made the admin check into a middleware
Added room check as a standalone action in the admin panel
Added a counter in the nose count for admins
This commit is contained in:
Andrea 2024-01-22 00:35:44 +01:00
parent a154c3059b
commit a2b9f7a7c1
20 changed files with 228 additions and 207 deletions

View File

@ -3,39 +3,34 @@ from room import unconfirm_room_by_order
from config import *
from utils import *
from ext import *
import sqlite3
import smtplib
import random
import string
import httpx
import json
from sanic.log import logger
bp = Blueprint("admin", url_prefix="/manage/admin")
def credentials_check(request, order:Order):
@bp.middleware
async def credentials_check(request: Request):
order = await get_order(request)
if not order:
raise exceptions.Forbidden("You have been logged out. Please access the link in your E-Mail to login again!")
if EXTRA_PRINTS:
print(f"Checking admin credentials of {order.code} with secret {order.secret}")
logger.info(f"Checking admin credentials of {order.code} with secret {order.secret}")
if not order.isAdmin() : raise exceptions.Forbidden("Birichino :)")
@bp.get('/cache/clear')
async def clear_cache(request, order:Order):
credentials_check(request, order)
await request.app.ctx.om.fill_cache()
return redirect(f'/manage/admin')
@bp.get('/loginas/<code>')
async def login_as(request, code, order:Order):
credentials_check(request, order)
dOrder = await get_order_by_code(request, code, throwException=True)
if(dOrder.isAdmin()):
raise exceptions.Forbidden("You can't login as another admin!")
if EXTRA_PRINTS:
print(f"Swapping login: {order.secret} {order.code} -> {dOrder.secret} {code}")
logger.info(f"Swapping login: {order.secret} {order.code} -> {dOrder.secret} {code}")
r = redirect(f'/manage/welcome')
r.cookies['foxo_code_ORG'] = order.code
r.cookies['foxo_secret_ORG'] = order.secret
@ -43,19 +38,25 @@ async def login_as(request, code, order:Order):
r.cookies['foxo_secret'] = dOrder.secret
return r
@bp.get('/room/verify')
async def verify_rooms(request, order:Order):
already_checked = await request.app.ctx.om.update_cache()
if not already_checked:
orders = filter(lambda x: x.status not in ['c', 'e'] and x.room_id == x.code, request.app.ctx.om.cache.values())
await validate_rooms(request, orders, None)
return redirect(f'/manage/admin')
@bp.get('/room/unconfirm/<code>')
async def unconfirm_room(request, code, order:Order):
credentials_check(request, order)
dOrder = await get_order_by_code(request, code, throwException=True)
unconfirm_room_by_order(dOrder, True, request)
await unconfirm_room_by_order(order=dOrder, throw=True, request=request)
return redirect(f'/manage/nosecount')
@bp.get('/room/delete/<code>')
async def delete_room(request, code, order:Order):
credentials_check(request, order)
dOrder = await get_order_by_code(request, code, throwException=True)
ppl = get_people_in_room_by_code(request, code)
ppl = await get_people_in_room_by_code(request, code)
for p in ppl:
await p.edit_answer('room_id', None)
await p.edit_answer('room_confirmed', "False")
@ -72,7 +73,6 @@ async def delete_room(request, code, order:Order):
@bp.post('/room/rename/<code>')
async def rename_room(request, code, order:Order):
credentials_check(request, order)
dOrder = await get_order_by_code(request, code, throwException=True)
name = request.form.get('name')

4
api.py
View File

@ -9,6 +9,7 @@ import random
import string
import httpx
import json
from sanic.log import logger
bp = Blueprint("api", url_prefix="/manage/api")
@ -185,7 +186,8 @@ async def nfc_scan(request, nfc_id):
@bp.get("/get_token/<code>/<login_code>")
async def get_token_from_code(request, code, login_code):
if not code in request.app.ctx.login_codes:
print(request.app.ctx.login_codes)
if DEV_MODE and EXTRA_PRINTS:
logger.debug(request.app.ctx.login_codes)
return response.json({'ok': False, 'error': 'You need to reauthenticate. The code has expired.'}, status=401)
if request.app.ctx.login_codes[code][1] == 0:

26
app.py
View File

@ -3,9 +3,6 @@ from sanic.response import text, html, redirect, raw
from jinja2 import Environment, FileSystemLoader
from time import time
import httpx
import re
import json
import logging
from os.path import join
from ext import *
from config import *
@ -14,8 +11,7 @@ from propic import resetDefaultPropic
from io import BytesIO
from asyncio import Queue
import sqlite3
log = logging.getLogger()
from sanic.log import logger
app = Sanic(__name__)
app.static("/res", "res/")
@ -47,15 +43,13 @@ async def clear_session(request, exception):
@app.before_server_start
async def main_start(*_):
print(">>>>>> main_start <<<<<<")
logger.info(f"[{app.name}] >>>>>> main_start <<<<<<")
app.config.REQUEST_MAX_SIZE = PROPIC_MAX_FILE_SIZE * 3
app.ctx.om = OrderManager()
if FILL_CACHE:
log.info("Filling cache!")
await app.ctx.om.updateCache()
log.info("Cache fill done!")
await app.ctx.om.update_cache()
app.ctx.nfc_counts = sqlite3.connect('data/nfc_counts.db')
@ -90,7 +84,7 @@ async def redirect_explore(request, code, secret, order: Order, secret2=None):
if not order:
async with httpx.AsyncClient() as client:
res = await client.get(join(base_url_event, f"orders/{code}/"), headers=headers)
print(res.json())
if res.status_code != 200:
raise exceptions.NotFound("This order code does not exist. Check that your order wasn't deleted, or the link is correct.")
@ -137,9 +131,7 @@ async def welcome(request, order: Order, quota: Quotas):
if member_id == order.code:
room_members.append(order)
else:
room_members.append(await app.ctx.om.get_order(code=member_id, cached=True))
print (order.room_errors)
room_members.append(await app.ctx.om.get_order(code=member_id, cached=True))
tpl = app.ctx.tpl.get_template('welcome.html')
return html(tpl.render(order=order, quota=quota, room_members=room_members, pending_roommates=pending_roommates, ROOM_ERROR_MESSAGES=ROOM_ERROR_TYPES))
@ -166,17 +158,17 @@ async def download_ticket(request, order: Order):
@app.route("/manage/admin")
async def admin(request, order: Order):
await request.app.ctx.om.updateCache()
await request.app.ctx.om.update_cache()
if not order:
raise exceptions.Forbidden("You have been logged out. Please access the link in your E-Mail to login again!")
if EXTRA_PRINTS:
print(f"Checking admin credentials of {order.code} with secret {order.secret}")
logger.info(f"Checking admin credentials of {order.code} with secret {order.secret}")
if not order.isAdmin(): raise exceptions.Forbidden("Birichino :)")
tpl = app.ctx.tpl.get_template('admin.html')
return html(tpl.render(order=order))
@app.route("/manage/logout")
async def logour(request):
async def logout(request):
orgCode = request.cookies.get("foxo_code_ORG")
orgSecret = request.cookies.get("foxo_secret_ORG")
if orgCode != None and orgSecret != None:
@ -185,8 +177,6 @@ async def logour(request):
r.cookies['foxo_secret'] = orgSecret
r.delete_cookie("foxo_code_ORG")
r.delete_cookie("foxo_secret_ORG")
del r.cookies['foxo_code_ORG']
del r.cookies['foxo_secret_ORG']
return r
raise exceptions.Forbidden("You have been logged out.")

View File

@ -10,7 +10,6 @@ async def carpooling_list(request, order: Order, error=None):
if not order: raise exceptions.Forbidden("You have been logged out. Please access the link in your E-Mail to login again!")
orders = [value for value in request.app.ctx.om.cache.values() if value.status not in ['c', 'e'] and value.carpooling_message]
print(orders)
tpl = request.app.ctx.tpl.get_template('carpooling.html')

View File

@ -12,6 +12,20 @@ PROPIC_MAX_FILE_SIZE = 5 * 1024 * 1024 # 5MB
PROPIC_MAX_SIZE = (2048, 2048) # (Width, Height)
PROPIC_MIN_SIZE = (125, 125) # (Width, Height)
# This is used for feedback sending inside of the app. Feedbacks will be sent to the specified chat using the bot api id.
TG_BOT_API = '123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
TG_CHAT_ID = -1234567
# These order codes have additional functions.
ADMINS = ['XXXXX', 'YYYYY']
# A list of staff_roles
ADMINS_PRETIX_ROLE_NAMES = ["Reserved Area admin", "main staff"]
SMTP_HOST = 'host'
SMTP_PORT = 0
SMTP_USER = 'user'
SMTP_PASSWORD = 'pw'
FILL_CACHE = True
CACHE_EXPIRE_TIME = 60 * 60 * 4
@ -24,6 +38,11 @@ METADATA_NAME = "item_name"
# Metadata property for internal category mapping (not related to pretix's category)
METADATA_CATEGORY = "category_name"
SPONSORSHIP_COLOR_MAP = {
'super': (251, 140, 0),
'normal': (142, 36, 170)
}
# Maps Products metadata name <--> ID
ITEMS_ID_MAP = {
'early_bird_ticket': 126,
@ -107,16 +126,4 @@ ROOM_CAPACITY_MAP = {
}
# Autofilled
ROOM_TYPE_NAMES = { }
# This is used for feedback sending inside of the app. Feedbacks will be sent to the specified chat using the bot api id.
TG_BOT_API = '123456789:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA'
TG_CHAT_ID = -1234567
# These order codes have additional functions.
ADMINS = ['XXXXX', 'YYYYY']
SMTP_HOST = 'host'
SMTP_PORT = 0
SMTP_USER = 'user'
SMTP_PASSWORD = 'pw'
ROOM_TYPE_NAMES = { }

View File

@ -8,13 +8,11 @@ from ext import *
from config import *
from sanic import response
from sanic import Blueprint
import logging
log = logging.getLogger()
from sanic.log import logger
def checkConfig():
if (not DEV_MODE) and DUMMY_DATA:
log.warn('It is strongly unadvised to use dummy data in production')
logger.warn('It is strongly unadvised to use dummy data in production')
def getOrders(page):
return None

View File

@ -1,25 +1,27 @@
from sanic import Sanic
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from messages import ROOM_ERROR_TYPES
import smtplib
from messages import *
from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD, SMTP_ADMIN_PASS
from config import SMTP_HOST, SMTP_PORT, SMTP_USER, SMTP_PASSWORD
from jinja2 import Environment, FileSystemLoader
def send_unconfirm_message (room_order, room_errors, orders, title):
async def send_unconfirm_message (room_order, orders):
memberMessages = []
issues_plain = ""
issues_html = "<ul>"
for err in room_errors:
for err in room_order.room_errors:
if err in ROOM_ERROR_TYPES.keys():
issues_plain += f"{ROOM_ERROR_TYPES[err]}"
issues_plain += f"{ROOM_ERROR_TYPES[err]}\n"
issues_html += f"<li>{ROOM_ERROR_TYPES[err]}</li>"
issues_html += "</ul>"
for member in orders:
plain_body = ROOM_UNCONFIRM_TEXT['plain'].format(member.name, room_order.room_name, issues_plain)
html_body = ROOM_UNCONFIRM_TEXT['html'].format(member.name, room_order.room_name, issues_html)
html_body = render_email_template(ROOM_UNCONFIRM_TITLE, ROOM_UNCONFIRM_TEXT['html'].format(member.name, room_order.room_name, issues_html))
plain_text = MIMEText(plain_body, "plain")
html_text = MIMEText(html_body, "html")
message = MIMEMultipart("alternative")
@ -32,6 +34,11 @@ def send_unconfirm_message (room_order, room_errors, orders, title):
if len(memberMessages) == 0: return
with smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT) as sender:
sender.login(SMTP_USER, SMTP_PASSWORD)
for message in memberMessages:
sender.sendmail(message['From'], message['to'], message.as_string())
def render_email_template(title = "", body = ""):
tpl = app.ctx.tpl.get_template('email/comunication.html')
tpl = Environment(loader=FileSystemLoader("tpl"), autoescape=False).get_template('email/comunication.html')
return str(tpl.render(title=title, body=body))

83
ext.py
View File

@ -1,12 +1,14 @@
from dataclasses import dataclass
from sanic import Request, exceptions
from sanic import Request, exceptions, Sanic
import httpx
import re
from utils import *
from config import *
from os.path import join
import json
from sanic.log import logger
from time import time
import asyncio
@dataclass
class Order:
@ -169,11 +171,11 @@ class Order:
for key in range(len(self.answers)):
if self.answers[key].get('question_identifier', None) == name:
if new_answer != None:
print('EXISTING ANSWER UPDATE', name, '=>', new_answer)
if DEV_MODE and EXTRA_PRINTS: logger.debug('[ANSWER EDIT] EXISTING ANSWER UPDATE %s => %s', name, new_answer)
self.answers[key]['answer'] = new_answer
found = True
else:
print('DEL ANSWER', name, '=>', new_answer)
if DEV_MODE and EXTRA_PRINTS: logger.debug('[ANSWER EDIT] DEL ANSWER %s => %s', name, new_answer)
del self.answers[key]
break
@ -186,7 +188,7 @@ class Order:
for r in res['results']:
if r['identifier'] != name: continue
print('ANSWER UPDATE', name, '=>', new_answer)
if DEV_MODE and EXTRA_PRINTS: logger.debug(f'[ANSWER EDIT] %s => %s', name, new_answer)
self.answers.append({
'question': r['id'],
'answer': new_answer,
@ -196,7 +198,7 @@ class Order:
async def send_answers(self):
async with httpx.AsyncClient() as client:
print("POSITION ID IS", self.position_id)
if DEV_MODE and EXTRA_PRINTS: logger.debug("[ANSWER POST] POSITION ID IS %s", self.position_id)
for i, ans in enumerate(self.answers):
if TYPE_OF_QUESTIONS[ans['question']] == QUESTION_TYPES["multiple_choice_from_list"]: # if multiple choice
@ -213,7 +215,7 @@ class Order:
if res.status_code != 200:
for ans, err in zip(self.answers, res.json()['answers']):
if err:
print('ERROR ON', ans, err)
logger.error ('[ANSWERS SENDING] ERROR ON', ans, err)
raise exceptions.ServerError('There has been an error while updating this answers.')
@ -251,18 +253,21 @@ async def get_order(request: Request=None):
class OrderManager:
def __init__(self):
self.lastCacheUpdate = 0
self.updating = False
self.empty()
def empty(self):
self.cache = {}
self.order_list = []
async def updateCache(self):
# Will fill cache once the last cache update is greater than cache expire time
async def update_cache(self):
t = time()
if(t - self.lastCacheUpdate > CACHE_EXPIRE_TIME):
print("[TIME] Re-filling cache!")
to_return = False
if(t - self.lastCacheUpdate > CACHE_EXPIRE_TIME and not self.updating):
to_return = True
await self.fill_cache()
self.lastCacheUpdate = t
return to_return
def add_cache(self, order):
self.cache[order.code] = order
@ -275,30 +280,44 @@ class OrderManager:
self.order_list.remove(code)
async def fill_cache(self):
# Check cache lock
if self.updating == True: return
# Set cache lock
self.updating = True
start_time = time()
logger.info("[CACHE] Filling cache...")
# Index item's ids
await load_items()
# Index questions' types
await load_questions()
# Clear cache data completely
self.empty()
p = 0
async with httpx.AsyncClient() as client:
while 1:
p += 1
res = await client.get(join(base_url_event, f"orders/?page={p}"), headers=headers)
if res.status_code == 404: break
data = res.json()
for o in data['results']:
o = Order(o)
if o.status in ['canceled', 'expired']:
self.remove_cache(o.code)
else:
self.add_cache(Order(o))
self.lastCacheUpdate = time()
for o in self.cache.values():
if o.code == o.room_id:
print(o.room_name)
await validate_room(None, o, self)
try:
async with httpx.AsyncClient() as client:
while 1:
p += 1
res = await client.get(join(base_url_event, f"orders/?page={p}"), headers=headers)
if res.status_code == 404: break
# Parse order data
data = res.json()
for o in data['results']:
o = Order(o)
if o.status in ['canceled', 'expired']:
self.remove_cache(o.code)
else:
self.add_cache(Order(o))
self.lastCacheUpdate = time()
logger.info(f"[CACHE] Cache filled in {self.lastCacheUpdate - start_time}s.")
except Exception as ex:
logger.error("[CACHE] Error while refreshing cache.", ex)
finally:
self.updating = False
# Validating rooms
rooms = list(filter(lambda o: (o.code == o.room_id), self.cache.values()))
asyncio.create_task(validate_rooms(None, rooms, self))
async def get_order(self, request=None, code=None, secret=None, nfc_id=None, cached=False):
@ -308,7 +327,7 @@ class OrderManager:
if order.nfc_id == nfc_id:
return order
await self.updateCache()
await self.update_cache()
# If a cached order is needed, just get it if available
if code and cached and code in self.cache and time()-self.cache[code].time < 3600:
return self.cache[code]
@ -319,7 +338,7 @@ class OrderManager:
secret = request.cookies.get("foxo_secret")
if re.match('^[A-Z0-9]{5}$', code or '') and (secret is None or re.match('^[a-z0-9]{16,}$', secret)):
print('Fetching', code, 'with secret', secret)
if DEV_MODE and EXTRA_PRINTS: logger.debug(f'Fetching {code} with secret {secret}')
async with httpx.AsyncClient() as client:
res = await client.get(join(base_url_event, f"orders/{code}/"), headers=headers)

View File

@ -1,7 +1,7 @@
from config import *
from PIL import Image, ImageDraw, ImageFont
from sanic import Blueprint, exceptions
import textwrap
from sanic.log import logger
jobs = []
@ -31,6 +31,7 @@ def draw_profile (source, member, position, font, size=(170, 170), border_width=
idraw.text(name_loc, str(member['name']), font=font, fill=name_color)
async def generate_room_preview(request, code, room_data):
if code in jobs: raise exceptions.SanicException("Please try again later!", status_code=409)
font_path = f'res/font/NotoSans-Bold.ttf'
main_fill = (187, 198, 206)
propic_size = (170, 170)
@ -67,7 +68,7 @@ async def generate_room_preview(request, code, room_data):
draw_profile(source, member, (propic_gap + (propic_total_width * m), 63), font, propic_size, border_width)
source.save(f'res/rooms/{code}.jpg', 'JPEG', quality=60)
except Exception as err:
if EXTRA_PRINTS: print(err)
if EXTRA_PRINTS: logger.exception(str(err))
finally:
# Remove fault job
if len(jobs) > 0: jobs.pop()

View File

@ -8,6 +8,6 @@ ROOM_ERROR_TYPES = {
ROOM_UNCONFIRM_TITLE = "Your room got unconfirmed"
ROOM_UNCONFIRM_TEXT = {
'html': "Hello <b>{0}</b><br>We had to unconfirm your room '{1}' due to the following problem/s:<br>{2}<br>Please contact your room's owner or contact our support for further informations at <a href=\"https://furizon.net/contact/\"> https://furizon.net/contact/</a>.<br>Thank you",
'plain': "Hello {0}\nWe had to unconfirm your room '{1}' due to the following problem/s:\n{2}\nPlease contact your room's owner or contact our support for further informations at https://furizon.net/contact/.\nThank you"
'html': "Hello <b>{0}</b><br>We had to <b>unconfirm</b> your room <i>'{1}'</i> due to the following issues:<br></p>{2}<br><p>Please contact your room's owner or contact our support for further informations at <a href=\"https://furizon.net/contact/\"> https://furizon.net/contact/</a>.<br>Thank you.<br><br><a class=\"link\" style=\"background-color: #1095c1; color: #fff;\" href=\"https://reg.furizon.net/manage/welcome\">Manage booking</a>",
'plain': "Hello {0}\nWe had to unconfirm your room '{1}' due to the following issues:\n{2}\nPlease contact your room's owner or contact our support for further informations at https://furizon.net/contact/.\nThank you\n\nTo manage your booking: https://reg.furizon.net/manage/welcome"
}

View File

@ -13,7 +13,7 @@ bp = Blueprint("propic", url_prefix="/manage/propic")
async def resetDefaultPropic(request, order: Order, isFursuiter, sendAnswer=True):
s = "_fursuiter" if isFursuiter else ""
if (EXTRA_PRINTS):
print("Resetting {fn} picture for {orderCod}".format(fn="Badge" if not isFursuiter else "fursuit", orderCod = order.code))
logger.info("Resetting {fn} picture for {orderCod}".format(fn="Badge" if not isFursuiter else "fursuit", orderCod = order.code))
with open("res/propic/default.png", "rb") as f:
data = f.read()
f.close()
@ -56,7 +56,7 @@ async def upload_propic(request, order: Order):
# Check max file size
if EXTRA_PRINTS:
print(f"Image {fn} weight: {len(body[0].body)} bytes")
logger.debug (f"Image {fn} weight: {len(body[0].body)} bytes")
if len(body[0].body) > PROPIC_MAX_FILE_SIZE:
raise exceptions.BadRequest("File size too large for " + ("Profile picture" if fn == 'propic' else 'Fursuit picture'))

View File

@ -1,4 +1,5 @@
div.room-actions {
container-name: room-actions;
float: right;
}

10
room.py
View File

@ -204,8 +204,8 @@ async def approve_roomreq(request, code, order: Order):
await order.edit_answer('pending_roommates', (','.join([x for x in order.pending_roommates if x != pending_member.code]) or None))
await pending_member.send_answers()
await order.send_answers(order.code)
remove_room_preview()
await order.send_answers()
remove_room_preview(order.code)
return redirect('/manage/welcome')
@bp.route("/leave")
@ -363,14 +363,14 @@ def remove_room_preview(code):
try:
if os.path.exists(preview_file): os.remove(preview_file)
except Exception as ex:
if (EXTRA_PRINTS): print(ex)
if (EXTRA_PRINTS): logger.exception(str(ex))
@bp.route("/view/<code>")
async def get_view(request, code):
room_file_name = f"res/rooms/{code}.jpg"
room_data = await get_room(request, code)
if not os.path.exists(room_file_name) and code not in jobs:
if not room_data: raise exceptions.NotFound("No room was found with that code.")
if not os.path.exists(room_file_name):
await generate_room_preview(request, code, room_data)
tpl = request.app.ctx.tpl.get_template('view_room.html')
return html(tpl.render(preview=room_file_name, room_data=room_data))

View File

@ -6,7 +6,7 @@ bp = Blueprint("stats", url_prefix="/manage")
@bp.route("/sponsorcount")
async def sponsor_count(request, order: Order):
await request.app.ctx.om.updateCache()
await request.app.ctx.om.update_cache()
orders = {key:value for key,value in sorted(request.app.ctx.om.cache.items(), key=lambda x: x[1].ans('fursona_name')) if value.status not in ['c', 'e']}
tpl = request.app.ctx.tpl.get_template('sponsorcount.html')
@ -14,7 +14,7 @@ async def sponsor_count(request, order: Order):
@bp.route("/nosecount")
async def nose_count(request, order: Order):
await request.app.ctx.om.updateCache()
await request.app.ctx.om.update_cache()
orders = {key:value for key,value in sorted(request.app.ctx.om.cache.items(), key=lambda x: len(x[1].room_members), reverse=True) if value.status not in ['c', 'e']}
tpl = request.app.ctx.tpl.get_template('nosecount.html')
@ -22,7 +22,7 @@ async def nose_count(request, order: Order):
@bp.route("/fursuitcount")
async def fursuit_count(request, order: Order):
await request.app.ctx.om.updateCache()
await request.app.ctx.om.update_cache()
orders = {key:value for key,value in sorted(request.app.ctx.om.cache.items(), key=lambda x: x[1].ans('fursona_name')) if value.status not in ['c', 'e']}
tpl = request.app.ctx.tpl.get_template('fursuitcount.html')

View File

@ -12,6 +12,7 @@
<h2>Admin panel</h2>
<a href="/manage/admin/cache/clear" role="button" title="Reload the orders' data and the re-sync items' indexes from pretix">Clear cache</a>
<a href="/manage/nosecount" role="button" title="Shortcut to the nosecount's admin data">Manage rooms</a>
<a href="/manage/admin/room/verify" role="button" title="Will unconfirm rooms that fail the default check. Useful when editing answers from Pretix">Verify Rooms</a>
<hr>
</main>

View File

@ -14,16 +14,6 @@
{% if order.room_id and not order.room_confirmed %}
<p class="notice">⚠️ Your room hasn't been confirmed yet. Unconfirmed rooms are subject to changes by the staff as we optimize for hotel capacity.</p>
{% endif %}
{{len(order.room_errors)}}
{% if order.room_id and len(order.room_errors) > 0 %}
<p class="notice">⚠️ There are some issues with your room:
<ul>
{% for issue in issues %}
<li>{{ROOM_ERROR_MESSAGES[issue]}}</li>
{% endfor %}
</ul>
</p>
{% endif %}
{# Show notice if the room is confirmed #}
{% if order.room_confirmed %}
@ -94,6 +84,8 @@
<a style="grid-area: 1 / 1 / 2 / 2;" role="button" href="javascript:document.getElementById('modal-roomrename').setAttribute('open', 'true');">Rename room</a>
<a style="grid-area: 1 / 2 / 2 / 3;" href="/manage/room/delete" role="button" {{'disabled' if (len(room_members) > 1) else ''}} >Delete room</a>
<a style="grid-area: 2 / 1 / 3 / 3; display:block;" role="button" {% if not room.forbidden and len(room_members) == order.room_person_no %}href="javascript:document.getElementById('modal-roomconfirm').setAttribute('open', 'true');"{% endif %}>Confirm <strong>{{[None,'single','double','triple','quadruple','quintuple'][order.room_person_no]}}</strong> room</a>
{% else %}
{# <a style="grid-area: 1 / 1 / 2 / 2;" role="button" href="javascript:navigator.share({title: 'Furizon room', text:'Viewing room {{order.room_name}}', url: `${window.location.protocol}//${window.location.host}/manage/room/view/{{order.code}}}`});">Share</a> #}
{% endif %}
{% else %}
{% if order.room_id and not order.room_confirmed %}

View File

@ -1,40 +1,26 @@
<!DOCTYPE html>
<html lang="en">
<!--By Drew tha woof-->
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
<meta name="supported-color-schemes" content="light dark">
<title>Simple Transactional Email</title>
<style media="all" type="text/css">
:root{
--main-background: #fff; --h2-color: #24333e; --color: hsl(205deg, 20%, 32%);
--font-family: system-ui,-apple-system,"Segoe UI","Roboto","Ubuntu","Cantarell","Noto Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";
}
*, ::after, ::before { box-sizing:border-box; background-repeat:no-repeat; }
body { width: 100%; margin: 0; background-color: var(--main-background); }
.container { max-width: 40em; padding: 1em; box-sizing: border-box; margin: 0 auto; }
h2 { --font-size: 1.75rem; --typography-spacing-vertical: 2.625rem; color: var(--h2-color) }
h1, h2, h3, h4, h5, h6 { margin-top: 0; margin-bottom: var(--typography-spacing-vertical); font-size: var(--font-size); font-family: var(--font-family);}
address, blockquote, dl, figure, form, ol, p, pre, table, ul {margin-top: 0; color: var(--color); font-style: normal; font-weight: 400; font-family: var(--font-family);}
img.con-logo {content: url('data:image/png;base64,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');}
/* Dark theme */
@media only screen and (prefers-color-scheme: dark) {
:root{ --main-background: #11191f; --h2-color: #e1e6eb; --color: hsl(205deg, 16%, 77%);}
.icon {filter: invert(1);}
img.con-logo { content: url('data:image/png;base64,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'); }
}
* { color: #bbc6ce; }
.body { width: 100%; margin: 0; background-color: #11191f; }
.container { max-width: 40em; padding: 1em; margin: 0 auto; }
.title { font-size: 1.75em; margin-bottom: 1.2em; color: #e1e6eb; margin-top: 0; font-family: sans-serif; }
.main-content { margin-top: 0; font-style: normal; font-weight: 400; font-family: sans-serif;}
.con-logo { height:2em;}
.link { text-decoration: none; background-color: #1095c1; color: #fff; padding: 1em; border-radius: 5px; font-weight: 600; }
</style>
</head>
</html>
<main class="container">
<header>
<picture style="display: inline-flex; justify-content: center;">
<img class="con-logo" style="height:2rem;text-align:center;">
</picture>
</header>
<article>
<h2>{{title}}</h2>
<p>{{body}}</p>
</article>
</main>
<div class="body">
<div class="container">
<img src="https://reg.furizon.net/res/furizon.png" class="con-logo">
<div>
<h2 class="title">{{title}}</h2>
<p class="main-content">{{body}}</p>
</div>
</div>
</div>
</html>

View File

@ -20,8 +20,11 @@
</picture>
</header>
<p>Welcome to the nosecount page! Here you can see all of the available rooms at the convention, as well as the occupants currently staying in each room. Use this page to find your friends and plan your meet-ups.</p>
{% for o in orders.values() %}
{% if o.code == o.room_id and o.room_confirmed %}
{% for o in orders.values() if (o.code == o.room_id and o.room_confirmed) %}
{% if loop.first %}
<hr />
<h1>Confirmed rooms{% if order and order.isAdmin() %}<span> ({{loop.length}})</span>{% endif %}</h1>
{% endif %}
<h4 style="margin-top:1em;">
<span>{{o.room_name}}</span>
{% if order and order.isAdmin() %}
@ -46,15 +49,14 @@
{% endif %}
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% for o in orders.values() if (o.code == o.room_id and not o.room_confirmed) %}
{% if loop.first %}
<hr />
<h1>Unconfirmed rooms</h1>
<h1>Unconfirmed rooms{% if order and order.isAdmin() %}<span> ({{loop.length}})</span>{% endif %}</h1>
<p>These unconfirmed rooms are still being organized and may be subject to change. These rooms may also have openings for additional roommates. If you are interested in sharing a room, you can use this page to find potential roommates</p>
{% endif %}
<h3>
<h4>
<span>{{o.room_name}}</span>
{% if o.room_person_no - len(o.room_members) > 0 %} <span class="nsc-room-counter"> - Remaining slots: {{o.room_person_no - len(o.room_members)}}</span> {% endif %}
{% if order and order.isAdmin() %}
@ -63,7 +65,7 @@
<a class="act-del" onclick="confirmAction('delete', this)" action="/manage/admin/room/delete/{{o.code}}"><img src="/res/icons/delete.svg" class="icon" /><span>Delete</span></a>
</div>
{% endif %}
</h3>
</h4>
<div class="grid people" style="padding-bottom:1em;">
{% for m in o.room_members %}
{% if m in orders %}
@ -84,7 +86,7 @@
{% for person in orders.values() if not person.room_id and (not person.room_confirmed) and not person.daily %}
{% if loop.first %}
<hr />
<h1>Roomless furs</h1>
<h1>Roomless furs{% if order and order.isAdmin() %}<span> ({{loop.length}})</span>{% endif %}</h1>
<p>These furs have not yet secured a room for the convention. If you see your name on this list, please make sure to secure a room before the deadline to avoid being placed in a random room. If you are looking for a roommate or have an open spot in your room, you can use this page to find and connect with other furries who are also looking for housing 🎲</p>
<div class="grid people" style="padding-bottom:1em;">
{% endif %}
@ -101,7 +103,7 @@
{% for person in orders.values() if person.daily %}
{% if loop.first %}
<hr />
<h1>Daily furs!</h1>
<h1>Daily furs!{% if order and order.isAdmin() %}<span> ({{loop.length}})</span>{% endif %}</h1>
<p>These furs will not stay in our hotels, but may be there with us just a few days!</p>
<div class="grid people" style="padding-bottom:1em;">
{% endif %}

View File

@ -16,8 +16,7 @@
<header>
<picture>
<source srcset="/res/furizon.png" media="(prefers-color-scheme:dark)">
<img src="/res/furizon-light.png" style="height:4rem;text-align:center;"
onload="window.location.href = '/manage/nosecount/'">
<img src="/res/furizon-light.png" style="height:4rem;text-align:center;" onload="window.location.href = '/manage/nosecount/'">
</picture>
</header>
</main>

115
utils.py
View File

@ -2,9 +2,9 @@ from os.path import join
from sanic import exceptions
from config import *
import httpx
from email.mime.text import MIMEText
from messages import ROOM_ERROR_TYPES
import smtplib
from email_util import send_unconfirm_message
from sanic.log import logger
METADATA_TAG = "meta_data"
VARIATIONS_TAG = "variations"
@ -77,26 +77,22 @@ async def load_items():
if not categoryName: continue
CATEGORIES_LIST_MAP[categoryName].append(q['id'])
if (EXTRA_PRINTS):
print (f'Mapped Items:')
print (ITEMS_ID_MAP)
print (f'Mapped Variations:')
print (ITEM_VARIATIONS_MAP)
print (f'Mapped categories:')
print (CATEGORIES_LIST_MAP)
print (f'Mapped Rooms:')
print (ROOM_TYPE_NAMES)
logger.debug(f'Mapped Items: %s', ITEMS_ID_MAP)
logger.debug(f'Mapped Variations: %s', ITEM_VARIATIONS_MAP)
logger.debug(f'Mapped categories: %s', CATEGORIES_LIST_MAP)
logger.debug(f'Mapped Rooms: %s', ROOM_TYPE_NAMES)
# Tries to get an item name from metadata. Prints a warning if an item has no metadata
def check_and_get_name(type, q):
itemName = extract_metadata_name(q)
if not itemName and EXTRA_PRINTS:
print (type + ' ' + q['id'] + ' has not been mapped.')
logger.warning('%s %s has not been mapped.', type, q['id'])
return itemName
def check_and_get_category (type, q):
categoryName = extract_category (q)
if not categoryName and EXTRA_PRINTS:
print (type + ' ' + q['id'] + ' has no category set.')
logger.warning('%s %s has no category set.', type, q['id'])
return categoryName
# Checks if the item has specified metadata name
@ -136,16 +132,12 @@ async def get_order_by_code(request, code, throwException=False):
raise exceptions.BadRequest(f"[getOrderByCode] Code {code} not found!")
return res
def get_people_in_room_by_code(request, code, om=None):
async def get_people_in_room_by_code(request, code, om=None):
if not om: om = request.app.ctx.om
c = om.cache
ret = []
for person in c.values():
if person.room_id == code:
ret.append(person)
return ret
await om.update_cache()
return filter(lambda rm: rm.room_id == code, om.cache.values())
async def unconfirm_room_by_order(order, throw=True, request=None, om=None):
async def unconfirm_room_by_order(order, room_members:[]=None, throw=True, request=None, om=None):
if not om: om = request.app.ctx.om
if not order.room_confirmed:
if throw:
@ -153,46 +145,70 @@ async def unconfirm_room_by_order(order, throw=True, request=None, om=None):
else:
return
ppl = get_people_in_room_by_code(request, order.code, om)
for p in ppl:
room_members = await get_people_in_room_by_code(request, order.code, om) if not room_members or len(room_members) == 0 else room_members
for p in room_members:
await p.edit_answer('room_confirmed', "False")
await p.send_answers()
async def validate_room(request, order, om):
async def validate_rooms(request, rooms, om):
logger.info('Validating rooms...')
if not om: om = request.app.ctx.om
# Validate room
check, room_errors, member_orders = await check_room(request, order, om)
if check == True: return
print(f'[ROOM VALIDATION FAILED] {order.code} has failed room validation.', room_errors)
order.room_errors = room_errors
om.add_cache (order)
failed_rooms = []
# Validate rooms
for order in rooms:
# returns tuple (room owner order, check, room error list, room members orders)
result = await check_room(request, order, om)
order = result[0]
check = result[1]
if check != None and check == False:
failed_rooms.append(result)
# End here if room is not confirmed
if not order.room_confirmed: return
# End here if no room has failed check
if len(failed_rooms) == 0:
logger.info('[ROOM VALIDATION] Every room passed the check.')
return
# Unconfirm and email users about the room
await unconfirm_room_by_order(order, False, None, om)
try:
# Build message
issues_str = ""
for err in room_errors:
if err in ROOM_ERROR_TYPES:
issues_str += f" - {ROOM_ERROR_TYPES[err]}"
logger.warning(f'[ROOM VALIDATION] Room validation failed for orders: ', list(map(lambda rf: rf[0].code, failed_rooms)))
# Get confirmed rooms that fail validation
failed_confirmed_rooms = list(filter(lambda fr: (fr[0].room_confirmed == True), failed_rooms))
s = smtplib.SMTP_SSL(SMTP_HOST, SMTP_PORT)
s.login(SMTP_USER, SMTP_PASSWORD)
for message in memberMessages:
s.sendmail(message['From'], message['to'], message.as_string())
s.quit()
except Exception as ex:
if EXTRA_PRINTS: print('could not send emails', ex)
if len(failed_confirmed_rooms) == 0:
logger.info('[ROOM VALIDATION] No rooms to unconfirm.')
return
logger.info(f"[ROOM VALIDATION] Trying to unconfirm {len(failed_confirmed_rooms)} rooms...")
# Try unconfirming them
for rtu in failed_confirmed_rooms:
order = rtu[0]
member_orders = rtu[2]
# Unconfirm and email users about the room
await unconfirm_room_by_order(order, member_orders, False, None, om)
logger.info(f"[ROOM VALIDATION] Sending unconfirm notice to room members...")
sent_count = 0
# Send unconfirm notice via email
for rtu in failed_confirmed_rooms:
order = rtu[0]
member_orders = rtu[2]
try:
await send_unconfirm_message (order, member_orders)
sent_count += len(member_orders)
except Exception as ex:
if EXTRA_PRINTS: logger.exception(str(ex))
logger.info(f"[ROOM VALIDATION] Sent {sent_count} emails")
async def check_room(request, order, om=None):
room_errors = []
room_members = []
use_cached = request == None
if not om: om = request.app.ctx.om
if not order or not order.room_id or order.room_id != order.code: return False, room_errors, room_members
if not order or not order.room_id or order.room_id != order.code: return (order, False, room_members)
# This is not needed anymore you buy tickets already
#if quotas.get_left(len(order.room_members)) == 0:
@ -203,7 +219,7 @@ async def check_room(request, order, om=None):
if m == order.code:
res = order
else:
res = await om.get_order(code=m)
res = await om.get_order(code=m, cached=use_cached)
# Room user in another room
if res.room_id != order.code:
@ -222,4 +238,5 @@ async def check_room(request, order, om=None):
if len(room_members) != order.room_person_no and order.room_person_no != None:
room_errors.append('capacity_mismatch')
return len(room_errors) == 0, room_errors, room_members
order.set_room_errors(room_errors)
return (order, len(room_errors) == 0, room_members)