Repository navigation
Expand file tree
/
Copy pathmain.py
More file actions
1366 lines (1175 loc) · 56.7 KB
/
Copy pathmain.py
File metadata and controls
1366 lines (1175 loc) · 56.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
import asyncio
import httpx
from contextlib import asynccontextmanager
from typing import List, Optional, Dict
from fastapi import FastAPI, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from datetime import datetime, timedelta
from jose import JWTError, jwt
from passlib.context import CryptContext
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
import os
import re
import time
import uuid
from google.cloud.firestore_v1.base_query import FieldFilter
from google.api_core.exceptions import AlreadyExists
from database import db
# Configurations
SECRET_KEY = "mysecretkey_change_in_production"
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = 300
pwd_context = CryptContext(schemes=["bcrypt_sha256", "bcrypt"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token")
SWEEP_INTERVAL_SECONDS = 30
MAX_CODE_CHARS = 100_000 # stored with every submission; keeps documents far below Firestore's 1 MiB limit
TIMED_GRACE_SECONDS = 15 # Timed mode: lets the auto-submit fired at a problem's deadline still land
EXECUTOR_URL = os.getenv("EXECUTOR_URL", "https://sanjaymarathi-compicode-executor.hf.space").rstrip("/")
EXECUTOR_SPACE = os.getenv("EXECUTOR_SPACE", "sanjaymarathi/compicode-executor")
EXECUTOR_KEEPALIVE_SECONDS = 30 * 60 # free Spaces sleep after 48h without traffic
EXECUTOR_WAKE_SECONDS = 90 # how long a submission waits for a sleeping executor to boot
MAIN_LOOP: Optional[asyncio.AbstractEventLoop] = None
@asynccontextmanager
async def lifespan(_app: FastAPI):
global MAIN_LOOP
MAIN_LOOP = asyncio.get_running_loop()
sweeper = asyncio.create_task(contest_sweeper())
keepalive = asyncio.create_task(executor_keepalive())
try:
yield
finally:
sweeper.cancel()
keepalive.cancel()
app = FastAPI(title="CompiCode", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# --- Auth Helpers ---
def verify_password(plain_password, hashed_password):
try:
return pwd_context.verify(plain_password, hashed_password)
except ValueError:
return False
def get_password_hash(password):
try:
return pwd_context.hash(password)
except ValueError:
raise HTTPException(status_code=400, detail="Invalid password format")
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
to_encode = data.copy()
expire = datetime.utcnow() + (expires_delta if expires_delta else timedelta(minutes=600))
to_encode.update({"exp": expire})
return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
async def get_current_user(token: str = Depends(oauth2_scheme)):
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
)
try:
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
except JWTError:
raise credentials_exception
users_ref = db.collection("users").where(filter=FieldFilter("username", "==", username)).limit(1).stream()
user_doc = next(users_ref, None)
if not user_doc:
raise credentials_exception
user_data = user_doc.to_dict()
user_data["id"] = user_doc.id
return user_data
# --- Pydantic Schemas ---
class UserCreate(BaseModel):
username: str
password: str
class Token(BaseModel):
access_token: str
token_type: str
class TestCaseCreate(BaseModel):
input_data: str
expected_output: str
class QuestionCreate(BaseModel):
title: str
description: str
is_global: bool = False
test_cases: List[TestCaseCreate]
class ContestQuestionInfo(BaseModel):
question_id: str
points: int = 10
time_limit: int = 300
class ContestCreate(BaseModel):
title: str
description: Optional[str] = None
mode: str = "standard"
evaluation_mode: Optional[str] = "strict"
visibility: str = "public"
penalty_per_wrong_answer: int = 5
overall_time_limit: int = 60
scheduled_start_time: Optional[str] = None
selected_questions: List[ContestQuestionInfo]
class SubmitCode(BaseModel):
code: str
language: str
question_id: str
contest_id: str
class SandboxTestRequest(BaseModel):
code: str
language: str
test_cases: List[Dict]
# --- WebSocket Manager for Sudden Death ---
class ConnectionManager:
def __init__(self):
self.active_connections: Dict[str, List[WebSocket]] = {}
self.contest_state: Dict[str, dict] = {}
async def connect(self, websocket: WebSocket, contest_id: str):
await websocket.accept()
if contest_id not in self.active_connections:
self.active_connections[contest_id] = []
self.active_connections[contest_id].append(websocket)
if contest_id not in self.contest_state:
doc = db.collection("contests").document(contest_id).get()
limit_sec = 3600
initial_state = "WAITING_TO_START"
if doc.exists:
d = doc.to_dict()
limit_sec = d.get("overall_time_limit", 60) * 60
if d.get("status") == "ended":
initial_state = "CONTEST_OVER"
self.contest_state[contest_id] = {
"state": initial_state,
"current_q_idx": 0,
"active_question_id": None,
"winner": None,
"sync_timer": limit_sec,
}
await websocket.send_json({"type": "SYNC_STATE", "data": self.contest_state[contest_id]})
def disconnect(self, websocket: WebSocket, contest_id: str):
if contest_id in self.active_connections:
if websocket in self.active_connections[contest_id]:
self.active_connections[contest_id].remove(websocket)
async def broadcast(self, contest_id: str, message: dict):
if contest_id in self.active_connections:
for connection in self.active_connections[contest_id]:
try:
await connection.send_json(message)
except:
pass
def get_state(self, contest_id: str):
return self.contest_state.get(contest_id)
async def set_state(self, contest_id: str, state_updates: dict):
if contest_id in self.contest_state:
self.contest_state[contest_id].update(state_updates)
await self.broadcast(contest_id, {"type": "SYNC_STATE", "data": self.contest_state[contest_id]})
manager = ConnectionManager()
# --- Contest time-up handling ---
# A contest that has been active for longer than `overall_time_limit` minutes
# is over. Timed mode is the exception: there the limit is only the window for
# *opening* problems. A problem opened inside the window keeps its own
# countdown, so the contest stays active until the last of those runs out.
# The rule is applied lazily by every read/submit path *and* eagerly by the
# background sweeper, so a contest can no longer stay "active" just because
# nobody happened to poll it.
def _parse_ts(value) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace("Z", ""))
except ValueError:
return None
def contest_elapsed_seconds(data: dict) -> float:
started = _parse_ts(data.get("start_time"))
if not started:
return 0.0
return max(0.0, (datetime.utcnow() - started).total_seconds())
def question_start_id(contest_id: str, user_id: str, question_id: str) -> str:
return f"{contest_id}_{user_id}_{question_id}"
def timed_close_seconds(contest_id: str, data: dict) -> float:
"""Seconds after the start at which a timed contest closes for good."""
started = _parse_ts(data.get("start_time"))
def _load():
latest = data["overall_time_limit"] * 60
starts = db.collection("question_starts").where(filter=FieldFilter("contest_id", "==", contest_id)).stream()
for s in starts:
deadline = _parse_ts(s.to_dict().get("deadline"))
if deadline:
latest = max(latest, (deadline - started).total_seconds())
return latest + TIMED_GRACE_SECONDS
# Only called once the window has shut, when no new deadlines can appear.
return cached(f"timed_close:{contest_id}", 30, _load)
def contest_time_is_up(contest_id: str, data: dict) -> bool:
if data.get("status") != "active":
return False
limit = data.get("overall_time_limit")
if not limit or not data.get("start_time"):
return False
elapsed = contest_elapsed_seconds(data)
if data.get("mode") == "timed":
# Waiting out the grace first lets problems opened at the last moment land.
if elapsed < limit * 60 + TIMED_GRACE_SECONDS:
return False
return elapsed >= timed_close_seconds(contest_id, data)
return elapsed >= limit * 60
def _ended_fields(reason: str) -> dict:
return {"status": "ended", "end_reason": reason, "ended_at": datetime.utcnow().isoformat() + "Z"}
def run_async(coro):
"""Schedule a coroutine from either the event loop or a threadpool thread.
Sync route handlers run in a worker thread where asyncio.create_task() has
no running loop and raises, so fall back to the loop captured at startup.
"""
try:
asyncio.get_running_loop().create_task(coro)
except RuntimeError:
if MAIN_LOOP is not None and MAIN_LOOP.is_running():
asyncio.run_coroutine_threadsafe(coro, MAIN_LOOP)
else:
coro.close()
async def announce_contest_ended(contest_id: str, mode: Optional[str]):
state = manager.get_state(contest_id)
if mode == "sudden_death" and state:
if state["state"] not in ("CONTEST_OVER", "FINISHED"):
state["state"] = "CONTEST_OVER"
await manager.set_state(contest_id, state)
return
await manager.broadcast(contest_id, {"type": "CONTEST_ENDED"})
def expire_contest_if_needed(contest_doc, data: dict) -> bool:
"""End the contest in Firestore if its time is up. Mutates `data` to match."""
if not contest_doc.exists or not contest_time_is_up(contest_doc.id, data):
return False
contest_doc.reference.update(_ended_fields("time_up"))
data["status"] = "ended"
data["end_reason"] = "time_up"
run_async(announce_contest_ended(contest_doc.id, data.get("mode")))
return True
async def persist_contest_ended(contest_id: str, reason: str):
"""Mark a contest ended in Firestore unless something already ended it."""
def _work():
ref = db.collection("contests").document(contest_id)
snap = ref.get()
if snap.exists and snap.to_dict().get("status") == "active":
ref.update(_ended_fields(reason))
await asyncio.to_thread(_work)
def sweep_expired_contests() -> int:
ended = 0
active = db.collection("contests").where(filter=FieldFilter("status", "==", "active")).stream()
for doc in active:
if expire_contest_if_needed(doc, doc.to_dict()):
ended += 1
return ended
async def contest_sweeper():
while True:
try:
await asyncio.to_thread(sweep_expired_contests)
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[sweeper] failed: {e}")
await asyncio.sleep(SWEEP_INTERVAL_SECONDS)
_ttl_cache: Dict[str, tuple] = {}
def cached(key: str, ttl_seconds: int, loader):
now = datetime.utcnow().timestamp()
hit = _ttl_cache.get(key)
if hit and hit[0] > now:
return hit[1]
value = loader()
_ttl_cache[key] = (now + ttl_seconds, value)
return value
def username_of(user_id: Optional[str]) -> str:
if not user_id:
return "Unknown"
def _load():
doc = db.collection("users").document(user_id).get()
return doc.to_dict().get("username", "Unknown") if doc.exists else "Unknown"
return cached(f"user:{user_id}", 60, _load)
# --- Executor (a separate Hugging Face Space) ---
# Free Spaces sleep after 48 hours without traffic, and a sleeping Space answers
# with a loading page instead of results. So CompiCode pings the executor when
# it boots and whenever the site is opened, keeps pinging it while running so
# it never falls asleep, and a submission that lands mid-boot waits for it.
# With an HF_TOKEN secret it can also restart a Space that is asleep or crashed.
class ExecutorUnavailable(Exception):
pass
_executor_pinged_at = 0.0
_executor_restarted_at = 0.0
async def wake_executor(force: bool = False) -> None:
global _executor_pinged_at, _executor_restarted_at
now = time.monotonic()
if not force and now - _executor_pinged_at < 60:
return
_executor_pinged_at = now
async with httpx.AsyncClient(timeout=15.0) as client:
try:
await client.get(f"{EXECUTOR_URL}/health")
except httpx.HTTPError:
pass # still booting; the request alone starts the wake-up
token = os.getenv("HF_TOKEN")
if not token or now - _executor_restarted_at < 600:
return
try:
runtime = await client.get(f"https://huggingface.co/api/spaces/{EXECUTOR_SPACE}/runtime", follow_redirects=True)
stage = runtime.json().get("stage")
if stage in ("SLEEPING", "RUNTIME_ERROR"):
_executor_restarted_at = now
await client.post(f"https://huggingface.co/api/spaces/{EXECUTOR_SPACE}/restart",
headers={"Authorization": f"Bearer {token}"})
print(f"[executor] was {stage}; restart requested")
except (httpx.HTTPError, ValueError) as e:
print(f"[executor] restart check failed: {e}")
async def executor_keepalive():
while True:
try:
await wake_executor(force=True)
except asyncio.CancelledError:
raise
except Exception as e:
print(f"[executor] keepalive failed: {e}")
await asyncio.sleep(EXECUTOR_KEEPALIVE_SECONDS)
async def call_executor(payload: dict) -> list:
"""Judge `payload` and return its results, waiting out a cold start.
Raises httpx.TimeoutException when the executor is up but too slow, and
ExecutorUnavailable when it never came up.
"""
deadline = time.monotonic() + EXECUTOR_WAKE_SECONDS
delay = 2.0
async with httpx.AsyncClient() as client:
while True:
try:
response = await client.post(f"{EXECUTOR_URL}/evaluate", json=payload, timeout=60.0)
if response.status_code == 200:
try:
return response.json().get("results", [])
except ValueError:
problem = "it answered with a loading page"
elif response.status_code in (400, 413, 422):
raise ExecutorUnavailable(f"it rejected the request (HTTP {response.status_code})")
else:
problem = f"HTTP {response.status_code}"
except (httpx.ConnectError, httpx.RemoteProtocolError, httpx.ReadError) as e:
problem = str(e) or type(e).__name__
if time.monotonic() + delay >= deadline:
raise ExecutorUnavailable(problem)
asyncio.create_task(wake_executor())
await asyncio.sleep(delay)
delay = min(delay * 1.5, 10.0)
@app.post("/executor/wake")
async def wake_executor_endpoint():
"""The site calls this on load so a sleeping executor boots before anyone submits."""
asyncio.create_task(wake_executor())
return {"ok": True}
# --- Async Timer Loop for Sudden Death ---
async def sudden_death_timer(contest_id: str):
state = manager.get_state(contest_id)
if not state: return
break_timer = 10
state["state"] = "ROUND_OVER"
await manager.set_state(contest_id, state)
while break_timer > 0:
await asyncio.sleep(1)
state = manager.get_state(contest_id)
if not state: return
state["sync_timer"] -= 1
break_timer -= 1
await manager.broadcast(contest_id, {"type": "TIMER_TICK", "data": state["sync_timer"]})
if state["sync_timer"] <= 0:
state["state"] = "CONTEST_OVER"
await manager.set_state(contest_id, state)
await persist_contest_ended(contest_id, "time_up")
return
state = manager.get_state(contest_id)
if not state: return
state["current_q_idx"] += 1
doc = db.collection("contests").document(contest_id).get()
cqs = doc.to_dict().get("questions", []) if doc.exists else []
total_q = len(cqs)
if state["current_q_idx"] >= total_q:
state["state"] = "CONTEST_OVER"
await manager.set_state(contest_id, state)
await persist_contest_ended(contest_id, "completed")
return
active_qid = cqs[state["current_q_idx"]].get("question_id")
state["state"] = "QUESTION_ACTIVE"
state["winner"] = None
state["active_question_id"] = active_qid
await manager.set_state(contest_id, state)
asyncio.create_task(global_active_timer(contest_id, state["current_q_idx"]))
async def global_active_timer(contest_id: str, q_idx: int):
state = manager.get_state(contest_id)
while state and state["state"] == "QUESTION_ACTIVE" and state["current_q_idx"] == q_idx and state["sync_timer"] > 0:
await asyncio.sleep(1)
state = manager.get_state(contest_id)
if not state or state["state"] != "QUESTION_ACTIVE" or state["current_q_idx"] != q_idx:
return
state["sync_timer"] -= 1
await manager.broadcast(contest_id, {"type": "TIMER_TICK", "data": state["sync_timer"]})
state = manager.get_state(contest_id)
if state and state["state"] == "QUESTION_ACTIVE" and state["current_q_idx"] == q_idx and state["sync_timer"] <= 0:
state["state"] = "CONTEST_OVER"
await manager.set_state(contest_id, state)
await persist_contest_ended(contest_id, "time_up")
# --- Routes ---
@app.post("/register")
def register(user: UserCreate):
users = db.collection("users").where(filter=FieldFilter("username", "==", user.username)).limit(1).stream()
if next(users, None):
raise HTTPException(status_code=400, detail="Username already registered")
is_admin = (user.username.lower() == "admin")
db.collection("users").add({
"username": user.username,
"hashed_password": get_password_hash(user.password),
"is_admin": is_admin
})
return {"message": "User registered successfully"}
@app.post("/token", response_model=Token)
def login(form_data: OAuth2PasswordRequestForm = Depends()):
users = db.collection("users").where(filter=FieldFilter("username", "==", form_data.username)).limit(1).stream()
user_doc = next(users, None)
if not user_doc:
raise HTTPException(status_code=401, detail="Incorrect credentials")
user_data = user_doc.to_dict()
if not verify_password(form_data.password, user_data["hashed_password"]):
raise HTTPException(status_code=401, detail="Incorrect credentials")
access_token = create_access_token(data={"sub": user_data["username"]})
return {"access_token": access_token, "token_type": "bearer"}
@app.get("/me")
def get_me(current_user: dict = Depends(get_current_user)):
is_admin = current_user.get("is_admin", False) or current_user["username"].lower() == "admin"
return {"username": current_user["username"], "id": current_user["id"], "is_admin": is_admin}
@app.get("/questions")
def get_questions(current_user: dict = Depends(get_current_user)):
docs = db.collection("questions").stream()
questions = []
for doc in docs:
data = doc.to_dict()
if data.get("is_global") or data.get("creator_id") == current_user["id"]:
questions.append({"id": doc.id, "title": data.get("title"), "description": data.get("description")})
return questions
@app.post("/questions")
def create_question(question: QuestionCreate, current_user: dict = Depends(get_current_user)):
doc_ref = db.collection("questions").document()
doc_ref.set({
"title": question.title,
"description": question.description,
"is_global": question.is_global,
"creator_id": current_user["id"],
"test_cases": [{"input_data": tc.input_data, "expected_output": tc.expected_output} for tc in question.test_cases]
})
return {"message": "Question added to bank", "id": doc_ref.id}
@app.put("/questions/{q_id}")
def update_question(q_id: str, question: QuestionCreate):
doc_ref = db.collection("questions").document(q_id)
if not doc_ref.get().exists:
raise HTTPException(status_code=404, detail="Question not found")
doc_ref.update({
"title": question.title,
"description": question.description,
"test_cases": [{"input_data": tc.input_data, "expected_output": tc.expected_output} for tc in question.test_cases]
})
return {"message": "Question updated successfully"}
@app.get("/questions/{q_id}")
def get_single_question(q_id: str):
doc = db.collection("questions").document(q_id).get()
if not doc.exists:
raise HTTPException(404, "Question not found")
data = doc.to_dict()
return {
"id": doc.id,
"title": data.get("title"),
"description": data.get("description"),
"test_cases": [{"input": tc.get("input_data"), "expected": tc.get("expected_output")} for tc in data.get("test_cases", [])]
}
@app.delete("/questions/{q_id}")
def delete_question(q_id: str, current_user: dict = Depends(get_current_user)):
doc_ref = db.collection("questions").document(q_id)
if not doc_ref.get().exists:
raise HTTPException(status_code=404, detail="Question not found")
doc_ref.delete()
return {"message": "Question deleted successfully"}
@app.post("/contests")
def create_contest(contest: ContestCreate, current_user: dict = Depends(get_current_user)):
link_code = str(uuid.uuid4())[:8]
doc_ref = db.collection("contests").document()
questions = []
for sq in contest.selected_questions:
questions.append({
"question_id": sq.question_id,
"points": sq.points,
"time_limit": sq.time_limit
})
doc_ref.set({
"title": contest.title,
"description": contest.description,
"mode": contest.mode,
"evaluation_mode": contest.evaluation_mode,
"visibility": contest.visibility,
"host_id": current_user["id"],
"penalty_per_wrong_answer": contest.penalty_per_wrong_answer,
"overall_time_limit": contest.overall_time_limit,
"link_code": link_code,
"status": "waiting",
"start_time": None,
"scheduled_start_time": contest.scheduled_start_time,
"created_at": datetime.utcnow().isoformat(),
"questions": questions
})
return {"message": "Contest created!", "link_code": link_code}
def build_contest_payload(contest_doc) -> dict:
"""Shared response for the link-code and by-id contest lookups."""
data = contest_doc.to_dict()
expire_contest_if_needed(contest_doc, data)
q_data = []
for cq in data.get("questions", []):
q_doc = db.collection("questions").document(cq["question_id"]).get()
if q_doc.exists:
q = q_doc.to_dict()
q_data.append({
"id": q_doc.id,
"title": q.get("title"),
"description": q.get("description"),
"points": cq.get("points"),
"time_limit": cq.get("time_limit")
})
return {
"id": contest_doc.id,
"title": data.get("title"),
"description": data.get("description"),
"mode": data.get("mode"),
"visibility": data.get("visibility", "public"),
"status": data.get("status"),
"end_reason": data.get("end_reason"),
"start_time": data.get("start_time"),
"scheduled_start_time": data.get("scheduled_start_time"),
"server_elapsed_seconds": contest_elapsed_seconds(data),
"host_id": data.get("host_id"),
"host_name": username_of(data.get("host_id")),
"overall_time_limit": data.get("overall_time_limit"),
"penalty_per_wrong_answer": data.get("penalty_per_wrong_answer"),
"evaluation_mode": data.get("evaluation_mode", "strict"),
"questions": q_data
}
def require_host_contest(contest_id: str, current_user: dict):
doc = db.collection("contests").document(contest_id).get()
if not doc.exists:
raise HTTPException(status_code=404, detail="Contest not found")
if doc.to_dict().get("host_id") != current_user["id"]:
raise HTTPException(status_code=403, detail="Only the host can do this")
return doc
@app.get("/contests/{link_code}")
def get_contest(link_code: str):
contests = db.collection("contests").where(filter=FieldFilter("link_code", "==", link_code)).limit(1).stream()
contest_doc = next(contests, None)
if not contest_doc:
doc = db.collection("contests").document(link_code).get()
if doc.exists:
contest_doc = doc
else:
raise HTTPException(status_code=404, detail="Contest not found")
return build_contest_payload(contest_doc)
@app.post("/contests/{contest_id}/start")
async def start_sudden_death_contest(contest_id: str, current_user: dict = Depends(get_current_user)):
doc = require_host_contest(contest_id, current_user)
data = doc.to_dict()
if data.get("status") == "ended":
raise HTTPException(status_code=400, detail="This contest has already ended")
if data.get("status") == "active":
# Idempotent: a scheduled start can fire more than once.
return {"success": True, "already_active": True}
if data.get("mode") != "sudden_death":
doc.reference.update({"status": "active", "start_time": datetime.utcnow().isoformat() + "Z"})
return {"success": True}
state = manager.get_state(contest_id)
if not state:
state = {
"state": "WAITING_TO_START",
"current_q_idx": 0,
"active_question_id": None,
"winner": None,
"sync_timer": data.get("overall_time_limit", 60) * 60
}
manager.contest_state[contest_id] = state
cqs = data.get("questions", [])
q_id = cqs[0]["question_id"] if cqs else None
state["state"] = "QUESTION_ACTIVE"
state["current_q_idx"] = 0
state["active_question_id"] = q_id
await manager.set_state(contest_id, state)
asyncio.create_task(global_active_timer(contest_id, 0))
doc.reference.update({
"status": "active",
"start_time": datetime.utcnow().isoformat() + "Z"
})
return {"success": True}
@app.post("/contests/{contest_id}/open")
def open_standard_contest(contest_id: str, current_user: dict = Depends(get_current_user)):
doc = require_host_contest(contest_id, current_user)
data = doc.to_dict()
if data.get("status") == "active":
return {"message": "Already active"}
if data.get("status") == "ended":
raise HTTPException(status_code=400, detail="This contest has already ended")
doc.reference.update({
"status": "active",
"start_time": datetime.utcnow().isoformat() + "Z"
})
return {"success": True, "message": "Contest opened successfully"}
@app.post("/contests/{contest_id}/end")
async def end_contest(contest_id: str, current_user: dict = Depends(get_current_user)):
doc = require_host_contest(contest_id, current_user)
if doc.to_dict().get("status") != "ended":
doc.reference.update(_ended_fields("host"))
state = manager.get_state(contest_id)
if state:
state["state"] = "FINISHED"
await manager.set_state(contest_id, state)
await manager.broadcast(contest_id, {"type": "CONTEST_ENDED"})
return {"success": True, "message": "Contest ended"}
@app.delete("/contests/{contest_id}")
async def delete_contest(contest_id: str, current_user: dict = Depends(get_current_user)):
doc = require_host_contest(contest_id, current_user)
def _purge():
# Firestore batches are capped at 500 writes.
refs = [doc.reference]
for coll in ("participants", "submissions", "question_starts"):
refs += [s.reference for s in db.collection(coll).where(filter=FieldFilter("contest_id", "==", contest_id)).stream()]
for i in range(0, len(refs), 400):
batch = db.batch()
for r in refs[i:i + 400]:
batch.delete(r)
batch.commit()
await asyncio.to_thread(_purge)
await manager.broadcast(contest_id, {"type": "CONTEST_DELETED"})
manager.contest_state.pop(contest_id, None)
manager.active_connections.pop(contest_id, None)
return {"success": True, "message": "Contest deleted"}
@app.get("/contests/{contest_id}/info")
def get_contest_info_by_id(contest_id: str):
doc = db.collection("contests").document(contest_id).get()
if not doc.exists:
raise HTTPException(status_code=404, detail="Contest not found")
return build_contest_payload(doc)
@app.websocket("/ws/contest/{contest_id}")
async def websocket_endpoint(websocket: WebSocket, contest_id: str):
await manager.connect(websocket, contest_id)
try:
while True:
data = await websocket.receive_text()
except WebSocketDisconnect:
manager.disconnect(websocket, contest_id)
@app.post("/sandbox/test")
async def sandbox_test(req: SandboxTestRequest, current_user: dict = Depends(get_current_user)):
payload = {
"code": req.code,
"language": req.language,
"test_cases": [{"input": tc.get("input_data", ""), "expected_output": tc.get("expected_output", "")} for tc in req.test_cases]
}
eval_results = []
try:
eval_results = await call_executor(payload)
for i, res in enumerate(eval_results):
if i < len(req.test_cases):
res["input"] = req.test_cases[i].get("input_data", "")
res["expected"] = req.test_cases[i].get("expected_output", "")
except httpx.TimeoutException:
return {"passed": False, "results": [], "error": "Execution timed out (Server unresponsive)"}
except ExecutorUnavailable as e:
return {"passed": False, "results": [], "error": f"The judge is starting up. Try again in a minute. ({e})"}
except Exception as e:
return {"passed": False, "results": [], "error": f"Execution engine error: {str(e)}"}
passed = all(res.get("passed", False) for res in eval_results) if eval_results else False
return {"passed": passed, "results": eval_results}
def participant_block_reason(contest_id: str, contest: dict, user_id: str) -> Optional[str]:
"""Why this user may not compete, or None. Public contests enrol them on first use."""
if user_id == contest.get("host_id"):
return None
p_docs = db.collection("participants").where(filter=FieldFilter("contest_id", "==", contest_id))\
.where(filter=FieldFilter("user_id", "==", user_id)).limit(1).stream()
p_doc = next(p_docs, None)
if p_doc is None:
if contest.get("visibility", "public") == "private":
return "This is a private contest. Request access from the host first."
db.collection("participants").add({
"contest_id": contest_id,
"user_id": user_id,
"status": "accepted",
"joined_at": datetime.utcnow().isoformat()
})
elif p_doc.to_dict().get("status", "accepted") != "accepted":
return "You are not an approved participant of this contest."
return None
def open_timed_question(ref, contest_id: str, contest: dict, user_id: str, question_id: str, time_limit: int) -> bool:
"""Start the user's countdown on a problem now. False if Contest Time has already run out."""
window = contest.get("overall_time_limit")
if window and contest_elapsed_seconds(contest) >= window * 60:
return False
now = datetime.utcnow()
try:
ref.create({
"contest_id": contest_id,
"user_id": user_id,
"question_id": question_id,
"started_at": now.isoformat() + "Z",
"deadline": (now + timedelta(seconds=time_limit)).isoformat() + "Z",
})
except AlreadyExists:
pass # another tab or request started it first
return True
@app.post("/contests/{contest_id}/questions/{question_id}/start")
def start_timed_question(contest_id: str, question_id: str, current_user: dict = Depends(get_current_user)):
"""Timed mode: start this user's countdown on a problem, or report the one already running.
Problems can only be started while Contest Time is still running.
"""
contest_doc = db.collection("contests").document(contest_id).get()
if not contest_doc.exists:
raise HTTPException(status_code=404, detail="Contest not found")
contest = contest_doc.to_dict()
if contest.get("mode") != "timed":
raise HTTPException(status_code=400, detail="Only timed contests have per-problem timers")
cq = next((q for q in contest.get("questions", []) if q.get("question_id") == question_id), None)
if cq is None:
raise HTTPException(status_code=404, detail="This problem is not part of the contest")
time_limit = cq.get("time_limit") or 0
ref = db.collection("question_starts").document(question_start_id(contest_id, current_user["id"], question_id))
snap = ref.get()
if not snap.exists:
expire_contest_if_needed(contest_doc, contest)
if contest.get("status") != "active":
raise HTTPException(status_code=400, detail="This contest is not running.")
blocked = participant_block_reason(contest_id, contest, current_user["id"])
if blocked:
raise HTTPException(status_code=403, detail=blocked)
if not open_timed_question(ref, contest_id, contest, current_user["id"], question_id, time_limit):
return {"locked": True, "time_limit": time_limit, "elapsed_seconds": 0}
snap = ref.get()
started = _parse_ts(snap.to_dict().get("started_at"))
return {"locked": False, "time_limit": time_limit, "elapsed_seconds": max(0.0, (datetime.utcnow() - started).total_seconds())}
class CodeSubmission(BaseModel):
code: str
language: str
question_id: str
contest_id: str
time_taken_seconds: Optional[int] = None
@app.post("/submit")
async def submit_code(submission: CodeSubmission, current_user: dict = Depends(get_current_user)):
contest_doc = db.collection("contests").document(submission.contest_id).get()
if not contest_doc.exists:
raise HTTPException(status_code=404, detail="Contest not found")
contest = contest_doc.to_dict()
if len(submission.code) > MAX_CODE_CHARS:
return {"passed": False, "results": [], "error": f"Code is too long (limit {MAX_CODE_CHARS:,} characters)."}
already_passed = db.collection("submissions").where(filter=FieldFilter("contest_id", "==", submission.contest_id))\
.where(filter=FieldFilter("user_id", "==", current_user["id"]))\
.where(filter=FieldFilter("question_id", "==", submission.question_id))\
.where(filter=FieldFilter("passed", "==", True)).limit(1).stream()
if next(already_passed, None):
return {"passed": True, "already_solved": True, "results": [], "message": "You already solved this question!"}
if contest.get("status") == "waiting":
return {"passed": False, "results": [], "error": "This contest has not started yet."}
if expire_contest_if_needed(contest_doc, contest):
return {"passed": False, "results": [], "error": "Time is up! The contest has ended."}
if contest.get("status") != "active":
return {"passed": False, "results": [], "error": "This contest has ended. Submissions are no longer accepted."}
blocked = participant_block_reason(submission.contest_id, contest, current_user["id"])
if blocked:
return {"passed": False, "results": [], "error": blocked}
if contest.get("mode") == "timed":
ref = db.collection("question_starts").document(
question_start_id(submission.contest_id, current_user["id"], submission.question_id))
start = ref.get()
if not start.exists:
# The solve page normally starts the countdown when the problem opens. If that
# never reached us (an old open tab, a failed request), start it now while
# Contest Time is still running.
cq = next((q for q in contest.get("questions", []) if q.get("question_id") == submission.question_id), None)
if cq is None:
return {"passed": False, "results": [], "error": "This problem is not part of the contest."}
if not open_timed_question(ref, submission.contest_id, contest, current_user["id"], submission.question_id, cq.get("time_limit") or 0):
return {"passed": False, "results": [], "error": "This problem is locked: it was not opened before Contest Time ran out."}
start = ref.get()
deadline = _parse_ts(start.to_dict().get("deadline"))
if deadline and datetime.utcnow() > deadline + timedelta(seconds=TIMED_GRACE_SECONDS):
return {"passed": False, "results": [], "error": "Your time on this problem is up."}
state = manager.get_state(submission.contest_id)
if contest.get("mode") == "sudden_death" and state and state["state"] != "QUESTION_ACTIVE":
return {"passed": False, "results": [], "error": "Contest is not active"}
q_doc = db.collection("questions").document(submission.question_id).get()
test_cases = q_doc.to_dict().get("test_cases", []) if q_doc.exists else []
if not test_cases:
raise HTTPException(status_code=400, detail="No test cases found for this question")
payload = {
"code": submission.code,
"language": submission.language,
"test_cases": [{"input": tc.get("input_data", ""), "expected_output": tc.get("expected_output", "")} for tc in test_cases]
}
eval_results = []
try:
eval_results = await call_executor(payload)
for i, res in enumerate(eval_results):
if i < len(test_cases):
if i < 2:
res["input"] = test_cases[i].get("input_data", "")
res["expected"] = test_cases[i].get("expected_output", "")
else:
res["input"] = "Hidden Testcase"
res["expected"] = "Hidden Testcase"
except httpx.TimeoutException:
return {"passed": False, "results": [], "error": "Executor timed out. Please try again."}
except ExecutorUnavailable as e:
return {"passed": False, "results": [], "error": f"The judge is starting up. Please submit again in a minute. ({e})"}
except Exception as e:
return {"passed": False, "results": [], "error": f"Executor unavailable: {str(e)}"}
passed_all = bool(eval_results) and all(r.get("passed", False) for r in eval_results)
time_taken = 0
if submission.time_taken_seconds is not None:
time_taken = submission.time_taken_seconds
elif contest.get("start_time"):
try:
start_dt = datetime.fromisoformat(contest.get("start_time").replace('Z', ''))
time_taken = int((datetime.utcnow() - start_dt).total_seconds())
except:
pass
verdict = "accepted" if passed_all else ("error" if any(r.get("error") for r in eval_results) else "wrong_answer")
db.collection("submissions").add({
"user_id": current_user["id"],
"question_id": submission.question_id,
"contest_id": submission.contest_id,
"passed": passed_all,
"verdict": verdict,
"testcases_passed": sum(1 for r in eval_results if r.get("passed", False)),
"total_testcases": len(eval_results),
"penalty_incurred": 0 if passed_all else contest.get("penalty_per_wrong_answer", 5),
"time_taken": time_taken,
"language": submission.language,
"code": submission.code,
"timestamp": datetime.utcnow().isoformat()
})
if passed_all and contest.get("mode") == "sudden_death" and state and state["state"] == "QUESTION_ACTIVE":
cqs = contest.get("questions", [])
if state["current_q_idx"] < len(cqs) and cqs[state["current_q_idx"]]["question_id"] == submission.question_id:
state["winner"] = current_user["username"]
# Flip synchronously so a concurrent winning submission can't also start a round timer.
state["state"] = "ROUND_OVER"
await manager.set_state(submission.contest_id, state)
asyncio.create_task(sudden_death_timer(submission.contest_id))
return {"passed": passed_all, "already_solved": False, "results": eval_results}
@app.post("/contests/{contest_id}/join")
def join_contest(contest_id: str, current_user: dict = Depends(get_current_user)):
contest_doc = db.collection("contests").document(contest_id).get()
if not contest_doc.exists: