1 from turbot.blocks import (
2 section_block, text_block, divider_block, accessory_block, button_block
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
9 def find_hunt_for_hunt_id(turb, hunt_id):
10 """Given a hunt ID find the database item for that hunt
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.).
17 response = turb.table.get_item(
20 'SK': 'hunt-{}'.format(hunt_id)
23 if 'Item' in response:
24 return response['Item']
28 def hunt_puzzles_for_hunt_id(turb, hunt_id):
29 """Return all puzzles that belong to the given hunt_id"""
31 response = turb.table.query(
32 KeyConditionExpression=(
33 Key('hunt_id').eq(hunt_id) &
34 Key('SK').begins_with('puzzle-')
38 return response['Items']
40 def hunt_blocks(turb, hunt, puzzle_status='unsolved', search_terms=[],
41 limit_to_rounds=None):
42 """Generate Slack blocks for a hunt
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
48 The hunt argument should be a dictionary as returned from the
51 Three optional arguments can be used to filter which puzzles to
52 include in the result:
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.
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
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
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.).
81 hunt_id = hunt['hunt_id']
82 channel_id = hunt['channel_id']
84 puzzles = hunt_puzzles_for_hunt_id(turb, hunt_id)
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]
93 puzzles = [puzzle for puzzle in puzzles
94 if puzzle_matches_all(puzzle, search_terms)]
96 # Compute the set of rounds across all puzzles
98 for puzzle in puzzles:
99 if 'rounds' not in puzzle:
101 for round in puzzle['rounds']:
104 hunt_text = "*{} puzzles in hunt <{}|{}>*".format(
105 puzzle_status.capitalize(),
106 channel_url(channel_id),
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>"
115 quoted_terms = ['`{}`'.format(term) for term in search_terms]
116 hunt_text += " matching {}".format(" AND ".join(quoted_terms))
121 section_block(text_block(hunt_text)),
122 button_block("✏", "edit_hunt", hunt_id)
129 text = "No puzzles found."
130 if puzzle_status != 'all':
131 text += ' (Consider searching for "all" puzzles?)'
133 section_block(text_block(text))
136 # Construct blocks for each round
138 if limit_to_rounds is not None and round not in limit_to_rounds:
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)
144 block += round_blocks(round, puzzles)
145 block.append(divider_block())
149 # Also blocks for any puzzles not in any round
150 stray_puzzles = [puzzle for puzzle in puzzles if 'rounds' not in puzzle]
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)
163 block.append(divider_block())