]> git.cworth.org Git - turbot-web/blob - html_generator.py
Widened the expanded view boxes on the main board. Changed the answers display to...
[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(", ".join(puzzle['solution']).upper()),
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 + ".tmp", "w")
216     for line in html:
217         f.write(line)
218     f.close()
219     os.rename(file + ".tmp", file)
220     return None
221
222 def round_overview(hunt, rnd, puzzles):
223     #inputs: round name, puzzles
224     #round overview page
225     #saves as (round name)_round.html, in case meta/round share names.
226     #underscores replace spaces for links.
227     rnd_puzzles, meta_solved, metas = round_stat(rnd, puzzles)[2:]
228     if meta_solved == metas and metas > 0:
229         status = 'solved'
230     else:
231         status = 'unsolved'
232     start = ['<html>\n',
233      '    <head>\n',
234      '        <link rel="stylesheet" href="/individual.css">\n',
235      '        <title>Mystery Hunt 2022</title>\n',
236      '        <script src="/sorttable.js"></script>\n',
237      '    </head>\n',
238      '    <body class="{}">\n'.format(status),
239      '        <h1><b>{}</b></h1>\n'.format(rnd),
240      '        <p>{}</p>\n'.format(link(website + "index.html", 'Hunt Overview')),
241      '        <div>\n',
242      '            <table class="center sortable">\n',
243      '                <thead>\n',
244      '                    <tr>\n',
245      '                        <th>Puzzle Title/Slack</th>\n',
246      '                        <th>Puzzle Link</th>\n',
247      '                        <th>Sheet</th>\n',
248      '                        <th>Overview</th>\n',
249      '                        <th>Answer</th>\n',
250      #'                        <th>Extra Links</th>\n',
251      '                        <th>Tags</th>\n',
252      '                    </tr>\n',
253      '                </thead>\n',
254      '                <tbody>\n']
255     puzzle_list = []
256     for puzzle in rnd_puzzles:
257         if puzzle['type'] == 'meta':
258             meta = ' [META]'
259         else:
260             meta = ''
261         slack_url = channel_url(puzzle['channel_id'])
262
263         if puzzle['status'] == 'solved':
264             puzzle_list += [ '                    <tr>\n',
265              '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
266              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
267              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
268              '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
269              '                        <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
270             # '                        <td></td>\n',
271              '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
272              '                    </tr>\n']
273         else:
274             puzzle_list += [ '                    <tr>\n',
275              '                        <td><b>{}</b></td>\n'.format(elink(slack_url, puzzle['name']+meta)),
276              '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
277              '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
278              '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
279              '                        <td></td>\n',
280             # '                        <td></td>\n',
281              '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
282              '                    </tr>\n']
283     end = ['                </tbody>\n',
284     '            </table>\n',
285     '        </div>\n',
286     '    </body>\n',
287     '</html>\n']
288     html = start + puzzle_list + end
289     file = hunt_file(hunt, "{}_round.html".format(filename_from_name(rnd)))
290     f = open(file + ".tmp", "w")
291     for line in html:
292         f.write(line)
293     f.close()
294     os.rename(file + ".tmp", file)
295     return None
296
297 def puzzle_overview(hunt, puzzle):
298     #overview page for individual puzzles. saves as (name of puzzle).html,
299     #with underscores rather than spaces
300     name = puzzle['name']
301     if puzzle['type'] == 'meta':
302         meta = ' [META]'
303     else:
304         meta = ''
305     slack_url = channel_url(puzzle['channel_id'])
306     if 'rounds' in puzzle:
307         round_url = [link(website + filename_from_name(rnd) + "_round.html", rnd) for rnd in puzzle['rounds']]
308     else:
309         round_url = ''
310     if puzzle['status'] == 'solved':
311         solution = ", ".join(puzzle['solution']).upper()
312         status = 'solved'
313     else:
314         solution = ""
315         status = 'unsolved'
316     html = ['<!DOCTYPE html>\n',
317      '<html>\n',
318      '<head>\n',
319      '    <meta charset="utf-8">\n',
320      '    <meta name="viewport" content="width=device-width, initial-scale=1">\n',
321      '    <link rel="stylesheet" href="/individual.css">\n',
322      '    <title>{}</title>\n'.format(name+meta),
323      '    <p>{}</p>'.format(link(website + 'index.html', 'Hunt Overview')),
324      '\n',
325      '</head>\n',
326      '<body class="{}">\n'.format(status),
327      '    <h1>{}</h1>\n'.format(name+meta),
328      '    <div>\n',
329      '        <table class="center">\n',
330      '            <tr>\n',
331      '                <td>{}</td>\n'.format(elink(slack_url, 'Channel')), #slack channel
332      '                <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
333      '                <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
334      '                <td>Additional Resources</td>\n',
335      '            </tr>\n',
336      '        </table>\n',
337      '        <table class="center">\n',
338      '            <tr>\n',
339      '                <td>Round(s): {}</td>\n'.format(" ".join(round_url)), #round page on our site
340      '                <td>Tags: {}</td>\n'.format(", ".join(puzzle.get('tags',[]))), #add tags
341      '            </tr>\n',
342      '            <tr>\n',
343      '                <td>Answer: {}</td>\n'.format(solution),
344      '                <td>State: {}</td>\n'.format(puzzle['status']),
345      '            </tr>\n',
346      '        </table>\n',
347      '    </div>\n',
348      '\n',
349      '</body>\n',
350      '</html>\n']
351     file = hunt_file(hunt, "{}.html".format(filename_from_name(name)))
352     f = open(file + ".tmp", "w")
353     for line in html:
354         f.write(line)
355     f.close()
356     os.rename(file + ".tmp", file)
357     return None
358
359 def puzzle_lists(hunt, puzzles, filt):
360     #filt is one of "All", "Solved", "Unsolved" and spits out the appropriate list of puzzles
361     #generates pages for all puzzles, solved puzzles, unsolved puzzles
362     #saves as all/solved/unsolved.html, has a sidebar to link to each other and to the big board
363     solved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] == 'solved']
364     unsolved_puzzles = [puzzle for puzzle in puzzles if puzzle['status'] != 'solved']
365     start = ['<html>\n',
366      '    <head>\n',
367      '        <link rel="stylesheet" href="/overview.css">\n',
368      '        <title>Mystery Hunt 2022</title>\n',
369      '        <script src="/sorttable.js"></script>\n',
370      '    </head>\n',
371      '    <div class="sidenav">\n'
372      '      <a href="index.html">Hunt Overview</a>'
373      '      <a href="all.html">All Puzzles</a>\n'
374      '      <a href="unsolved.html">Unsolved</a>\n'
375      '      <a href="solved.html">Solved</a>\n'
376      '    </div>\n'
377      '    <body>\n',
378      '        <h1><b>{}</b></h1>\n'.format('{} Puzzles').format(filt),
379      '        <p>{}</p>\n'.format(link(website + "index.html", 'Hunt Overview')),
380      '        <div>\n',
381      '            <table class="center sortable">\n',
382      '                <thead>\n',
383      '                    <tr>\n',
384      '                        <th>Puzzle Title/Slack</th>\n',
385      '                        <th>Puzzle Link</th>\n',
386      '                        <th>Sheet</th>\n',
387      '                        <th>Overview</th>\n',
388      '                        <th>Answer</th>\n',
389      '                        <th>Round(s)</th>\n',
390      '                        <th>Tags</th>\n',
391      '                    </tr>\n',
392      '                </thead>\n',
393      '                <tbody>\n']
394     solved_code = []
395     unsolved_code = []
396     for puzzle in solved_puzzles:
397         if puzzle['type'] == 'meta':
398             meta = ' [META]'
399         else:
400             meta = ''
401         slack_url = channel_url(puzzle['channel_id'])
402         if 'rounds' in puzzle:
403             round_url = link(website + filename_from_name(puzzle['rounds'][0]) + "_round.html", puzzle['rounds'][0])
404         else:
405             round_url = ''
406         #assuming one round per puzzle for now
407
408         solved_code += ['                    <tr>\n',
409          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
410          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
411          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
412          '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
413          '                        <td>{}</td>\n'.format(", ".join(puzzle['solution']).upper()),
414          '                        <td>{}</td>\n'.format(round_url),
415          '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
416          '                    </tr>\n']
417     for puzzle in unsolved_puzzles:
418         if puzzle['type'] == 'meta':
419             meta = ' [META]'
420         else:
421             meta = ''
422         slack_url = channel_url(puzzle['channel_id'])
423         if 'rounds' in puzzle:
424             round_url = link(website + filename_from_name(puzzle['rounds'][0]) + "_round.html", puzzle['rounds'][0])
425         else:
426             round_url = ''
427         #assuming one round per puzzle for now
428
429         unsolved_code += ['                    <tr>\n',
430          '                        <td>{}</td>\n'.format(elink(slack_url, puzzle['name']+meta)),
431          '                        <td>{}</td>\n'.format(elink(puzzle.get('url',''), 'Puzzle')),
432          '                        <td>{}</td>\n'.format(elink(puzzle['sheet_url'], 'Sheet')),
433          '                        <td>{}</td>\n'.format(link(website + filename_from_name(puzzle['name']) + '.html', 'Overview')),
434          '                        <td></td>\n',
435          '                        <td>{}</td>\n'.format(round_url),
436          '                        <td>{}</td>\n'.format(", ".join(puzzle.get('tags',[]))),
437          '                    </tr>\n']
438     end = ['                </tbody>\n',
439     '            </table>\n',
440     '        </div>\n',
441     '    </body>\n',
442     '</html>\n']
443     if filt == "All":
444         file1 = hunt_file(hunt, 'all.html')
445         f = open(file1 + ".tmp", "w")
446         for line in start + unsolved_code + solved_code + end:
447             f.write(line)
448         f.close()
449         os.rename(file1 + ".tmp", file1)
450     elif filt == "Solved":
451         file2 = hunt_file(hunt, 'solved.html')
452         f = open(file2 + ".tmp", 'w')
453         for line in start + solved_code + end:
454             f.write(line)
455         f.close()
456         os.rename(file2 + ".tmp", file2)
457     elif filt == "Unsolved":
458         file3 = hunt_file(hunt, 'unsolved.html')
459         f = open(file3 + ".tmp", 'w')
460         for line in start + unsolved_code + end:
461             f.write(line)
462         f.close()
463         os.rename(file3 + ".tmp", file3)
464     return None
465
466 def generate_for_hunt_id(table, hunt_id):
467     hunt, puzzles, rounds = hunt_info(table, hunt_id)
468
469     # Create a directory for the hunt in the WEBROOT
470     root = hunt_file(hunt, "")
471     try:
472         os.mkdir(root)
473     except FileExistsError:
474         #  We're happy as a clam if the directory already exists
475         pass
476
477     overview(hunt, puzzles, rounds)
478     for rnd in rounds:
479         round_overview(hunt, rnd, puzzles)
480         for puzzle in puzzles:
481             puzzle_overview(hunt, puzzle)
482     puzzle_lists(hunt, puzzles, "All")
483     puzzle_lists(hunt, puzzles, "Solved")
484     puzzle_lists(hunt, puzzles, "Unsolved")
485             
486
487 # Initialize AWS resources to talk to the database
488 db = boto3.resource('dynamodb')
489 table = db.Table("turbot")
490
491 def usage():
492     print("Usage: {} hunt_id [...]")
493     print("")
494     print("Generates pages (under {}) ".format(WEBROOT))
495     print("for the specified hunt_id(s).")
496
497 if len(sys.argv) < 2:
498     usage()
499     sys.exit(1)
500
501 for hunt_id in sys.argv[1:]:
502     generate_for_hunt_id(table, hunt_id)