Spaces:
Sleeping
Sleeping
| """``/api/daily-challenge`` — Daily random challenge with 2× XP. | |
| Every 24 hours a random challenge is picked from the pool. Students | |
| who complete it get double XP (base + bonus via ``/claim``). | |
| """ | |
| import random | |
| from datetime import datetime, date, timedelta, timezone | |
| from typing import Optional | |
| import httpx | |
| from fastapi import APIRouter, Depends, HTTPException, Request | |
| from pydantic import BaseModel | |
| from app.core.auth import get_current_user | |
| from app.core.config import SUPABASE_URL, SUPABASE_ANON_KEY, SUPABASE_EDGE_URL | |
| from app.services.supabase_service import supabase_headers | |
| router = APIRouter() | |
| # Per-type tables we can pick a daily challenge from. | |
| _TABLES: list[tuple[str, str, str]] = [ | |
| ("encryption_challenges", "crypto", "red"), | |
| ("code_fixing_challenges", "code-fixing", "blue"), | |
| ("log_analysis_challenges", "log-analysis", "blue"), | |
| ("vulnerability_hunter_challenges", "vulnerability-hunter", "blue"), | |
| ("web_exploitation_challenges", "web-exploitation", "red"), | |
| ] | |
| class DailyChallengeResponse(BaseModel): | |
| challengeId: str | |
| challengeType: str | |
| teamRole: str | |
| title: str | |
| difficulty: str | |
| xpReward: int | |
| xpMultiplier: int = 2 | |
| timeRemaining: int # seconds until midnight | |
| completed: bool = False | |
| class ClaimRequest(BaseModel): | |
| challengeId: str | |
| xpAmount: int = 0 | |
| class ClaimResponse(BaseModel): | |
| bonus: int | |
| # --------------------------------------------------------------------------- # | |
| # GET /api/daily-challenge # | |
| # --------------------------------------------------------------------------- # | |
| async def get_daily_challenge(user: dict = Depends(get_current_user)): | |
| today = date.today() | |
| dc = await _fetch_today_daily_challenge(today) | |
| if dc: | |
| challenge_info = await _fetch_challenge_info( | |
| dc["challenge_id"], dc["challenge_type"], dc["team_role"] | |
| ) | |
| if challenge_info: | |
| remaining = _seconds_until_midnight() | |
| completed = await _check_user_completed(user["user_id"], today) | |
| return DailyChallengeResponse( | |
| challengeId=dc["challenge_id"], | |
| challengeType=dc["challenge_type"], | |
| teamRole=dc["team_role"], | |
| title=challenge_info["title"], | |
| difficulty=challenge_info["difficulty"], | |
| xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])), | |
| timeRemaining=remaining, | |
| completed=completed, | |
| ) | |
| # No daily challenge for today yet — pick one. | |
| dc = await _pick_daily_challenge(today) | |
| if not dc: | |
| raise HTTPException(status_code=404, detail="لا توجد تحديات متاحة للتحدي اليومي") | |
| challenge_info = await _fetch_challenge_info( | |
| dc["challenge_id"], dc["challenge_type"], dc["team_role"] | |
| ) | |
| if not challenge_info: | |
| raise HTTPException(status_code=404, detail="التحدي غير متاح حالياً") | |
| remaining = _seconds_until_midnight() | |
| completed = await _check_user_completed(user["user_id"], today) | |
| return DailyChallengeResponse( | |
| challengeId=dc["challenge_id"], | |
| challengeType=dc["challenge_type"], | |
| teamRole=dc["team_role"], | |
| title=challenge_info["title"], | |
| difficulty=challenge_info["difficulty"], | |
| xpReward=int(dc.get("xp_reward", challenge_info["xp_reward"])), | |
| timeRemaining=remaining, | |
| completed=completed, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # POST /api/daily-challenge/claim # | |
| # --------------------------------------------------------------------------- # | |
| async def claim_daily_challenge( | |
| req: ClaimRequest, | |
| request: Request, | |
| user: dict = Depends(get_current_user), | |
| ): | |
| today = date.today() | |
| dc = await _fetch_today_daily_challenge(today) | |
| if not dc or dc["challenge_id"] != req.challengeId: | |
| return ClaimResponse(bonus=0) | |
| bonus = req.xpAmount or int(dc.get("xp_reward", 150)) | |
| if bonus <= 0: | |
| return ClaimResponse(bonus=0) | |
| async with httpx.AsyncClient(timeout=30) as client: | |
| resp = await client.post( | |
| f"{SUPABASE_EDGE_URL}/apex-xp", | |
| json={"action": "add_xp", "user_id": user["user_id"], "xp_amount": bonus}, | |
| ) | |
| if resp.status_code != 200: | |
| raise HTTPException(status_code=502, detail="فشل إضافة نقاط XP") | |
| # Mark as completed for this user (idempotent via UNIQUE) | |
| await _mark_user_completed(user["user_id"], today, req.challengeId) | |
| return ClaimResponse(bonus=bonus) | |
| # --------------------------------------------------------------------------- # | |
| # Internal helpers # | |
| # --------------------------------------------------------------------------- # | |
| async def _check_user_completed(user_id: str, today: date) -> bool: | |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return False | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/user_daily_completions" | |
| f"?user_id=eq.{user_id}&active_date=eq.{today.isoformat()}&limit=1" | |
| ) | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(url, headers=supabase_headers()) | |
| if r.status_code == 200: | |
| return len(r.json()) > 0 | |
| except Exception as e: | |
| print(f"[daily_challenge] check_completed error: {e}") | |
| return False | |
| async def _mark_user_completed(user_id: str, today: date, challenge_id: str): | |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return | |
| url = f"{SUPABASE_URL}/rest/v1/user_daily_completions" | |
| headers = supabase_headers(content_type=True) | |
| headers["Prefer"] = "return=minimal,resolution=ignore-duplicates" | |
| payload = { | |
| "user_id": user_id, | |
| "active_date": today.isoformat(), | |
| "challenge_id": challenge_id, | |
| } | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| await client.post(url, json=payload, headers=headers) | |
| except Exception as e: | |
| print(f"[daily_challenge] mark_completed error: {e}") | |
| async def _fetch_today_daily_challenge(today: date) -> Optional[dict]: | |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return None | |
| url = ( | |
| f"{SUPABASE_URL}/rest/v1/daily_challenges" | |
| f"?active_date=eq.{today.isoformat()}&limit=1" | |
| f"&order=created_at.desc" | |
| ) | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(url, headers=supabase_headers()) | |
| if r.status_code == 200: | |
| rows = r.json() | |
| if rows: | |
| return rows[0] | |
| except Exception as e: | |
| print(f"[daily_challenge] fetch error: {e}") | |
| return None | |
| async def _pick_daily_challenge(today: date) -> Optional[dict]: | |
| if not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return None | |
| candidates = [] | |
| for table, ctype, team in _TABLES: | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get( | |
| f"{SUPABASE_URL}/rest/v1/{table}" | |
| f"?select=id,title,difficulty,xp_reward" | |
| f"&order=created_at.desc&limit=50", | |
| headers=supabase_headers(), | |
| ) | |
| if r.status_code == 200: | |
| rows = r.json() | |
| for row in rows: | |
| candidates.append((row, ctype, team)) | |
| except Exception as e: | |
| print(f"[daily_challenge] {table}: {e}") | |
| continue | |
| if not candidates: | |
| return None | |
| row, ctype, team = random.choice(candidates) | |
| xp_reward = int(row.get("xp_reward") or 150) | |
| payload = { | |
| "challenge_id": row["id"], | |
| "challenge_type": ctype, | |
| "team_role": team, | |
| "xp_reward": xp_reward, | |
| "active_date": today.isoformat(), | |
| } | |
| url = f"{SUPABASE_URL}/rest/v1/daily_challenges" | |
| headers = supabase_headers(content_type=True) | |
| headers["Prefer"] = "return=representation,resolution=ignore-duplicates" | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.post(url, json=payload, headers=headers) | |
| if r.status_code in (200, 201): | |
| rows = r.json() | |
| if rows: | |
| return rows[0] | |
| else: | |
| print(f"[daily_challenge] insert failed: {r.status_code} {r.text[:200]}") | |
| except Exception as e: | |
| print(f"[daily_challenge] insert exception: {e}") | |
| return payload # best-effort return | |
| async def _fetch_challenge_info( | |
| challenge_id: str, challenge_type: str, team_role: str | |
| ) -> Optional[dict]: | |
| table = { | |
| "crypto": "encryption_challenges", | |
| "code-fixing": "code_fixing_challenges", | |
| "log-analysis": "log_analysis_challenges", | |
| "vulnerability-hunter": "vulnerability_hunter_challenges", | |
| "web-exploitation": "web_exploitation_challenges", | |
| }.get(challenge_type) | |
| if not table or not SUPABASE_URL or not SUPABASE_ANON_KEY: | |
| return None | |
| url = f"{SUPABASE_URL}/rest/v1/{table}?id=eq.{challenge_id}&limit=1" | |
| try: | |
| async with httpx.AsyncClient(timeout=10) as client: | |
| r = await client.get(url, headers=supabase_headers()) | |
| if r.status_code == 200: | |
| rows = r.json() | |
| if rows: | |
| return rows[0] | |
| except Exception as e: | |
| print(f"[daily_challenge] fetch_challenge_info: {e}") | |
| return None | |
| def _seconds_until_midnight() -> int: | |
| now = datetime.now(timezone.utc) | |
| midnight = datetime(now.year, now.month, now.day, tzinfo=timezone.utc).replace( | |
| hour=0, minute=0, second=0 | |
| ) + timedelta(days=1) | |
| return int((midnight - now).total_seconds()) | |