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