Skip to content

Commit 5524401

Browse files
authored
Feat: Mountain Madness 2026 Good/Evil counters (#133)
1 parent be5501b commit 5524401

File tree

8 files changed

+188
-1
lines changed

8 files changed

+188
-1
lines changed

src/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import os
2+
from zoneinfo import ZoneInfo
23

34
# TODO(future): replace new.sfucsss.org with sfucsss.org during migration
45
# TODO(far-future): branch-specific root IP addresses (e.g., devbranch.sfucsss.org)
56
ENV_LOCAL = os.environ.get("LOCAL")
67
IS_PROD = True if not ENV_LOCAL or ENV_LOCAL.lower() != "true" else False
78
GITHUB_ORG_NAME = "CSSS-Test-Organization" if not IS_PROD else "CSSS"
9+
TZ_INFO = ZoneInfo("America/Vancouver")
810

911
W3_GUILD_ID = "1260652618875797504"
1012
CSSS_GUILD_ID = "228761314644852736"

src/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import candidates.urls
1111
import database
1212
import elections.urls
13+
import mountain_madness._2026.urls
1314
import nominees.urls
1415
import officers.urls
1516
import permission.urls
@@ -33,7 +34,12 @@
3334
# if on production, disable viewing the docs
3435
else:
3536
print("Running production environment")
36-
origins = ["https://sfucsss.org", "https://test.sfucsss.org", "https://admin.sfucsss.org"]
37+
origins = [
38+
"https://sfucsss.org",
39+
"https://test.sfucsss.org",
40+
"https://admin.sfucsss.org",
41+
"https://madness.sfucsss.org",
42+
]
3743
app = FastAPI(
3844
lifespan=database.lifespan,
3945
title="CSSS Site Backend",
@@ -53,6 +59,7 @@
5359
app.include_router(nominees.urls.router)
5460
app.include_router(officers.urls.router)
5561
app.include_router(permission.urls.router)
62+
app.include_router(mountain_madness._2026.urls.router)
5663

5764

5865
@app.get("/")
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
# A counter created for Mountain Madness 2026
2+
3+
* The file it writes to is `/var/www/mountain_madness/2026/counter.json`
4+
* It contains the four following endpoints (root is `/api/mm2026`)
5+
* GET `/counters`: Returns the counter values
6+
* POST `/good`: Increments the Good counter
7+
* POST `/evil`: Increments the Evil counter
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from .counter import CounterFile
2+
from .models import CounterResponse
3+
4+
mm_counter = CounterFile("/var/www/mountain_madness/2026/counter.json", save_interval=300, save_threshold=10)
5+
mm_counter.increment("good", 0) # initialize the counter with default values if it doesn't exist
6+
mm_counter.increment("evil", 0) # initialize the counter with default values if it doesn't exist
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import atexit
2+
import datetime
3+
import fcntl
4+
import json
5+
import threading
6+
import time
7+
from pathlib import Path
8+
9+
from constants import TZ_INFO
10+
11+
12+
class CounterFile:
13+
def __init__(self, filepath: str, save_interval: int, save_threshold: int):
14+
"""
15+
Counter that saves to a file.
16+
17+
Args:
18+
filepath: The absolute file path to save the counter to.
19+
save_interval: How often to save to the file, in seconds
20+
save_threshold: How often to save to the file, in number of changes
21+
"""
22+
self.__counters: dict[str, int] = {}
23+
24+
self.filepath = Path(filepath)
25+
self.save_interval = save_interval
26+
self.save_threshold = save_threshold
27+
self.lock = threading.Lock()
28+
self.create_time = datetime.datetime.now(TZ_INFO)
29+
self.last_save_time = self.create_time
30+
self.changes_since_last_save = 0
31+
32+
self._is_auto_save_enabled = save_interval > 0
33+
34+
# If the program shuts down, turn save one last time
35+
atexit.register(self.__save_to_file)
36+
37+
self.start()
38+
39+
def start(self):
40+
self.filepath.parent.mkdir(parents=True, exist_ok=True)
41+
save_data = {
42+
"counters": self.__counters,
43+
"last_save_time": self.last_save_time.isoformat(),
44+
}
45+
# Create file if it doesn't exist
46+
if not self.filepath.exists():
47+
self.filepath.write_text(json.dumps(save_data, indent=2))
48+
self.__save_to_file()
49+
else:
50+
# Otherwise check the file and load from it
51+
try:
52+
with Path.open(self.filepath) as f:
53+
fcntl.flock(f.fileno(), fcntl.LOCK_SH)
54+
try:
55+
data = json.load(f)
56+
self.__counters = data.get("counters", {})
57+
finally:
58+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
59+
except (OSError, json.JSONDecodeError):
60+
self.__counters = {}
61+
if self._is_auto_save_enabled:
62+
self.daemon_thread = threading.Thread(target=self.__save_daemon, daemon=True)
63+
self.daemon_thread.start()
64+
65+
def increment(self, key: str, amount: int = 1):
66+
with self.lock:
67+
self.__counters[key] = self.__counters.get(key, 0) + amount
68+
self.changes_since_last_save += 1
69+
70+
if self.changes_since_last_save >= self.save_threshold:
71+
self.__save_to_file()
72+
73+
def get_all_counters(self) -> dict[str, int]:
74+
with self.lock:
75+
return self.__counters.copy()
76+
77+
def __save_daemon(self):
78+
while self._is_auto_save_enabled:
79+
time.sleep(self.save_interval)
80+
if self.changes_since_last_save:
81+
self.__save_to_file()
82+
83+
def __save_to_file(self):
84+
with self.lock:
85+
if not self.changes_since_last_save:
86+
return
87+
last_save_time = datetime.datetime.now(TZ_INFO)
88+
save_data = {"counters": self.__counters.copy(), "last_save_time": last_save_time.isoformat()}
89+
90+
# Save to temp file and then move it
91+
temp_file = self.filepath.with_suffix(".tmp")
92+
try:
93+
with Path.open(temp_file, "w") as f:
94+
fcntl.flock(f.fileno(), fcntl.LOCK_EX)
95+
try:
96+
json.dump(save_data, f, indent=2)
97+
f.flush()
98+
finally:
99+
fcntl.flock(f.fileno(), fcntl.LOCK_UN)
100+
101+
temp_file.replace(self.filepath)
102+
except OSError:
103+
print("Error saving counter file")
104+
return
105+
106+
with self.lock:
107+
self.changes_since_last_save = 0
108+
self.last_save_time = last_save_time
109+
110+
def shutdown(self):
111+
self._is_auto_save_enabled = False
112+
if hasattr(self, "daemon_thread") and self.daemon_thread.is_alive():
113+
self.daemon_thread.join(timeout=5)
114+
self.__save_to_file()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from pydantic import BaseModel, ConfigDict
2+
3+
4+
class CounterResponse(BaseModel):
5+
model_config = ConfigDict(from_attributes=True)
6+
good: int
7+
evil: int
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sudo mkdir -p /var/lib/mountain_madness/2026

src/mountain_madness/_2026/urls.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
from fastapi import APIRouter
2+
3+
from mountain_madness._2026 import CounterResponse, mm_counter
4+
5+
router = APIRouter(
6+
prefix="/mm",
7+
tags=["Mountain Madness"],
8+
)
9+
10+
11+
@router.get(
12+
"/counters",
13+
description="Get both counters",
14+
response_description="Get both counters",
15+
response_model=CounterResponse,
16+
operation_id="mm_get_counters",
17+
)
18+
async def get_all_counters():
19+
return CounterResponse(**mm_counter.get_all_counters())
20+
21+
22+
@router.post(
23+
"/good",
24+
description="Increment the good counter",
25+
response_description="Increment the good counter",
26+
response_model=CounterResponse,
27+
operation_id="mm_good_increment",
28+
)
29+
async def increment_good():
30+
mm_counter.increment("good")
31+
return CounterResponse(**mm_counter.get_all_counters())
32+
33+
34+
@router.post(
35+
"/evil",
36+
description="Increment the evil counter",
37+
response_description="Increment the evil counter",
38+
response_model=CounterResponse,
39+
operation_id="mm_evil_increment",
40+
)
41+
async def increment_evil():
42+
mm_counter.increment("evil")
43+
return CounterResponse(**mm_counter.get_all_counters())

0 commit comments

Comments
 (0)