From 4a393bb3f25afcec6fa9bb8e07453ee4b7c18448 Mon Sep 17 00:00:00 2001 From: bardsley Date: Tue, 29 Oct 2024 23:48:19 +0000 Subject: [PATCH] Feature/meal info (#209) * init commit of meal endpoints * meal reminder endpoint * email template typos * add to serverless * function outline * dump meal group data * meal summary lambda * remove debug statement * change get to post * add attendees table name * Adding a trigger Meal reminder button * log meal reminders * Remove debug * add env variable * import time * Missed empty var --------- Co-authored-by: Connor Monaghan --- app/admin/meal/meal-client.tsx | 27 +++++ app/admin/meal/page.tsx | 29 ++++++ app/api/admin/meal/trigger/route.ts | 29 ++++++ components/admin/hub.tsx | 4 +- functions/meal_groups/lambda_function.py | 36 +++++++ functions/meal_reminder/lambda_function.py | 72 ++++++++++++++ functions/meal_seating/lambda_function.py | 0 functions/meal_summary/lambda_function.py | 110 +++++++++++++++++++++ functions/send_email/lambda_function.py | 2 +- functions/send_email/meal_body.html | 2 - functions/serverless.yml | 58 +++++++++++ lib/authorise.ts | 2 +- 12 files changed, 365 insertions(+), 6 deletions(-) create mode 100644 app/admin/meal/meal-client.tsx create mode 100644 app/admin/meal/page.tsx create mode 100644 app/api/admin/meal/trigger/route.ts create mode 100644 functions/meal_groups/lambda_function.py create mode 100644 functions/meal_reminder/lambda_function.py create mode 100644 functions/meal_seating/lambda_function.py create mode 100644 functions/meal_summary/lambda_function.py diff --git a/app/admin/meal/meal-client.tsx b/app/admin/meal/meal-client.tsx new file mode 100644 index 00000000..8f2562ec --- /dev/null +++ b/app/admin/meal/meal-client.tsx @@ -0,0 +1,27 @@ +'use client' +import React, { useState } from "react"; + +export default function DiningPageClient() { + const [status,setStatus] = useState({} as any) + const handleSubmit = async (e) => { + e.preventDefault(); + + const response = await fetch('/api/admin/meal/trigger', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({}), + }) + console.log(response) + setStatus(response.ok ? {success: true, message: "Emails Triggered"} : { success: false, message: "Error" }) + } + + return ( +
+ {JSON.stringify(status) == "{}" ? null :

{status.message}

} + +
+ + ) +} diff --git a/app/admin/meal/page.tsx b/app/admin/meal/page.tsx new file mode 100644 index 00000000..65ed461f --- /dev/null +++ b/app/admin/meal/page.tsx @@ -0,0 +1,29 @@ +import React from "react"; +import Layout from "@components/layout/layout"; +import { Container } from "@components/layout/container"; +import Navigation from "@components/admin/navigation"; +import DiningPageClient from "./meal-client" +// import { currentUser } from "@clerk/nextjs/server"; + +const pages = [ + { name: 'Admin', href: '/admin', current: true }, + { name: 'Dining', href: '/admin/dining', current: true }, +] + +export default async function AdminUserPage() { + + // const loggedInUser = await currentUser(); + + return ( +
+ {" "} + + + + + + +
+
+); +} \ No newline at end of file diff --git a/app/api/admin/meal/trigger/route.ts b/app/api/admin/meal/trigger/route.ts new file mode 100644 index 00000000..e4dead3d --- /dev/null +++ b/app/api/admin/meal/trigger/route.ts @@ -0,0 +1,29 @@ +import { createClerkClient } from '@clerk/backend'; +import { auth } from '@clerk/nextjs/server'; +import { NextRequest } from 'next/server'; + +export async function POST(_req: NextRequest) { + const clerkClient = createClerkClient({ secretKey: process.env.CLERK_SECRET_KEY }); + const {userId} = auth(); + + if(!userId){ + return Response.json({error: "User is not signed in."}, { status: 401 }); + } + + const requestingUser = await clerkClient.users.getUser(userId); + if(!requestingUser.publicMetadata.admin){ + return Response.json({error: "User is does not have permissions."}, { status: 401 }); + } + + const triggerUrl = process.env.LAMBDA_MEAL_TRIGGER_REMINDER + console.log(triggerUrl) + const triggerResponse = await fetch(triggerUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + }) + + return triggerResponse.ok ? Response.json({message: "Email triggered"}, {status: 200}) : Response.json({message: "Email Failed"}, {status: 500}) + +} \ No newline at end of file diff --git a/components/admin/hub.tsx b/components/admin/hub.tsx index 2198b847..991e305f 100644 --- a/components/admin/hub.tsx +++ b/components/admin/hub.tsx @@ -70,8 +70,8 @@ const actions = [ state: 'live', }, { - title: 'Dining Information', - href: '#', + title: 'Meal Information', + href: '/admin/meal', description: "Meal information and seating plans", icon: CakeIcon, iconForeground: 'text-green-800', diff --git a/functions/meal_groups/lambda_function.py b/functions/meal_groups/lambda_function.py new file mode 100644 index 00000000..2e8a22f7 --- /dev/null +++ b/functions/meal_groups/lambda_function.py @@ -0,0 +1,36 @@ +import json +import os +import logging + +import boto3 +from boto3.dynamodb.conditions import Key, Attr +from shared import DecimalEncoder + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +## ENV +event_table_name = os.environ.get("EVENT_TABLE_NAME") +logger.info(event_table_name) + +dynamodb_client = boto3.client('dynamodb') +db = boto3.resource('dynamodb') +table = db.Table(event_table_name) + +def err(msg:str, code=400, logmsg=None, **kwargs): + logmsg = logmsg if logmsg else msg + logger.error(logmsg) + for k in kwargs: + logger.error(k+":"+kwargs[k]) + return { + 'statusCode': code, + 'body': json.dumps({'error': msg}) + } + +def lambda_handler(event, context): + scan_response = table.scan(FilterExpression=Key('PK').begins_with('GROUP#')) + + return { + 'statusCode': 200, + 'body': json.dumps(scan_response, cls=DecimalEncoder.DecimalEncoder) + } \ No newline at end of file diff --git a/functions/meal_reminder/lambda_function.py b/functions/meal_reminder/lambda_function.py new file mode 100644 index 00000000..9f6f0bc4 --- /dev/null +++ b/functions/meal_reminder/lambda_function.py @@ -0,0 +1,72 @@ +import json +import logging + +import boto3 +from boto3.dynamodb.conditions import Key, Attr +from shared import DecimalEncoder +import os +import time + +## ENV +attendees_table_name = os.environ.get("ATTENDEES_TABLE_NAME") +send_email_lambda = os.environ.get("SEND_EMAIL_LAMBDA") +event_table_name = os.environ.get("EVENT_TABLE_NAME") + +logger = logging.getLogger() +logger.setLevel("INFO") + +# profile_name='danceengine-admin' +# boto3.setup_default_session(profile_name=profile_name) +# logging.basicConfig() + +db = boto3.resource('dynamodb') +table = db.Table(attendees_table_name) +event_table = db.Table(event_table_name) + +lambda_client = boto3.client('lambda') + +def err(msg:str, code=400, logmsg=None, **kwargs): + logmsg = logmsg if logmsg else msg + logger.error(logmsg) + for k in kwargs: + logger.error(k+":"+kwargs[k]) + return { + 'statusCode': code, + 'body': json.dumps({'error': msg}) + } + +def lambda_handler(event, context): + logger.info(f"Event {event}") + if event['requestContext']['http']['method'] != "POST": return err("Method not allowed, make POST request", code=405) + + response = table.scan(FilterExpression=Key('active').eq(True)) + + filtered_items = [item for item in response['Items'] if (item['access'][2] == 1) and ('meal_preferences' not in item or item['meal_preferences'] == None or any(choice == -1 for choice in item['meal_preferences']['choices']))] + # logger.info(filtered_items) + + #! If no matches should problably return a 404 + for item in filtered_items: + # send the email with these details + logger.info("Invoking send_email lambda") + response = lambda_client.invoke( + FunctionName=send_email_lambda, + InvocationType='Event', + Payload=json.dumps({ + 'email_type':"meal_reminder", + 'email':item['email'], + 'ticket_number':item['ticket_number'], + }, cls=DecimalEncoder.DecimalEncoder), + ) + logger.info(response) + + event_table.put_item( + Item={ + 'PK': "EMAIL#MEALREMINDER".format(), + 'SK': "DETAIL#{}".format(time.time()), + 'timestamp': "{}".format(time.time()) + } + ) + + return {'statusCode': 200 } + +# lambda_handler({'requestContext':{'http':{'method':"GET"}}}, None) \ No newline at end of file diff --git a/functions/meal_seating/lambda_function.py b/functions/meal_seating/lambda_function.py new file mode 100644 index 00000000..e69de29b diff --git a/functions/meal_summary/lambda_function.py b/functions/meal_summary/lambda_function.py new file mode 100644 index 00000000..40d13089 --- /dev/null +++ b/functions/meal_summary/lambda_function.py @@ -0,0 +1,110 @@ +import json +import logging + +import boto3 +from boto3.dynamodb.conditions import Key, Attr +from shared import DecimalEncoder +import os + +## ENV +attendees_table_name = os.environ.get("ATTENDEES_TABLE_NAME") +event_table_name = os.environ.get("EVENT_TABLE_NAME") + +logger = logging.getLogger() +logger.setLevel("INFO") + +# profile_name='danceengine-admin' +# boto3.setup_default_session(profile_name=profile_name) +# logging.basicConfig() + +db = boto3.resource('dynamodb') +table = db.Table(attendees_table_name) +event_table = db.Table(event_table_name) + +lambda_client = boto3.client('lambda') + +def err(msg:str, code=400, logmsg=None, **kwargs): + logmsg = logmsg if logmsg else msg + logger.error(logmsg) + for k in kwargs: + logger.error(k+":"+kwargs[k]) + return { + 'statusCode': code, + 'body': json.dumps({'error': msg}) + } + +def get_last_email(): + response = table.scan(FilterExpression=Key('PK').eq("EMAIL#MEALREMINDER")) + sorted_list = sorted(response['Items'], key=lambda item: item['timestamp']) if len(response['Items'] > 0) else None + + return sorted_list + +def lambda_handler(event, context): + logger.info(f"Event {event}") + if event['requestContext']['http']['method'] != "GET": return err("Method not allowed, make GET request", code=405) + + response = table.scan(FilterExpression=Key('active').eq(True)) + + course_mappings = [ + {0: "Vegetable Terrine", 1: "Chicken Liver Pate"}, + {0: "Roasted Onion", 1: "Fish and Prawn Risotto", 2: "Chicken Supreme"}, + {0: "Fruit Platter", 1: "Bread and Butter Pudding"} + ] + num_courses = 3 + course_frequencies = [{} for _ in range(num_courses)] + + not_selected_count = 0 # All choices are -1 (not selected) + incomplete_count = 0 # Some choices are -1 (incomplete selection) + not_wanted_count = 0 # Any choice is -99 (not wanted) + selected_count = 0 # All choices are >= 0 (options selected) + + filtered_items = [item for item in response['Items'] if (item['access'][2] == 1)] + + #! If no matches should problably return a 404 + for item in filtered_items: + if ('meal_preferences' not in item): + not_selected_count += 1 + continue + elif (item['meal_preferences'] is None): + not_selected_count += 1 + continue + + meal_prefs = item['meal_preferences'] + if meal_prefs and 'choices' in meal_prefs: + choices = meal_prefs['choices'] + + if all(choice == -1 for choice in choices): + not_selected_count += 1 + elif any(choice == -1 for choice in choices): + incomplete_count += 1 + elif any(choice == -99 for choice in choices): + not_wanted_count += 1 + elif all(choice >= 0 for choice in choices): + selected_count += 1 + + for i, choice in enumerate(choices): + if choice >= 0 and choice in course_mappings[i]: + dish_name = course_mappings[i][choice] + if dish_name in course_frequencies[i]: + course_frequencies[i][dish_name] += 1 + else: + course_frequencies[i][dish_name] = 1 + + logger.info(f"Statistics: Not selected: {not_selected_count}, Incomplete: {incomplete_count}, Not wanted: {not_wanted_count}, Selected: {selected_count}") + logger.info(f"Course Frequencies: {course_frequencies}") + + return { + 'statusCode': 200, + 'body': json.dumps({ + 'statistics': { + 'not_selected_count': not_selected_count, + 'incomplete_count': incomplete_count, + 'not_wanted_count': not_wanted_count, + 'selected_count': selected_count, + 'course_frequencies': course_frequencies, + 'reminders_sent': get_last_email() + }}, cls=DecimalEncoder.DecimalEncoder) + } + +# from pprint import pprint +# pprint(lambda_handler({'requestContext':{'http':{'method':"GET"}}}, None)) \ No newline at end of file diff --git a/functions/send_email/lambda_function.py b/functions/send_email/lambda_function.py index 993596d6..3d901c21 100644 --- a/functions/send_email/lambda_function.py +++ b/functions/send_email/lambda_function.py @@ -144,7 +144,7 @@ def lambda_handler(event, context): body_tmpl = Template(body_file.read()) subdomain = "www" if os.environ.get("STAGE_NAME") == "prod" else os.environ.get("STAGE_NAME") body = body_tmpl.substitute({ - 'deanline_date':deadline_date, + 'deadline_date':deadline_date, 'ticket_link':"http://{}.merseysidelatinfestival.co.uk/preferences?email={}&ticket_number={}".format(subdomain, event['email'], event['ticket_number']), }) attachment = None diff --git a/functions/send_email/meal_body.html b/functions/send_email/meal_body.html index 3a685b81..6b015c46 100644 --- a/functions/send_email/meal_body.html +++ b/functions/send_email/meal_body.html @@ -29,12 +29,10 @@

Set your meal preferences!

It will be an unforgettable evening with great food and even better dance moves. Take a quick moment to select your meal options now.

-

You can modify your options up until the $deadline_date, after this date you can no longer select options and it will be set to the default option. If you have purchased multiple tickets with dinner included you will need to select the options for each ticket.

-

To manage your ticket click the link below and you will be able to select your meal options. If you have a table preference you can join a group and we will try out best to sit you with the people in your group. diff --git a/functions/serverless.yml b/functions/serverless.yml index 9cbab847..2bcced89 100644 --- a/functions/serverless.yml +++ b/functions/serverless.yml @@ -495,6 +495,64 @@ functions: path: /attendee_groups method: patch +#-------------------- + + MealReminder: + runtime: python3.11 + handler: meal_reminder/lambda_function.lambda_handler + name: "${sls:stage}-meal_reminder" + package: + patterns: + - '!**/**' + - "meal_reminder/**" + - "shared/**" + environment: + STAGE_NAME: ${sls:stage} + ATTENDEES_TABLE_NAME: ${param:attendeesTableName} + SEND_EMAIL_LAMBDA: "${sls:stage}-send_email" + EVENT_TABLE_NAME: ${param:eventTableName} + events: + - httpApi: + path: /meal/send_reminder + method: post + +#-------------------- + + MealGroups: + runtime: python3.11 + handler: meal_groups/lambda_function.lambda_handler + name: "${sls:stage}-meal_groups" + package: + patterns: + - '!**/**' + - "meal_groups/**" + - "shared/**" + environment: + STAGE_NAME: ${sls:stage} + EVENT_TABLE_NAME: ${param:eventTableName} + events: + - httpApi: + path: /meal/groups + method: get + +#-------------------- + MealSummary: + runtime: python3.11 + handler: meal_summary/lambda_function.lambda_handler + name: "${sls:stage}-meal_summary" + package: + patterns: + - '!**/**' + - "meal_summary/**" + - "shared/**" + environment: + STAGE_NAME: ${sls:stage} + EVENT_TABLE_NAME: ${param:eventTableName} + ATTENDEES_TABLE_NAME: ${param:attendeesTableName} + events: + - httpApi: + path: /meal/summary + method: get diff --git a/lib/authorise.ts b/lib/authorise.ts index 14bdb493..c8885c3e 100644 --- a/lib/authorise.ts +++ b/lib/authorise.ts @@ -6,7 +6,7 @@ const grantUsage = { "developer": ["#","/admin/users","/admin/stripe","/admin/import"], "content-manager": ['/admin/content'], "door-staff": ['/admin/ticketing','/admin/scan', '/admin/epos'], - "event-manager": ['/admin/ticketing.*'] // Everything under ticketing + "event-manager": ['/admin/ticketing.*','/admin/meal.*'] // Everything under ticketing } export const authUsage = (user,path) => {