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