]> git.cworth.org Git - turbot/blob - turbot/hunt.py
5bbbbc6cd6e77f72aabd1717b7b83f2dff6c44c7
[turbot] / turbot / hunt.py
1 from turbot.blocks import section_block, text_block, divider_block
2 from turbot.round import round_blocks
3 from turbot.puzzle import puzzle_blocks, puzzle_matches_all
4 from turbot.channel import channel_url
5 from boto3.dynamodb.conditions import Key
6
7 def find_hunt_for_hunt_id(turb, hunt_id):
8     """Given a hunt ID find the database item for that hunt
9
10     Returns None if hunt ID is not found, otherwise a
11     dictionary with all fields from the hunt's row in the table,
12     (channel_id, active, hunt_id, name, url, sheet_url, etc.).
13     """
14
15     response = turb.table.get_item(
16         Key={
17             'hunt_id': hunt_id,
18             'SK': 'hunt-{}'.format(hunt_id)
19         })
20
21     if 'Item' in response:
22         return response['Item']
23     else:
24         return None
25
26 def hunt_puzzles_for_hunt_id(turb, hunt_id):
27     """Return all puzzles that belong to the given hunt_id"""
28
29     response = turb.table.query(
30         KeyConditionExpression=(
31             Key('hunt_id').eq(hunt_id) &
32             Key('SK').begins_with('puzzle-')
33         )
34     )
35
36     return response['Items']
37
38 def hunt_blocks(turb, hunt, puzzle_status='unsolved', search_terms=[],
39                 limit_to_rounds=None):
40     """Generate Slack blocks for a hunt
41
42     The hunt argument should be a dictionary as returned from the
43     database.
44
45     Three optional arguments can be used to filter which puzzles to
46     include in the result:
47
48       puzzle_status: If either 'solved' or 'unsolved' only puzzles
49                      with that status will be included in the
50                      result. If any other value, all puzzles in the
51                      hunt will be considered.
52
53       search_terms: A list of search terms. Only puzzles that match
54                     all of these terms will be included in the
55                     result. A match will be considered on any of
56                     puzzle title, round title, puzzle URL, puzzle
57                     state, puzzle type, tags, or solution
58                     string. Terms can include regular expression
59                     syntax.
60
61       limit_to_rounds: A list of rounds. If provided only the given
62                        rounds will be included in the output. Note:
63                        an empty list means to display only puzzles
64                        assigned to no rounds, while an argument of
65                        None means to display all puzzles with no
66                        limit on the rounds.
67
68     The return value can be used in a Slack command expecting blocks to
69     provide all the details of a hunt, (puzzles, their state,
70     solution, links to channels and sheets, etc.).
71
72     """
73
74     name = hunt['name']
75     hunt_id = hunt['hunt_id']
76     channel_id = hunt['channel_id']
77
78     puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
79
80     # Filter the set of puzzles according the the requested puzzle_status
81     if puzzle_status in ('solved', 'unsolved'):
82         puzzles = [p for p in puzzles if p['status'] == puzzle_status]
83     else:
84         puzzle_status = 'all'
85
86     if search_terms:
87         puzzles = [puzzle for puzzle in puzzles
88                    if puzzle_matches_all(puzzle, search_terms)]
89
90     # Compute the set of rounds across all puzzles
91     rounds = set()
92     for puzzle in puzzles:
93         if 'rounds' not in puzzle:
94             continue
95         for round in puzzle['rounds']:
96             rounds.add(round)
97
98     hunt_text = "*{} puzzles in hunt <{}|{}>*".format(
99         puzzle_status.capitalize(),
100         channel_url(channel_id),
101         name
102     )
103     if limit_to_rounds is not None:
104         hunt_text += " *in round{}: {}*".format(
105             "s" if len(limit_to_rounds) > 1 else "",
106             ", ".join(limit_to_rounds) if limit_to_rounds else "<no round>"
107         )
108     if search_terms:
109         quoted_terms = ['`{}`'.format(term) for term in search_terms]
110         hunt_text += " matching {}".format(" AND ".join(quoted_terms))
111
112     blocks = [
113         section_block(text_block(hunt_text)),
114     ]
115
116     if not len(puzzles):
117         text = "No puzzles found."
118         if puzzle_status != 'all':
119             text += ' (Consider searching for "all" puzzles?)'
120         blocks += [
121             section_block(text_block(text))
122         ]
123
124     # Construct blocks for each round
125     for round in rounds:
126         if limit_to_rounds is not None and round not in limit_to_rounds:
127             continue
128         # If we're only displaying one round the round header is redundant
129         if limit_to_rounds and len(limit_to_rounds) == 1:
130             blocks += round_blocks(round, puzzles, omit_header=True)
131         else:
132             blocks += round_blocks(round, puzzles)
133         blocks.append(divider_block())
134
135     # Also blocks for any puzzles not in any round
136     stray_puzzles = [puzzle for puzzle in puzzles if 'rounds' not in puzzle]
137
138     # For this condition, either limit_to_rounds is None which
139     # means we definitely want to display these stray puzzles
140     # (since we are not limiting), _OR_ limit_to_rounds is not
141     # None but is a zero-length array, meaning we are limiting
142     # to rounds but specifically the round of unassigned puzzles
143     if len(stray_puzzles) and not limit_to_rounds:
144         stray_text = "*Puzzles with no assigned round*"
145         blocks.append(section_block(text_block(stray_text)))
146         for puzzle in stray_puzzles:
147             blocks += puzzle_blocks(puzzle)
148
149     blocks.append(divider_block())
150
151     return blocks