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
« prev ^ index » next coverage.py v7.14.0, created at 2026-05-12 04:49 +0000
1"""Public Flask endpoints for the Hive Contest.
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
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)."""
13from __future__ import annotations
15import logging
17from flask import Blueprint, jsonify, request, g
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
31logger = logging.getLogger(__name__)
33hive_contest_bp = Blueprint(
34 'hive_contest', __name__, url_prefix='/api/hive/contest'
35)
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
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()})
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
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()
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:
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')
91@hive_contest_bp.route('/ideas', methods=['GET'])
92def contest_ideas_list():
93 """Public: list submitted contest ideas.
95 Query: ?track=digital|embodied|human_wellness (optional)
96 &limit=50 (max 200)
97 &since=<iso> (incremental)
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
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()
125@hive_contest_bp.route('/ideas', methods=['POST'])
126@require_auth
127def contest_ideas_submit():
128 """Idempotent-ish: submit a contest idea.
130 Body:
131 { "title": "...",
132 "description": "...",
133 "track": "digital" | "embodied" | "human_wellness",
134 "source": "ui" | "nunba_agent" | "mcp_agent" }
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'
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
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()
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.
179 Event names:
180 contest.idea_submitted — payload = {post_id, track, title,
181 preview, user_id, source, spark_awarded}
182 ping — keepalive every 15s
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
192 q: _queue.Queue = _queue.Queue(maxsize=100)
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
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
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}')
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
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)
256@hive_contest_bp.route('/join', methods=['POST'])
257@require_auth
258def contest_join():
259 """Idempotent: register the authenticated user for the contest.
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
271 user_id = getattr(g.user, 'id', None)
272 if not user_id:
273 return jsonify({'error': 'auth required'}), 401
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()