1 # -*- coding: utf-8 -*-
3 Created on Thu Jan 6 23:35:23 2022
5 @author: Avram Gottschlich
8 I copied several functions from your code;
9 if it's easier to refer to them rather than copying them that's absolutely fine
11 This rewrites the html each time it is called
12 If it's easy to call the puzzle/round functions each time they're updated,
15 Requires sorttable.js, which should be included
18 from boto3.dynamodb.conditions import Key
23 WEBROOT = "/srv/halibut.cworth.org/www"
26 def hunt_file(hunt, name):
27 """Return a path file 'name' within the given hunt.
29 This will be withing WEBROOT and in a hunt-specific path."""
31 return "{}/{}/{}".format(WEBROOT, hunt['channel_id'], name)
33 def internal_link(hunt, name):
34 """Returns a path for a link on this site."""
36 # Just generate a relative link, (which is just the name itself)
37 return "{}".format(name)
39 def filename_from_name(name):
40 """Returns a string derived from name, but with all spaces and slashes
41 replaced with underscores, (for making a clean filename)"""
42 return re.sub(r'[ /]', '_', name)
44 def channel_url(channel_id):
45 """Given a channel ID, return the URL for that channel."""
47 return "https://halibutthatbass.slack.com/archives/{}".format(channel_id)
49 def find_hunt_for_hunt_id(table, hunt_id):
50 """Given a hunt ID find the database item for that hunt
52 Returns None if hunt ID is not found, otherwise a
53 dictionary with all fields from the hunt's row in the table,
54 (channel_id, active, hunt_id, name, url, sheet_url, etc.).
56 response = table.get_item(
59 'SK': 'hunt-{}'.format(hunt_id)
62 if 'Item' in response:
63 return response['Item']
67 def hunt_puzzles_for_hunt_id(table, hunt_id):
68 """Return all puzzles that belong to the given hunt_id"""
70 response = table.query(
71 KeyConditionExpression=(
72 Key('hunt_id').eq(hunt_id) &
73 Key('SK').begins_with('puzzle-')
77 return response['Items']
80 #shortcutting function for creating html links
81 #opens in a new tab for external links
82 return '<a href="{}" target="_blank" rel="noreferrer noopener">{}</a>'.format(lin, text)
85 #internal links, doesn't open new tab
86 return '<a href="{}">{}</a>'.format(lin, text)
88 def hunt_info(table, hunt_id):
90 Retrieves list of rounds, puzzles for the given hunt
93 hunt = find_hunt_for_hunt_id(table, hunt_id)
96 channel_id = hunt["channel_id"]
98 puzzles = hunt_puzzles_for_hunt_id(table, hunt_id)
101 for puzzle in puzzles:
102 if "rounds" not in puzzle:
104 for rnd in puzzle["rounds"]:
106 rounds = list(rounds)
109 return hunt, puzzles, rounds
111 def round_stat(rnd, puzzles):
112 #Counts puzzles, solved, list of puzzles for a given round
116 unsolved_puzzles = []
119 for puzzle in puzzles:
120 if "rounds" not in puzzle:
122 if rnd in puzzle["rounds"]:
123 if puzzle['type'] == 'meta':
125 if puzzle['status'] == 'solved':
129 if puzzle['status'] == 'solved':
131 solved_puzzles.append(puzzle)
133 unsolved_puzzles.append(puzzle)
134 solved_puzzles = sorted(solved_puzzles, key = lambda i: i['name'])
135 unsolved_puzzles = sorted(unsolved_puzzles, key = lambda i: i['name'])
136 rnd_puzzles = metas + unsolved_puzzles + solved_puzzles
137 return puzzle_count, solved_count, rnd_puzzles, meta_solved, len(metas)
141 def overview(hunt, puzzles, rounds):
142 #big board, main page. saves as index.html
143 start = ['<!DOCTYPE html>\n',
146 ' <meta charset="utf-8">\n',
147 ' <meta name="viewport" content="width=device-width, initial-scale=1">\n',
148 ' <meta http-equiv="refresh" content = "15">\n',
150 ' <link rel="stylesheet" href="/overview.css">\n',
151 ' <script type="text/javascript">\n',
152 ' // Hide all elements with class="containerTab", except for the one that matches the clickable grid column\n',
153 ' function openTab(tabName) {\n',
155 ' x = document.getElementsByClassName("containerTab");\n',
156 ' for (i = 0; i < x.length; i++) {\n',
157 ' x[i].style.display = "none";\n',
159 ' document.getElementById(tabName).style.display = "block";\n',
163 ' <title>Hunt Overview</title>\n',
164 ' <script src="/sorttable.js"></script>\n'
166 ' <div class="sidenav">\n',
167 ' <a href="index.html">Hunt Overview</a>',
168 ' <a href="all.html">All Puzzles</a>\n',
169 ' <a href="unsolved.html">Unsolved</a>\n',
170 ' <a href="solved.html">Solved</a>\n',
171 ' <a href="https://docs.google.com/document/d/14Ww6vWFO4hx1GYz8zDRxP_rI_v4hmRgdgYmN91F-Lqk/edit" target="_blank" rel="noreferrer noopener">Turbot Docs</a>\n'
174 columns = [' <div class="row">\n']
178 puzzle_count, solved_count, rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)
179 if metas == meta_solved and metas > 0:
183 columns += [' <div class="column {}" onclick="openTab(\'b{}\');">\n'.format(status, i),
184 ' <p>{}</p>\n'.format(rnd),
185 ' <p>Puzzles: {}/{}</p>\n'.format(solved_count, puzzle_count),
186 ' <p>Metas: {}/{}</p>\n'.format(meta_solved, metas),
190 ' <div id="b{}" class="containerTab {}" style="display:none;">\n'.format(i, status),
191 ' <span onclick="this.parentElement.style.display=\'none\'" class="closebtn">x</span>\n',
192 ' <h2>{}</h2>\n'.format(link(internal_link(hunt, filename_from_name(rnd)) + "_round.html", rnd)),
193 ' <table class="sortable">\n',
195 ' <th><u>Puzzle</u></th>\n',
196 ' <th><u>Answer</u></th>\n',
199 for puzzle in rnd_puzzles:
200 if puzzle['type'] == 'meta':
204 if puzzle['status'] == 'solved':
205 expanding += [' <tr class=\'solved\';>\n',
206 ' <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", puzzle['name']+meta)),
207 ' <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
210 expanding += [' <tr class=\'unsolved\';>\n',
211 ' <td><b>{}</b></td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", puzzle['name']+meta)),
214 expanding.append(' </table>\n')
215 expanding.append(' </div>\n')
217 columns.append(' </div>\n')
218 end = ['</body>\n', '</html>\n']
219 html = start + expanding + columns + end
220 file = hunt_file(hunt, "index.html")
221 f = open(file + ".tmp", "w")
225 os.rename(file + ".tmp", file)
228 def round_overview(hunt, rnd, puzzles):
229 #inputs: round name, puzzles
231 #saves as (round name)_round.html, in case meta/round share names.
232 #underscores replace spaces for links.
233 rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)[2:]
234 if meta_solved == metas and metas > 0:
240 ' <link rel="stylesheet" href="/individual.css">\n',
241 ' <title>Mystery Hunt 2022</title>\n',
242 ' <script src="/sorttable.js"></script>\n',
243 ' <meta http-equiv="refresh" content = "15">\n',
245 ' <body class="{}">\n'.format(status),
246 ' <h1><b>{}</b></h1>\n'.format(rnd),
247 ' <p>{}</p>\n'.format(link(internal_link(hunt, "index") + ".html", 'Hunt Overview')),
249 ' <table class="center sortable">\n',
252 ' <th>Puzzle Title/Slack</th>\n',
253 ' <th>Puzzle Link</th>\n',
255 ' <th>Overview</th>\n',
256 ' <th>Answer</th>\n',
257 #' <th>Extra Links</th>\n',
263 for puzzle in rnd_puzzles:
264 if puzzle['type'] == 'meta':
268 slack_url = channel_url(puzzle['channel_id'])
270 if puzzle['status'] == 'solved':
271 puzzle_list += [ ' <tr>\n',
272 ' <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
273 ' <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
274 ' <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
275 ' <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
276 ' <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
278 ' <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
281 puzzle_list += [ ' <tr>\n',
282 ' <td><b>{}</b></td>\n'.format(elink(slack_url, puzzle['name']+meta)),
283 ' <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
284 ' <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
285 ' <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
288 ' <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
290 end = [' </tbody>\n',
295 html = start + puzzle_list + end
296 file = hunt_file(hunt, "{}_round.html".format(filename_from_name(rnd)))
297 f = open(file + ".tmp", "w")
301 os.rename(file + ".tmp", file)
304 def puzzle_overview(hunt, puzzle):
305 #overview page for individual puzzles. saves as (name of puzzle).html,
306 #with underscores rather than spaces
307 name = puzzle['name']
308 if puzzle['type'] == 'meta':
312 slack_url = channel_url(puzzle['channel_id'])
313 if 'rounds' in puzzle:
314 round_url = [link(internal_link(hunt, filename_from_name(rnd)) + "_round.html", rnd) for rnd in puzzle['rounds']]
317 if puzzle['status'] == 'solved':
318 solution = ", ".join(puzzle['solution']).upper()
323 html = ['<!DOCTYPE html>\n',
326 ' <meta charset="utf-8">\n',
327 ' <meta name="viewport" content="width=device-width, initial-scale=1">\n',
328 ' <meta http-equiv="refresh" content = "15">\n',
329 ' <link rel="stylesheet" href="/individual.css">\n',
330 ' <title>{}</title>\n'.format(name+meta),
331 ' <p>{}</p>'.format(link(internal_link(hunt, 'index') + ".html", 'Hunt Overview')),
334 '<body class="{}">\n'.format(status),
335 ' <h1>{}</h1>\n'.format(name+meta),
337 ' <table class="center">\n',
339 ' <td>{}</td>\n'.format(elink(slack_url, 'Channel')), #slack channel
340 ' <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
341 ' <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
342 ' <td>Additional Resources</td>\n',
345 ' <table class="center">\n',
347 ' <td>Round(s): {}</td>\n'.format(" ".join(round_url)), #round page on our site
348 ' <td>Tags: {}</td>\n'.format(", ".join(puzzle.get('tags',[]))), #add tags
351 ' <td>Answer: {}</td>\n'.format(solution),
352 ' <td>State: {}</td>\n'.format(puzzle['status']),
359 file = hunt_file(hunt, "{}.html".format(filename_from_name(name)))
360 f = open(file + ".tmp", "w")
364 os.rename(file + ".tmp", file)
367 def puzzle_lists(hunt, puzzles, filt):
368 #filt is one of "All", "Solved", "Unsolved" and spits out the appropriate list of puzzles
369 #generates pages for all puzzles, solved puzzles, unsolved puzzles
370 #saves as all/solved/unsolved.html, has a sidebar to link to each other and to the big board
371 solved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] == 'solved']
372 unsolved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] != 'solved']
375 ' <link rel="stylesheet" href="/overview.css">\n',
376 ' <title>Mystery Hunt 2022</title>\n',
377 ' <script src="/sorttable.js"></script>\n',
378 ' <meta http-equiv="refresh" content = "15">\n',
380 ' <div class="sidenav">\n',
381 ' <a href="index.html">Hunt Overview</a>',
382 ' <a href="all.html">All Puzzles</a>\n',
383 ' <a href="unsolved.html">Unsolved</a>\n',
384 ' <a href="solved.html">Solved</a>\n',
385 ' <a href="https://docs.google.com/document/d/14Ww6vWFO4hx1GYz8zDRxP_rI_v4hmRgdgYmN91F-Lqk/edit" target="_blank" rel="noreferrer noopener">Turbot Docs</a>\n'
388 ' <h1><b>{}</b></h1>\n'.format('{} Puzzles').format(filt),
389 ' <p>{}</p>\n'.format(link(internal_link(hunt, 'index') + ".html", 'Hunt Overview')),
391 ' <table class="center sortable">\n',
394 ' <th>Puzzle Title/Slack</th>\n',
395 ' <th>Puzzle Link</th>\n',
397 ' <th>Overview</th>\n',
398 ' <th>Answer</th>\n',
399 ' <th>Round(s)</th>\n',
406 for puzzle in solved_puzzles:
407 if puzzle['type'] == 'meta':
411 slack_url = channel_url(puzzle['channel_id'])
412 if 'rounds' in puzzle:
413 round_url = link(internal_link(hunt, filename_from_name(puzzle['rounds'][0])) + "_round.html", puzzle['rounds'][0])
416 #assuming one round per puzzle for now
418 solved_code += [' <tr>\n',
419 ' <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
420 ' <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
421 ' <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
422 ' <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
423 ' <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
424 ' <td>{}</td>\n'.format(round_url),
425 ' <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
427 for puzzle in unsolved_puzzles:
428 if puzzle['type'] == 'meta':
432 slack_url = channel_url(puzzle['channel_id'])
433 if 'rounds' in puzzle:
434 round_url = link(internal_link(hunt, filename_from_name(puzzle['rounds'][0])) + "_round.html", puzzle['rounds'][0])
437 #assuming one round per puzzle for now
439 unsolved_code += [' <tr>\n',
440 ' <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
441 ' <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
442 ' <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
443 ' <td>{}</td>\n'.format(link(internal_link(hunt, filename_from_name(puzzle['name'])) + ".html", 'Overview')),
445 ' <td>{}</td>\n'.format(round_url),
446 ' <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
448 end = [' </tbody>\n',
454 file1 = hunt_file(hunt, 'all.html')
455 f = open(file1 + ".tmp", "w")
456 for line in start + unsolved_code + solved_code + end:
459 os.rename(file1 + ".tmp", file1)
460 elif filt == "Solved":
461 file2 = hunt_file(hunt, 'solved.html')
462 f = open(file2 + ".tmp", 'w')
463 for line in start + solved_code + end:
466 os.rename(file2 + ".tmp", file2)
467 elif filt == "Unsolved":
468 file3 = hunt_file(hunt, 'unsolved.html')
469 f = open(file3 + ".tmp", 'w')
470 for line in start + unsolved_code + end:
473 os.rename(file3 + ".tmp", file3)
476 def generate_for_hunt_id(table, hunt_id):
477 hunt, puzzles, rounds = hunt_info(table, hunt_id)
479 # Create a directory for the hunt in the WEBROOT
480 root = hunt_file(hunt, "")
483 except FileExistsError:
484 # We're happy as a clam if the directory already exists
487 overview(hunt, puzzles, rounds)
489 round_overview(hunt, rnd, puzzles)
490 for puzzle in puzzles:
491 puzzle_overview(hunt, puzzle)
492 puzzle_lists(hunt, puzzles, "All")
493 puzzle_lists(hunt, puzzles, "Solved")
494 puzzle_lists(hunt, puzzles, "Unsolved")
497 # Initialize AWS resources to talk to the database
498 db = boto3.resource('dynamodb')
499 table = db.Table("turbot")
502 print("Usage: {} hunt_id [...]")
504 print("Generates pages (under {}) ".format(WEBROOT))
505 print("for the specified hunt_id(s).")
507 if len(sys.argv) < 2:
511 for hunt_id in sys.argv[1:]:
512 generate_for_hunt_id(table, hunt_id)