-
Notifications
You must be signed in to change notification settings - Fork 0
/
Copy pathmain.py
1301 lines (1023 loc) · 43.2 KB
/
main.py
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 json
from fastapi import (
FastAPI,
HTTPException,
Request,
File,
UploadFile,
Depends,
WebSocket,
WebSocketDisconnect,
)
from fastapi.encoders import jsonable_encoder
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse, StreamingResponse
from pymongo import MongoClient
from pymongo.errors import PyMongoError
from datetime import datetime
import secrets
import os
from hashlib import sha256
from pydantic import BaseModel, Field
from typing import List, Optional
from io import BytesIO
import httpx
import matplotlib.font_manager as fm
from dotenv import load_dotenv
from motor.motor_asyncio import AsyncIOMotorClient
# from moesifasgi import MoesifMiddleware # DISABLED FOR NOW
import asyncio
from contextlib import asynccontextmanager
from bson.json_util import default
# Development mode flag to disable api key and origin checks
DEV = False
WS_MESSAGE = "Records updated"
load_dotenv()
# MongoDB connection
ws_client = AsyncIOMotorClient(os.environ.get("MONGO_URI"))
client = MongoClient(os.environ.get("MONGO_URI"))
events_db = client["events"]
previous_events_db = client["previous_events"]
tables_db = client["tables"]
ws_tables_db = ws_client["tables"]
ws_events_db = ws_client["events"]
api_db = client["api_keys"]
admin_db = client["admin_accounts"]
# Lifespan context manager for MongoDB connection and WebSocket monitoring
class ConnectionManager:
"""Manage WebSocket connections and broadcast messages to all clients."""
def __init__(self):
self.active_connections: List[WebSocket] = []
async def connect(self, websocket: WebSocket):
await websocket.accept()
self.active_connections.append(websocket)
def disconnect(self, websocket: WebSocket):
if websocket in self.active_connections:
self.active_connections.remove(websocket)
async def broadcast(self, message: dict):
for connection in self.active_connections:
try:
await connection.send_json(message)
except Exception as e:
print(f"Failed to send message to a connection: {e}")
await connection.send_json(message)
manager = ConnectionManager() # Connection manager instance
@asynccontextmanager
async def lifespan(app: FastAPI):
try:
await startup_tasks()
yield
finally:
await shutdown_tasks()
async def startup_tasks():
try:
asyncio.create_task(monitor_table_changes())
asyncio.create_task(monitor_event_changes())
except Exception as e:
print(f"Error during startup tasks: {e}")
async def shutdown_tasks():
for connection in manager.active_connections:
try:
await connection.disconnect()
except Exception as e:
print(f"Error disconnecting websocket: {e}")
if ws_client:
await ws_client.close()
if client:
client.close()
app = FastAPI(lifespan=lifespan)
# Websocket helper functions
async def monitor_table_changes():
"""Monitor changes in the tables collection and broadcast them to all connected clients."""
try:
change_stream = ws_tables_db.tables.watch()
async for change in change_stream:
# Clean the change document before broadcasting
cleaned_change = json.loads(json.dumps(change, default=default))
await manager.broadcast({"message": WS_MESSAGE})
print(f"Change broadcasted: {cleaned_change}")
except PyMongoError as e:
print(f"MongoDB change stream error: {e}")
finally:
if change_stream is not None:
await change_stream.close()
async def monitor_event_changes():
"""Monitor changes in the events collection and broadcast them to all connected clients."""
try:
change_stream = ws_events_db.events.watch()
async for change in change_stream:
# Clean the change document before broadcasting
cleaned_change = json.loads(json.dumps(change, default=default))
await manager.broadcast({"message": "Records updated"})
print(f"Change broadcasted: {cleaned_change}")
except PyMongoError as e:
print(f"MongoDB change stream error: {e}")
finally:
await change_stream.close()
# WebSocket Endpoints #
@app.websocket("/ws/updates")
async def websocket_endpoint(websocket: WebSocket):
"""WebSocket endpoint for real-time updates."""
await manager.connect(websocket)
try:
while True:
await websocket.receive_text() # Keep the connection alive
except WebSocketDisconnect:
manager.disconnect(websocket)
origins = ["*"] if DEV else ["https://www.emurpg.com", "https://emurpg.com"]
# CORS middleware
app.add_middleware(
CORSMiddleware,
allow_origins=origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Moesif middleware
Moesif_enpoints_to_skip = ["/api/charroller/process", "/api/admin/generate-tables"]
Moesif_content_types_to_skip = ["multipart/form-data"]
# Custom skip function for file uploads
async def custom_should_skip(request):
"""Checks if request should skip processing. Returns True for file uploads and specific endpoints."""
if hasattr(request, "scope") and request.scope.get("_is_file_upload"):
return True
content_type = request.headers.get("content-type", "") if request.headers else ""
path = request.url.path
will_skip = any(ep in path for ep in Moesif_enpoints_to_skip) or any(
ct in content_type for ct in Moesif_content_types_to_skip
)
print(f"Will skip: {will_skip}")
return will_skip
## Should skip check using async mode
async def should_skip(request, response):
"""Custom middleware function to determine if a request should skip certain processing."""
result = await custom_should_skip(request)
return result
## Custom identify user function (if you want to track users)
async def identify_user(request, response):
"""Custom middleware function to identify the user from the request."""
return request.client.host if request else None
## Moesif API settings
moesif_settings = {
"APPLICATION_ID": os.environ.get("MOESIF_APPLICATION_ID"),
"LOG_BODY": False,
"DEBUG": False,
"IDENTIFY_USER": identify_user,
"SKIP": should_skip,
"CAPTURE_OUTGOING_REQUESTS": True,
}
## Add Moesif middleware to the app
# app.add_middleware(MoesifMiddleware, settings=moesif_settings) # DISABLED FOR NOW
# Add fonts
font_dir = "resources/fonts"
font_files = fm.findSystemFonts(fontpaths=[font_dir])
for font_file in font_files:
fm.fontManager.addfont(font_file)
# Pydantic models
class Player(BaseModel):
"""Class that holds: name, student_id, table_id, seat_id, contact(optional) for a player."""
name: str
student_id: str
table_id: str
seat_id: int
contact: Optional[str] = None
class PlayerUpdate(BaseModel):
"""Class that holds: name, student_id, table_id, seat_id, contact(optional) for a player."""
name: str
student_id: str
table_id: str
seat_id: int
contact: Optional[str] = None
class Table(BaseModel):
"""Class that holds: game_name, game_master, player_quota, total_joined_players, joined_players, slug, created_at for a table."""
game_name: str
game_master: str
player_quota: int
total_joined_players: int = 0
joined_players: List[Player] = []
slug: str
created_at: str
class AdminCredentials(BaseModel):
"""Class that holds: username, hashedPassword for an admin."""
username: str
hashedPassword: str
class Member(BaseModel):
"""Class that holds: name, is_manager, manager_name(optional), game_played(optional), player_quota(optional) for a member."""
name: str
is_manager: bool
manager_name: Optional[str] = Field(default=None)
game_played: Optional[str] = Field(default=None)
player_quota: Optional[int] = Field(default=0)
class Event(BaseModel):
"""Class that holds: name, description, start_date, end_date, is_ongoing, total_tables, tables, slug, created_at for an event."""
name: str
description: Optional[str]
start_date: str
end_date: str
is_ongoing: bool = True
total_tables: int = 0
tables: List[str] = []
slug: str
created_at: str
class EventCreate(BaseModel):
"""Class that holds: name, description, start_date, end_date for creating an event."""
name: str
description: Optional[str]
start_date: str
end_date: str
# Helper functions #
# Event registration helper functions
def generate_slug(length=8):
"""Generate a random slug for the table and event."""
return secrets.token_urlsafe(length)
def generate_api_key(length=32, owner=""):
"""Generate a new API key for the given owner."""
if owner == "":
return "Owner name is required to generate an API key"
new_api_key = secrets.token_urlsafe(length)
api_db.api_keys.insert_one(
{"api_key": new_api_key, "owner": owner, "used_times": []}
)
return new_api_key
async def check_api_key(request: Request):
"""Check if the API key is valid and exists in the database."""
if DEV:
return True
# Extract the "apiKey" header from the request
api_key_header = request.headers.get("apiKey")
if not api_key_header:
# Raise error if the API key header is missing
raise HTTPException(status_code=400, detail="Missing API Key.")
try:
# Attempt to parse the API key as JSON if it has { } format
if (
api_key_header
and api_key_header.startswith("{")
and api_key_header.endswith("}")
):
api_key_data = json.loads(api_key_header)
api_key = api_key_data.get("apiKey")
else:
# Otherwise, assume it's a plain string
api_key = api_key_header
except json.JSONDecodeError:
# Fallback to treat as plain string if JSON parsing fails
api_key = api_key_header
# Check if the API key exists in the database
status = api_db.api_keys.find_one({"api_key": api_key})
if status:
# Update the usage time in the database
current_time = await fetch_current_datetime()
api_db.api_keys.update_one(
{"api_key": api_key}, {"$push": {"used_times": current_time}}
)
return True
# Raise error if the API key is invalid
raise HTTPException(status_code=401, detail="Unauthorized")
async def check_origin(request: Request):
"""Check if the request origin is allowed (https://www.emurpg.com or https://emurpg.com)."""
if DEV:
return True
# Get the "Origin" header from the request
origin_header = request.headers.get("origin")
print(f"Got a {request.method} request from origin: {origin_header}")
allowed_origins = [
"https://www.emurpg.com",
"https://emurpg.com",
]
# Check if the origin is allowed origins list
if origin_header not in allowed_origins:
raise HTTPException(status_code=403, detail="Forbidden: Invalid origin.")
return True # Origin is valid, proceed with the request
async def fetch_current_datetime():
"""Fetch the current datetime from Time API in Cyprus timezone."""
async with httpx.AsyncClient() as client:
response = await client.get(
"https://timeapi.io/api/time/current/zone?timeZone=Europe%2FAthens"
)
response.raise_for_status()
return response.json()["dateTime"]
async def check_request(
request: Request, checkApiKey: bool = True, checkOrigin: bool = True
):
if checkApiKey:
await check_api_key(request)
if checkOrigin:
await check_origin(request)
from PIL import Image, ImageDraw, ImageFont
def create_event_announcement(event_slug: str) -> BytesIO:
"""Create a medieval-themed announcement image with enhanced graphics."""
event = events_db.events.find_one({"slug": event_slug})
if not event:
raise ValueError("Event not found")
if isinstance(event["tables"], list) and all(
isinstance(x, str) for x in event["tables"]
):
tables = list(tables_db.tables.find({"event_slug": event_slug}))
else:
tables = event["tables"]
# Image dimensions and colors
WIDTH = 1920
HEIGHT = max(1080, 600 + (len(tables) * 200)) # Base height plus 200px per table
BACKGROUND = (44, 24, 16) # Deep brown
BORDER_COLOR = (139, 69, 19) # Saddle brown
TEXT_COLOR = (255, 215, 0) # Gold
HEADER_COLOR = (255, 223, 0) # Bright gold
TABLE_BG = (59, 36, 23) # Darker brown
def calculate_table_height(table):
num_players = len(table.get("joined_players", []))
return max(300, 160 + (num_players * 65))
# Create base image
img = Image.new("RGB", (WIDTH, HEIGHT), BACKGROUND)
draw = ImageDraw.Draw(img)
# Load fonts
font_path = "resources/fonts/Cinzel-Regular.ttf"
bold_font_path = "resources/fonts/Cinzel-Bold.ttf"
header_font = ImageFont.truetype(bold_font_path, 120)
date_font = ImageFont.truetype(font_path, 60)
table_header_font = ImageFont.truetype(bold_font_path, 70)
gm_font = ImageFont.truetype(font_path, 65)
player_font = ImageFont.truetype(font_path, 40)
footer_font = ImageFont.truetype(font_path, 50)
# Draw main border
border_width = 8
draw.rectangle(
[(border_width, border_width), (WIDTH - border_width, HEIGHT - border_width)],
outline=BORDER_COLOR,
width=border_width,
)
# Draw event header
header_text = event["name"].upper()
header_bbox = draw.textbbox((0, 0), header_text, font=header_font)
header_width = header_bbox[2] - header_bbox[0]
draw.text(
((WIDTH - header_width) // 2, 50), header_text, HEADER_COLOR, font=header_font
)
# Draw date
start_date = event["start_date"]
end_date = event["end_date"]
date_text = start_date if start_date == end_date else f"{start_date} - {end_date}"
date_bbox = draw.textbbox((0, 0), date_text, font=date_font)
date_width = date_bbox[2] - date_bbox[0]
draw.text(((WIDTH - date_width) // 2, 180), date_text, TEXT_COLOR, font=date_font)
# Table layout calculations
table_margin = 40
table_padding = 30
cols = min(3, len(tables))
rows = (len(tables) + cols - 1) // cols
table_width = (WIDTH - (table_margin * (cols + 1))) // cols
start_y = 300
# Calculate row heights
max_height_per_row = []
for row in range(rows):
row_heights = []
for col in range(cols):
idx = row * cols + col
if idx < len(tables):
height = calculate_table_height(tables[idx])
row_heights.append(height)
max_height_per_row.append(max(row_heights) if row_heights else 0)
# Draw tables
current_y = start_y
for row in range(rows):
for col in range(cols):
idx = row * cols + col
if idx >= len(tables):
continue
table = tables[idx]
x = table_margin + (col * (table_width + table_margin))
y = current_y
table_height = max_height_per_row[row]
# Table background
draw.rounded_rectangle(
[(x, y), (x + table_width, y + table_height)],
radius=20,
fill=TABLE_BG,
outline=BORDER_COLOR,
width=3,
)
# Game name
game_text = table["game_name"].upper()
game_bbox = draw.textbbox((0, 0), game_text, font=table_header_font)
game_width = game_bbox[2] - game_bbox[0]
draw.text(
(x + (table_width - game_width) // 2, y + 20),
game_text,
TEXT_COLOR,
font=table_header_font,
)
# Game Master
gm_text = f"{table['game_master']}"
gm_bbox = draw.textbbox((0, 0), gm_text, font=gm_font)
gm_width = gm_bbox[2] - gm_bbox[0]
draw.text(
(x + (table_width - gm_width) // 2, y + 100),
gm_text,
TEXT_COLOR,
font=gm_font,
)
# Players
players = [p["name"].upper() for p in table.get("joined_players", [])]
player_y = y + 160
player_y = y + 200 # Increased from 160
for player in players:
player_bbox = draw.textbbox((0, 0), player, font=player_font)
player_width = player_bbox[2] - player_bbox[0]
draw.text(
(x + (table_width - player_width) // 2, player_y),
player,
TEXT_COLOR,
font=player_font,
)
player_y += 50
current_y += max_height_per_row[row] + table_margin
# Footer with dice
footer_text = "EMU RPG CLUB"
footer_bbox = draw.textbbox((0, 0), footer_text, font=footer_font)
footer_width = footer_bbox[2] - footer_bbox[0]
footer_x = (WIDTH - footer_width) // 2
footer_y = HEIGHT - 80
# Draw footer text
draw.text((footer_x, footer_y), footer_text, TEXT_COLOR, font=footer_font)
# Save image
img_buffer = BytesIO()
img.save(img_buffer, format="PNG", quality=95)
img_buffer.seek(0)
return img_buffer
# Admin Endpoints #
####################
# These endpoints are for the admins to interact with the event system, they return sensitive information.
# New Admin Endpoints for Events
@app.post("/api/admin/events")
async def create_event(event: EventCreate, request: Request):
"""Create a new event with the provided details."""
await check_request(request, checkApiKey=True, checkOrigin=True)
new_event = {
"name": event.name,
"description": event.description,
"start_date": event.start_date,
"end_date": event.end_date,
"is_ongoing": True,
"total_tables": 0,
"available_tables": 0,
"tables": [],
"slug": generate_slug(),
"created_at": await fetch_current_datetime(),
"available_seats": 0,
}
events_db.events.insert_one(new_event)
return JSONResponse(
content={"message": "Event created successfully", "slug": new_event["slug"]},
status_code=201,
)
@app.get("/api/admin/events")
async def get_admin_events(request: Request):
"""Get all events from the database with all the sensitive"""
await check_request(request, checkApiKey=True, checkOrigin=True)
events = list(events_db.events.find({}, {"_id": 0}))
return JSONResponse(content=events)
@app.put("/api/admin/events/{slug}/finish")
async def finish_event(slug: str, request: Request):
"""Finish the event using the provided slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
event = events_db.events.find_one({"slug": slug})
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Get all tables for this event
tables = list(tables_db.tables.find({"event_slug": slug}))
for table in tables:
table.pop("_id", None) # Remove MongoDB _id
# Update event with full table data and mark as finished
event["tables"] = tables
event["is_ongoing"] = False
event.pop("_id", None)
# Move to previous_events and cleanup
previous_events_db.events.insert_one(event)
events_db.events.delete_one({"slug": slug})
tables_db.tables.delete_many({"event_slug": slug})
return JSONResponse(content={"message": "Event finished and archived"})
@app.delete("/api/admin/events/{slug}")
async def delete_event(slug: str, request: Request):
"""Delete the event using the provided slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
event = events_db.events.find_one({"slug": slug})
if not event:
raise HTTPException(status_code=404, detail="Event not found")
# Delete all tables associated with the event
tables_db.tables.delete_many({"event_slug": slug})
# Delete the event
events_db.events.delete_one({"slug": slug})
return JSONResponse(
content={"message": "Event and associated tables deleted successfully"}
)
@app.get("/api/admin/tables")
async def get_tables(request: Request):
"""Get all tables from the database with all the sensitive data."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = list(tables_db.tables.find({}, {"_id": 0}))
json_table = jsonable_encoder(table)
return JSONResponse(content=json_table)
@app.post("/api/admin/create_admin")
async def create_admin(credentials: AdminCredentials, request: Request):
"""Create a new admin account with the provided credentials. Send"""
await check_request(request, checkApiKey=True, checkOrigin=True)
new_admin = {
"username": credentials.username,
"password": credentials.hashedPassword,
}
password = new_admin["password"].encode("utf-8")
hashed_password = sha256(password).hexdigest()
new_admin["password"] = hashed_password
admin_db.admin_accounts.insert_one(new_admin)
return JSONResponse(content={"username": new_admin["username"]}, status_code=201)
@app.post("/api/admin/checkcredentials")
async def check_admin_credentials(credentials: AdminCredentials, request: Request):
"""Check if the provided admin credentials are correct."""
await check_request(request, checkApiKey=True, checkOrigin=True)
admin_account = admin_db.admin_accounts.find_one({"username": credentials.username})
if not admin_account:
raise HTTPException(status_code=401, detail="Invalid credentials")
if admin_account["password"] == credentials.hashedPassword:
return JSONResponse(content={"message": "Credentials are correct"})
else:
raise HTTPException(status_code=401, detail="Invalid credentials")
@app.get("/api/admin/table/{slug}")
async def get_table(slug: str, request: Request):
"""Get the table details from the database using the provided slug with sensitive data."""
await check_request(request, checkApiKey=True, checkOrigin=True)
# Fetch the table from the database using the provided slug
table = tables_db.tables.find_one({"slug": slug}, {"_id": 0})
if table:
serialized_table = jsonable_encoder(
table
) # Convert non-serializable fields (like datetime)
return JSONResponse(content={"status": "success", "data": serialized_table})
# If the table is not found, raise a 404 error
raise HTTPException(status_code=404, detail="Table not found")
@app.post("/api/admin/table/{slug}")
async def update_table(slug: str, request: Request):
"""Update the table details using the provided slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = tables_db.tables.find_one({"slug": slug})
if not table:
raise HTTPException(status_code=404, detail="Table not found")
data = await request.json()
old_quota = int(table["player_quota"])
old_joined = int(table["total_joined_players"])
new_quota = int(data.get("player_quota", old_quota))
new_joined = int(data.get("total_joined_players", old_joined))
update_data = {
"game_name": data.get("game_name", table["game_name"]),
"game_master": data.get("game_master", table["game_master"]),
"player_quota": new_quota,
"total_joined_players": new_joined,
"joined_players": data.get("joined_players", table["joined_players"]),
"slug": table["slug"],
"created_at": data.get("created_at", table["created_at"]),
}
# Calculate seat changes
old_available = old_quota - old_joined
new_available = new_quota - new_joined
# seat_difference = new_quota - old_quota # Might be useful later
# Update tables collection
tables_db.tables.update_one({"slug": slug}, {"$set": update_data})
update_fields = {"available_seats": new_quota - old_quota}
if old_available > 0 and new_available <= 0:
update_fields["available_tables"] = -1
elif old_available <= 0 and new_available > 0:
update_fields["available_tables"] = 1
events_db.events.update_one({"slug": table["event_slug"]}, {"$inc": update_fields})
return JSONResponse(content={"message": "Table updated successfully"})
@app.delete("/api/admin/table/{slug}")
async def delete_table(slug: str, request: Request):
"""Delete the table using the provided slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = tables_db.tables.find_one({"slug": slug})
if not table:
raise HTTPException(status_code=404, detail="Table not found")
remaining_seats = int(table["player_quota"]) - int(table["total_joined_players"])
events_db.events.update_one(
{"slug": table["event_slug"]},
{
"$inc": {
"total_tables": -1,
"available_tables": -1 if remaining_seats > 0 else 0,
"available_seats": -remaining_seats,
},
"$pull": {"tables": slug},
},
)
tables_db.tables.delete_one({"slug": slug})
return JSONResponse(content={"message": "Table deleted successfully"})
@app.post("/api/admin/create_table/{event_slug}")
async def create_table(event_slug: str, request: Request):
"""Create a new table for the event using the provided event slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
event = events_db.events.find_one({"slug": event_slug})
if not event:
raise HTTPException(status_code=404, detail="Event not found")
if not event["is_ongoing"]:
raise HTTPException(
status_code=400, detail="Cannot add tables to finished events"
)
table_data = await request.json()
player_quota = int(table_data.get("player_quota", 0))
new_table = {
"game_name": table_data.get("game_name"),
"game_master": table_data.get("game_master"),
"player_quota": player_quota,
"total_joined_players": 0,
"joined_players": [],
"slug": generate_slug(),
"event_slug": event_slug,
"event_name": event["name"],
"created_at": await fetch_current_datetime(),
}
tables_db.tables.insert_one(new_table)
events_db.events.update_one(
{"slug": event_slug},
{
"$inc": {
"total_tables": 1,
"available_tables": 1,
"available_seats": player_quota,
},
"$push": {"tables": new_table["slug"]},
},
)
return JSONResponse(
content={"message": "Table created successfully", "slug": new_table["slug"]},
status_code=201,
)
@app.get("/api/admin/get_players/{slug}")
async def get_players(slug: str, request: Request):
"""Get the list of players for the table using the provided slug, returns sensitive data."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = tables_db.tables.find_one({"slug": slug}, {"_id": 0})
if not table:
raise HTTPException(status_code=404, detail="Table not found")
return JSONResponse(content={"players": table.get("joined_players", [])})
@app.post("/api/admin/add_player/{slug}")
async def add_player(slug: str, player: Player, request: Request):
"""Add a new player to the table using the provided slug."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = tables_db.tables.find_one({"slug": slug})
event = events_db.events.find_one({"slug": table["event_slug"]})
if not table:
raise HTTPException(status_code=404, detail="Table not found")
if table["total_joined_players"] >= table["player_quota"]:
raise HTTPException(status_code=400, detail="table is full")
new_player = player.dict()
new_player["registration_timestamp"] = await fetch_current_datetime()
remaining_seats = int(table["player_quota"]) - (
int(table["total_joined_players"]) + 1
)
tables_db.tables.update_one(
{"slug": slug},
{
"$push": {"joined_players": new_player},
"$inc": {"total_joined_players": 1},
},
)
update_fields = {"available_seats": -1}
if remaining_seats == 0:
update_fields["available_tables"] = -1
events_db.events.update_one({"slug": table["event_slug"]}, {"$inc": update_fields})
return JSONResponse(content={"message": "Player added successfully"})
@app.put("/api/admin/update_player/{slug}/{student_id}")
async def update_player(
slug: str, student_id: str, player: PlayerUpdate, request: Request
):
"""Update the player details for the table using the provided slug and student_id."""
await check_request(request, checkApiKey=True, checkOrigin=True)
result = tables_db.tables.update_one(
{"slug": slug, "joined_players.student_id": student_id},
{"$set": {"joined_players.$": player.dict()}},
)
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="Player not found")
return JSONResponse(content={"message": "Player updated successfully"})
@app.delete("/api/admin/delete_player/{slug}/{student_id}")
async def delete_player(slug: str, student_id: str, request: Request):
"""Delete the player from the table using the provided table slug and student_id."""
await check_request(request, checkApiKey=True, checkOrigin=True)
table = tables_db.tables.find_one({"slug": slug})
result = tables_db.tables.update_one(
{"slug": slug},
{
"$pull": {"joined_players": {"student_id": student_id}},
"$inc": {"total_joined_players": -1},
},
)
remaining_seats = int(table["player_quota"]) - (
int(table["total_joined_players"]) - 1
)
update_fields = {"available_seats": 1}
if remaining_seats == 1: # Table becomes available
update_fields["available_tables"] = 1
events_db.events.update_one({"slug": table["event_slug"]}, {"$inc": update_fields})
if result.modified_count == 0:
raise HTTPException(status_code=404, detail="Player not found")
return JSONResponse(content={"message": "Player deleted successfully"})
# User Endpoints #
####################
# These endpoints are for the users to interact with the event system, they don't return sensitive information.
@app.get("/api/events")
async def get_events(request: Request):
"""Get all ongoing events from the database without sensitive data."""
await check_request(request, checkApiKey=False, checkOrigin=True)
# Only return ongoing events with non-sensitive data
events = list(
events_db.events.find(
{"is_ongoing": True},
{
"_id": 0,
"name": 1,
"description": 1,
"start_date": 1,
"end_date": 1,
"total_tables": 1,
"slug": 1,
"available_seats": 1,
"available_tables": 1,
},
)
)
return JSONResponse(content=events)
@app.get("/api/events/{slug}/tables")
async def get_event_tables(slug: str, request: Request):
"""Get all tables for the event using the provided slug."""
await check_request(request, checkApiKey=False, checkOrigin=True)
event = events_db.events.find_one({"slug": slug})
if not event:
raise HTTPException(status_code=404, detail="Event not found")
tables = list(tables_db.tables.find({"event_slug": slug}, {"_id": 0}))
return JSONResponse(content=tables)
@app.get("/api/tables")
async def get_tables(request: Request, dependencies=[Depends(check_origin)]):
"""Get all tables from the database without sensitive data."""
await check_request(request, checkApiKey=False, checkOrigin=True)
tables = list(
tables_db.tables.find({}, {"_id": 0, "joined_players": 0, "created_at": 0})
)
# Convert the tables into JSON serializable format
json_tables = jsonable_encoder(tables)
return JSONResponse(content=json_tables)
@app.get("/api/table/{slug}")
async def get_table(slug: str, request: Request):
"""Get the table details from the database using the provided slug without sensitive data."""
await check_request(request, checkApiKey=False, checkOrigin=True)
# Fetch the table from the database using the provided slug
table = tables_db.tables.find_one(
{"slug": slug}, {"_id": 0, "joined_players": 0, "created_at": 0}
)
if table:
serialized_table = jsonable_encoder(
table
) # Convert non-serializable fields (like datetime)
return JSONResponse(content={"status": "success", "data": serialized_table})
# If the table is not found, raise a 404 error
raise HTTPException(status_code=404, detail="Table not found")
@app.post("/api/register/{slug}")