]> git.cworth.org Git - turbot-web/blob - html_generator.py
Accept the name(s) of hunt_ids which should be generated on the command line
[turbot-web] / html_generator.py
1 # -*- coding: utf-8 -*-
2 """
3 Created on Thu Jan  6 23:35:23 2022
4
5 @author: Avram Gottschlich
6 """
7 """
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
10
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,
13 that would be great
14
15 Requires sorttable.js, which should be included
16 """
17 import boto3
18 from boto3.dynamodb.conditions import Key
19 import os
20 import re
21 import sys
22
23 WEBROOT = "/srv/halibut.cworth.org/www"
24
25 website = "https://halibut.cworth.org/"
26 #change this if we're using AWS or some other subdomain instead
27
28 def hunt_file(hunt, name):
29     """Return a path file 'name' within the given hunt.
30
31     This will be withing WEBROOT and in a hunt-specific path."""
32
33     return "{}/{}/{}".format(WEBROOT, hunt['channel_id'], name)
34
35 def filename_from_name(name):
36     """Returns a string derived from name, but with all spaces and slashes
37        replaced with underscores, (for making a clean filename)"""
38     return re.sub(r'[ /]', '_', name)
39
40 def channel_url(channel_id):
41     """Given a channel ID, return the URL for that channel."""
42
43     return "https://halibutthatbass.slack.com/archives/{}".format(channel_id)
44
45 def find_hunt_for_hunt_id(table, hunt_id):
46     """Given a hunt ID find the database item for that hunt
47
48     Returns None if hunt ID is not found, otherwise a
49     dictionary with all fields from the hunt's row in the table,
50     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
51     """
52     response = table.get_item(
53         Key={
54             'hunt_id': hunt_id,
55             'SK': 'hunt-{}'.format(hunt_id)
56             })
57
58     if 'Item' in response:
59         return response['Item']
60     else:
61         return None
62
63 def hunt_puzzles_for_hunt_id(table, hunt_id):
64     """Return all puzzles that belong to the given hunt_id"""
65
66     response = table.query(
67         KeyConditionExpression=(
68             Key('hunt_id').eq(hunt_id) &
69             Key('SK').begins_with('puzzle-')
70             )
71         )
72
73     return response['Items']
74
75 def elink(lin, text):
76     #shortcutting function for creating html links
77     #opens in a new tab for external links
78     return '<a href="{}" target="_blank" rel="noreferrer noopener">{}</a>'.format(lin, text)
79
80 def link(lin, text):
81     #internal links, doesn't open new tab
82     return '<a href="{}">{}</a>'.format(lin, text)
83
84 def hunt_info(table, hunt_id):
85     """
86     Retrieves list of rounds, puzzles for the given hunt
87     """
88
89     hunt = find_hunt_for_hunt_id(table, hunt_id)
90
91     name = hunt["name"]
92     channel_id = hunt["channel_id"]
93
94     puzzles = hunt_puzzles_for_hunt_id(table, hunt_id)
95
96     rounds = set()
97     for puzzle in puzzles:
98         if "rounds" not in puzzle:
99             continue
100         for rnd in puzzle["rounds"]:
101             rounds.add(rnd)
102     rounds = list(rounds)
103     rounds.sort()
104
105     return hunt, puzzles, rounds
106
107 def round_stat(rnd, puzzles):
108     #Counts puzzles, solved, list of puzzles for a given round
109     puzzle_count = 0
110     solved_count = 0
111     solved_puzzles = []
112     unsolved_puzzles = []
113     metas = []
114     meta_solved = 0
115     for puzzle in puzzles:
116         if "rounds" not in puzzle:
117             continue
118         if rnd in puzzle["rounds"]:
119             if puzzle['type'] == 'meta':
120                 metas.append(puzzle)
121                 if puzzle['status'] == 'solved':
122                     meta_solved += 1
123             else:
124                 puzzle_count += 1
125                 if puzzle['status'] == 'solved':
126                     solved_count += 1
127                     solved_puzzles.append(puzzle)
128                 else:
129                     unsolved_puzzles.append(puzzle)
130     solved_puzzles = sorted(solved_puzzles, key = lambda i: i['name'])
131     unsolved_puzzles = sorted(unsolved_puzzles, key = lambda i: i['name'])
132     rnd_puzzles = metas + unsolved_puzzles + solved_puzzles
133     return puzzle_count, solved_count, rnd_puzzles, meta_solved, len(metas)
134
135
136
137 def overview(hunt, puzzles, rounds):
138     #big board, main page. saves as index.html
139     start = ['<!DOCTYPE html>\n',
140      '<html>\n',
141      '<head>\n',
142      '  <meta charset="utf-8">\n',
143      '  <meta name="viewport" content="width=device-width, initial-scale=1">\n',
144      '\n',
145      '  <link rel="stylesheet" href="/overview.css">\n',
146      '  <script type="text/javascript">\n',
147      '    // Hide all elements with class="containerTab", except for the one that matches the clickable grid column\n',
148      '    function openTab(tabName) {\n',
149      '      var i, x;\n',
150      '      x = document.getElementsByClassName("containerTab");\n',
151      '      for (i = 0; i < x.length; i++) {\n',
152      '        x[i].style.display = "none";\n',
153      '      }\n',
154      '      document.getElementById(tabName).style.display = "block";\n',
155      '    }\n',
156      '  </script>\n',
157      '\n',
158      '  <title>Hunt Overview</title>\n',
159      '  <script src="/sorttable.js"></script>\n'
160      '</head>\n',
161      '    <div class="sidenav">\n'
162      '      <a href="index.html">Hunt Overview</a>'
163      '      <a href="all.html">All Puzzles</a>\n'
164      '      <a href="unsolved.html">Unsolved</a>\n'
165      '      <a href="solved.html">Solved</a>\n'
166      '    </div>\n'
167      '<body>\n',]
168     columns = ['  <div class="row">\n']
169     expanding = []
170     i = 1
171     for rnd in rounds:
172         puzzle_count, solved_count, rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)
173         if metas == meta_solved and metas > 0:
174             status = 'solved'
175         else:
176             status = 'unsolved'
177         columns += ['    <div class="column {}" onclick="openTab(\'b{}\');">\n'.format(status, i),
178         '      <p>{}</p>\n'.format(rnd),
179         '      <p>Puzzles: {}/{}</p>\n'.format(solved_count, puzzle_count),
180         '      <p>Metas: {}/{}</p>\n'.format(meta_solved, metas),
181         '    </div>\n']
182
183         expanding += [
184         '  <div id="b{}" class="containerTab {}" style="display:none;">\n'.format(i, status),
185         '    <span onclick="this.parentElement.style.display=\'none\'" class="closebtn">x</span>\n',
186         '    <h2>{}</h2>\n'.format(link(website + filename_from_name(rnd) + "_round.html", rnd)),
187         '    <table class="sortable">\n',
188         '      <tr>\n',
189         '        <th><u>Puzzle</u></th>\n',
190         '        <th><u>Answer</u></th>\n',
191         '      </tr>\n',]
192
193         for puzzle in rnd_puzzles:
194             if puzzle['type'] == 'meta':
195                 meta = ' [META]'
196             else:
197                 meta = ''
198             if puzzle['status'] == 'solved':
199                 expanding += ['      <tr class=\'solved\';>\n',
200                 '        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + ".html", puzzle['name']+meta)),
201                 '        <td>{}</td>\n'.format(puzzle['solution']),
202                 '      </tr>\n']
203             else:
204                 expanding += ['      <tr class=\'unsolved\';>\n',
205                 '        <td><b>{}</b></td>\n'.format(link(website + filename_from_name(puzzle['name']) + ".html", puzzle['name']+meta)),
206                 '        <td></td>\n',
207                 '      </tr>\n']
208         expanding.append('    </table>\n')
209         expanding.append('  </div>\n')
210         i += 1
211     columns.append('  </div>\n')
212     end = ['</body>\n', '</html>\n']
213     html = start + expanding + columns + end
214     file = hunt_file(hunt, "index.html")
215     f = open(file, "w")
216     for line in html:
217         f.write(line)
218     f.close()
219     return None
220
221 def round_overview(hunt, rnd, puzzles):
222     #inputs: round name, puzzles
223     #round overview page
224     #saves as (round name)_round.html, in case meta/round share names.
225     #underscores replace spaces for links.
226     rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)[2:]
227     if meta_solved == metas and metas > 0:
228         status = 'solved'
229     else:
230         status = 'unsolved'
231     start = ['<html>\n',
232      '    <head>\n',
233      '        <link rel="stylesheet" href="/individual.css">\n',
234      '        <title>Mystery Hunt 2022</title>\n',
235      '        <script src="/sorttable.js"></script>\n',
236      '    </head>\n',
237      '    <body class="{}">\n'.format(status),
238      '        <h1><b>{}</b></h1>\n'.format(rnd),
239      '        <p>{}</p>\n'.format(link(website + "index.html", 'Hunt Overview')),
240      '        <div>\n',
241      '            <table class="center sortable">\n',
242      '                <thead>\n',
243      '                    <tr>\n',
244      '                        <th>Puzzle Title/Slack</th>\n',
245      '                        <th>Puzzle Link</th>\n',
246      '                        <th>Sheet</th>\n',
247      '                        <th>Overview</th>\n',
248      '                        <th>Answer</th>\n',
249      #'                        <th>Extra Links</th>\n',
250      '                        <th>Tags</th>\n',
251      '                    </tr>\n',
252      '                </thead>\n',
253      '                <tbody>\n']
254     puzzle_list = []
255     for puzzle in rnd_puzzles:
256         if puzzle['type'] == 'meta':
257             meta = ' [META]'
258         else:
259             meta = ''
260         slack_url = channel_url(puzzle['channel_id'])
261
262         if puzzle['status'] == 'solved':
263             puzzle_list += [ '                    <tr>\n',
264              '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
265              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
266              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
267              '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
268              '                        <td>{}</td>\n'.format(puzzle['solution']),
269             # '                        <td></td>\n',
270              '                        <td>{}</td>\n'.format("".join(puzzle.get('tags',[]))),
271              '                    </tr>\n']
272         else:
273             puzzle_list += [ '                    <tr>\n',
274              '                        <td><b>{}</b></td>\n'.format(elink(slack_url, puzzle['name']+meta)),
275              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
276              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
277              '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
278              '                        <td></td>\n',
279             # '                        <td></td>\n',
280              '                        <td>{}</td>\n'.format(" ".join(puzzle.get('tags',[]))),
281              '                    </tr>\n']
282     end = ['                </tbody>\n',
283     '            </table>\n',
284     '        </div>\n',
285     '    </body>\n',
286     '</html>\n']
287     html = start + puzzle_list + end
288     file = hunt_file(hunt, "{}_round.html".format(filename_from_name(rnd)))
289     f = open(file, "w")
290     for line in html:
291         f.write(line)
292     f.close()
293     return None
294
295 def puzzle_overview(hunt, puzzle):
296     #overview page for individual puzzles. saves as (name of puzzle).html,
297     #with underscores rather than spaces
298     name = puzzle['name']
299     if puzzle['type'] == 'meta':
300         meta = ' [META]'
301     else:
302         meta = ''
303     slack_url = channel_url(puzzle['channel_id'])
304     if 'rounds' in puzzle:
305         round_url = [link(website + filename_from_name(rnd) + "_round.html", rnd) for rnd in puzzle['rounds']]
306     else:
307         round_url = ''
308     if puzzle['status'] == 'solved':
309         solution = puzzle['solution']
310         status = 'solved'
311     else:
312         solution = ""
313         status = 'unsolved'
314     html = ['<!DOCTYPE html>\n',
315      '<html>\n',
316      '<head>\n',
317      '    <meta charset="utf-8">\n',
318      '    <meta name="viewport" content="width=device-width, initial-scale=1">\n',
319      '    <link rel="stylesheet" href="/individual.css">\n',
320      '    <title>{}</title>\n'.format(name+meta),
321      '    <p>{}</p>'.format(link(website + 'index.html', 'Hunt Overview')),
322      '\n',
323      '</head>\n',
324      '<body class="{}">\n'.format(status),
325      '    <h1>{}</h1>\n'.format(name+meta),
326      '    <div>\n',
327      '        <table class="center">\n',
328      '            <tr>\n',
329      '                <td>{}</td>\n'.format(elink(slack_url, 'Channel')), #slack channel
330      '                <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
331      '                <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
332      '                <td>Additional Resources</td>\n',
333      '            </tr>\n',
334      '        </table>\n',
335      '        <table class="center">\n',
336      '            <tr>\n',
337      '                <td>Round(s): {}</td>\n'.format(" ".join(round_url)), #round page on our site
338      '                <td>Tags: {}</td>\n'.format(" ".join(puzzle.get('tags',[]))), #add tags
339      '            </tr>\n',
340      '            <tr>\n',
341      '                <td>Answer: {}</td>\n'.format(solution),
342      '                <td>State: {}</td>\n'.format(puzzle['status']),
343      '            </tr>\n',
344      '        </table>\n',
345      '    </div>\n',
346      '\n',
347      '</body>\n',
348      '</html>\n']
349     file = hunt_file(hunt, "{}.html".format(filename_from_name(name)))
350     f = open(file, "w")
351     for line in html:
352         f.write(line)
353     return None
354
355 def puzzle_lists(hunt, puzzles, filt):
356     #filt is one of "All", "Solved", "Unsolved" and spits out the appropriate list of puzzles
357     #generates pages for all puzzles, solved puzzles, unsolved puzzles
358     #saves as all/solved/unsolved.html, has a sidebar to link to each other and to the big board
359     solved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] == 'solved']
360     unsolved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] != 'solved']
361     start = ['<html>\n',
362      '    <head>\n',
363      '        <link rel="stylesheet" href="/overview.css">\n',
364      '        <title>Mystery Hunt 2022</title>\n',
365      '        <script src="/sorttable.js"></script>\n',
366      '    </head>\n',
367      '    <div class="sidenav">\n'
368      '      <a href="index.html">Hunt Overview</a>'
369      '      <a href="all.html">All Puzzles</a>\n'
370      '      <a href="unsolved.html">Unsolved</a>\n'
371      '      <a href="solved.html">Solved</a>\n'
372      '    </div>\n'
373      '    <body>\n',
374      '        <h1><b>{}</b></h1>\n'.format('{} Puzzles').format(filt),
375      '        <p>{}</p>\n'.format(link(website + "index.html", 'Hunt Overview')),
376      '        <div>\n',
377      '            <table class="center sortable">\n',
378      '                <thead>\n',
379      '                    <tr>\n',
380      '                        <th>Puzzle Title/Slack</th>\n',
381      '                        <th>Puzzle Link</th>\n',
382      '                        <th>Sheet</th>\n',
383      '                        <th>Overview</th>\n',
384      '                        <th>Answer</th>\n',
385      '                        <th>Round(s)</th>\n',
386      '                        <th>Tags</th>\n',
387      '                    </tr>\n',
388      '                </thead>\n',
389      '                <tbody>\n']
390     solved_code = []
391     unsolved_code = []
392     for puzzle in solved_puzzles:
393         if puzzle['type'] == 'meta':
394             meta = ' [META]'
395         else:
396             meta = ''
397         slack_url = channel_url(puzzle['channel_id'])
398         if 'rounds' in puzzle:
399             round_url = link(website + filename_from_name(puzzle['rounds'][0]) + "_round.html", puzzle['rounds'][0])
400         else:
401             round_url = ''
402         #assuming one round per puzzle for now
403
404         solved_code += ['                    <tr>\n',
405          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
406          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
407          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
408          '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
409          '                        <td>{}</td>\n'.format(puzzle['solution']),
410          '                        <td>{}</td>\n'.format(round_url),
411          '                        <td>{}</td>\n'.format("".join(puzzle.get('tags',[]))),
412          '                    </tr>\n']
413     for puzzle in unsolved_puzzles:
414         if puzzle['type'] == 'meta':
415             meta = ' [META]'
416         else:
417             meta = ''
418         slack_url = channel_url(puzzle['channel_id'])
419         if 'rounds' in puzzle:
420             round_url = link(website + filename_from_name(puzzle['rounds'][0]) + "_round.html", puzzle['rounds'][0])
421         else:
422             round_url = ''
423         #assuming one round per puzzle for now
424
425         unsolved_code += ['                    <tr>\n',
426          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
427          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
428          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
429          '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
430          '                        <td></td>\n',
431          '                        <td>{}</td>\n'.format(round_url),
432          '                        <td>{}</td>\n'.format("".join(puzzle.get('tags',[]))),
433          '                    </tr>\n']
434     end = ['                </tbody>\n',
435     '            </table>\n',
436     '        </div>\n',
437     '    </body>\n',
438     '</html>\n']
439     if filt == "All":
440         file1 = hunt_file(hunt, 'all.html')
441         f = open(file1, "w")
442         for line in start + unsolved_code + solved_code + end:
443             f.write(line)
444         f.close()
445     elif filt == "Solved":
446         file2 = hunt_file(hunt, 'solved.html')
447         f = open(file2, 'w')
448         for line in start + solved_code + end:
449             f.write(line)
450         f.close()
451     elif filt == "Unsolved":
452         file3 = hunt_file(hunt, 'unsolved.html')
453         f = open(file3, 'w')
454         for line in start + unsolved_code + end:
455             f.write(line)
456         f.close()
457     return None
458
459 def generate_for_hunt_id(table, hunt_id):
460     hunt, puzzles, rounds = hunt_info(table, hunt_id)
461
462     # Create a directory for the hunt in the WEBROOT
463     root = hunt_file(hunt, "")
464     try:
465         os.mkdir(root)
466     except FileExistsError:
467         #  We're happy as a clam if the directory already exists
468         pass
469
470     overview(hunt, puzzles, rounds)
471     for rnd in rounds:
472         round_overview(hunt, rnd, puzzles)
473         for puzzle in puzzles:
474             puzzle_overview(hunt, puzzle)
475             puzzle_lists(hunt, puzzles, "All")
476             puzzle_lists(hunt, puzzles, "Solved")
477             puzzle_lists(hunt, puzzles, "Unsolved")
478
479 # Initialize AWS resources to talk to the database
480 db = boto3.resource('dynamodb')
481 table = db.Table("turbot")
482
483 def usage():
484     print("Usage: {} hunt_id [...]")
485     print("")
486     print("Generates pages (under {}) ".format(WEBROOT))
487     print("for the specified hunt_id(s).")
488
489 if len(sys.argv) < 2:
490     usage()
491     sys.exit(1)
492
493 for hunt_id in sys.argv[1:]:
494     generate_for_hunt_id(table, hunt_id)