Coverage for integrations / social / api_hive_contest.py: 20.0%

125 statements  

« prev     ^ index     » next       coverage.py v7.14.0, created at 2026-05-12 04:49 +0000

1"""Public Flask endpoints for the Hive Contest. 

2 

3Surface: 

4 GET /api/hive/contest/info — rules, tracks, onramp 

5 GET /api/hive/contest/leaderboard — ranked entries 

6 GET /api/hive/contest/claude-code.mcp — paste-ready MCP snippet 

7 POST /api/hive/contest/join — idempotent registration 

8 

9All endpoints are public except POST /join which needs an authenticated 

10user (the @require_auth decorator matches the pattern used by 

11api_audit.py — no new auth mechanism, no parallel path).""" 

12 

13from __future__ import annotations 

14 

15import logging 

16 

17from flask import Blueprint, jsonify, request, g 

18 

19from integrations.agent_engine.hive_contest import ( 

20 ContestTrack, 

21 claude_code_mcp_snippet, 

22 get_contest_info, 

23 get_leaderboard, 

24 list_ideas, 

25 register_participant, 

26 submit_idea, 

27) 

28from integrations.social.models import get_db 

29from integrations.social.auth import require_auth 

30 

31logger = logging.getLogger(__name__) 

32 

33hive_contest_bp = Blueprint( 

34 'hive_contest', __name__, url_prefix='/api/hive/contest' 

35) 

36 

37 

38def _parse_track(raw) -> ContestTrack | None: 

39 if not raw: 

40 return None 

41 try: 

42 return ContestTrack(raw.lower()) 

43 except ValueError: 

44 return None 

45 

46 

47@hive_contest_bp.route('/info', methods=['GET']) 

48def contest_info(): 

49 """Public: rules, tracks, dates, how-to-join, Claude Code snippet.""" 

50 return jsonify({'data': get_contest_info()}) 

51 

52 

53@hive_contest_bp.route('/leaderboard', methods=['GET']) 

54def contest_leaderboard(): 

55 """Public: ranked entries. Query param: 

56 ?track=digital|embodied|human_wellness (default: overall) 

57 ?limit=50 (max 200) 

58 """ 

59 track = _parse_track(request.args.get('track')) 

60 try: 

61 limit = min(int(request.args.get('limit', 50)), 200) 

62 except (TypeError, ValueError): 

63 limit = 50 

64 

65 db = get_db() 

66 try: 

67 rows = get_leaderboard(db, track=track, limit=limit) 

68 return jsonify({ 

69 'data': rows, 

70 'meta': { 

71 'track': track.value if track else 'overall', 

72 'count': len(rows), 

73 }, 

74 }) 

75 finally: 

76 db.close() 

77 

78 

79@hive_contest_bp.route('/claude-code.mcp', methods=['GET']) 

80def contest_mcp_snippet(): 

81 """Public paste-ready snippet for Claude Code -> HARTOS MCP. 

82 Served as text/plain so the user can pipe it straight into 

83 their settings file: 

84 

85 curl -s $HOST/api/hive/contest/claude-code.mcp > ~/.config/claude-code/settings.json 

86 """ 

87 from flask import Response 

88 return Response(claude_code_mcp_snippet(), mimetype='text/plain') 

89 

90 

91@hive_contest_bp.route('/ideas', methods=['GET']) 

92def contest_ideas_list(): 

93 """Public: list submitted contest ideas. 

94 

95 Query: ?track=digital|embodied|human_wellness (optional) 

96 &limit=50 (max 200) 

97 &since=<iso> (incremental) 

98 

99 Consumed by: 

100 - hevolve.ai's floating-ideas page (initial fill) 

101 - HARTOS local /hive-contest page (grid render) 

102 - Nunba contest curator (shows 'similar ideas' while a user drafts) 

103 """ 

104 track = _parse_track(request.args.get('track')) 

105 try: 

106 limit = min(int(request.args.get('limit', 50)), 200) 

107 except (TypeError, ValueError): 

108 limit = 50 

109 since = (request.args.get('since') or '').strip() or None 

110 

111 db = get_db() 

112 try: 

113 rows = list_ideas(db, track=track, limit=limit, since_iso=since) 

114 return jsonify({ 

115 'data': rows, 

116 'meta': { 

117 'track': track.value if track else 'all', 

118 'count': len(rows), 

119 }, 

120 }) 

121 finally: 

122 db.close() 

123 

124 

125@hive_contest_bp.route('/ideas', methods=['POST']) 

126@require_auth 

127def contest_ideas_submit(): 

128 """Idempotent-ish: submit a contest idea. 

129 

130 Body: 

131 { "title": "...", 

132 "description": "...", 

133 "track": "digital" | "embodied" | "human_wellness", 

134 "source": "ui" | "nunba_agent" | "mcp_agent" } 

135 

136 The Nunba contest curator POSTs here on behalf of the user after 

137 the conversational capture completes. Clicking "Submit" on the 

138 local /hive-contest page POSTs with source='ui'. A Claude Code 

139 plugin can POST via MCP with source='mcp_agent'. 

140 """ 

141 body = request.get_json(silent=True) or {} 

142 title = (body.get('title') or '').strip() 

143 description = (body.get('description') or '').strip() 

144 track = _parse_track(body.get('track')) or ContestTrack.DIGITAL 

145 source = (body.get('source') or 'ui').strip().lower() 

146 if source not in ('ui', 'nunba_agent', 'mcp_agent'): 

147 source = 'ui' 

148 

149 user_id = getattr(g.user, 'id', None) 

150 if not user_id: 

151 return jsonify({'error': 'auth required'}), 401 

152 if not title or not description: 

153 return jsonify({'error': 'title and description required'}), 400 

154 

155 db = get_db() 

156 try: 

157 result = submit_idea( 

158 db, user_id=user_id, title=title, 

159 description=description, track=track, source=source, 

160 ) 

161 try: 

162 db.commit() 

163 except Exception as exc: 

164 db.rollback() 

165 logger.debug(f'commit failed: {exc}') 

166 return jsonify({'error': 'db commit failed'}), 500 

167 if not result.get('ok'): 

168 return jsonify({'error': result.get('reason', 'unknown')}), 400 

169 return jsonify({'data': result}), 201 

170 finally: 

171 db.close() 

172 

173 

174@hive_contest_bp.route('/ideas/stream', methods=['GET']) 

175def contest_ideas_stream(): 

176 """Server-Sent Events feed — the Hevolve floating UI keeps this 

177 connection open and animates a new card every time an idea lands. 

178 

179 Event names: 

180 contest.idea_submitted — payload = {post_id, track, title, 

181 preview, user_id, source, spark_awarded} 

182 ping — keepalive every 15s 

183 

184 Why SSE (not WebSocket): the feed is one-way server→client. SSE 

185 works across all browsers, reconnects automatically, and piggybacks 

186 on the existing Flask/waitress server. No extra infrastructure. 

187 """ 

188 from flask import Response, stream_with_context 

189 import queue as _queue 

190 import time as _time 

191 

192 q: _queue.Queue = _queue.Queue(maxsize=100) 

193 

194 # Subscribe to the EventBus topic via the platform bus's on()/off() 

195 # API (same pattern FederatedAggregator + ThemeService use) — no 

196 # parallel pub/sub stack. Obtain the bus from the platform 

197 # ServiceRegistry; absent registry (import-only tests) → bus=None 

198 # and the stream just emits keepalives. 

199 try: 

200 from core.platform.registry import get_registry 

201 _reg = get_registry() 

202 bus = _reg.get('events') if _reg.has('events') else None 

203 except Exception: 

204 bus = None 

205 

206 # EventBus callbacks are invoked as (topic, data) — capture data only. 

207 def _on_event(_topic, payload): 

208 try: 

209 q.put_nowait(payload or {}) 

210 except _queue.Full: 

211 pass 

212 

213 subscribed = False 

214 if bus is not None: 

215 try: 

216 bus.on('contest.idea_submitted', _on_event) 

217 subscribed = True 

218 except Exception as exc: 

219 logger.debug(f'idea stream subscribe failed: {exc}') 

220 

221 def _generate(): 

222 import json as _json 

223 # Initial keepalive so the client connects cleanly 

224 yield 'event: ping\ndata: {}\n\n' 

225 last_ping = _time.time() 

226 try: 

227 while True: 

228 try: 

229 payload = q.get(timeout=5.0) 

230 yield ( 

231 f'event: contest.idea_submitted\n' 

232 f'data: {_json.dumps(payload)}\n\n' 

233 ) 

234 except _queue.Empty: 

235 pass 

236 # Keepalive every 15s so intermediate proxies don't 

237 # close the connection. 

238 if _time.time() - last_ping > 15: 

239 yield 'event: ping\ndata: {}\n\n' 

240 last_ping = _time.time() 

241 finally: 

242 if subscribed and bus is not None: 

243 try: 

244 bus.off('contest.idea_submitted', _on_event) 

245 except Exception: 

246 pass 

247 

248 headers = { 

249 'Content-Type': 'text/event-stream', 

250 'Cache-Control': 'no-cache', 

251 'X-Accel-Buffering': 'no', 

252 } 

253 return Response(stream_with_context(_generate()), headers=headers) 

254 

255 

256@hive_contest_bp.route('/join', methods=['POST']) 

257@require_auth 

258def contest_join(): 

259 """Idempotent: register the authenticated user for the contest. 

260 

261 Body: 

262 { "track": "digital" | "embodied" | "human_wellness", 

263 "github": "optional-handle", 

264 "email": "optional" } 

265 """ 

266 body = request.get_json(silent=True) or {} 

267 track = _parse_track(body.get('track')) or ContestTrack.DIGITAL 

268 github = (body.get('github') or '').strip() or None 

269 email = (body.get('email') or '').strip() or None 

270 

271 user_id = getattr(g.user, 'id', None) 

272 if not user_id: 

273 return jsonify({'error': 'auth required'}), 401 

274 

275 db = get_db() 

276 try: 

277 result = register_participant( 

278 db, user_id=user_id, track=track, 

279 github_handle=github, email=email, 

280 ) 

281 try: 

282 db.commit() 

283 except Exception as exc: # pragma: no cover 

284 db.rollback() 

285 logger.debug(f'commit failed: {exc}') 

286 return jsonify({'data': result}) 

287 finally: 

288 db.close()