diff --git a/migration/20260102144900_add_random_set.py b/migration/20260102144900_add_random_set.py new file mode 100644 index 00000000..e7571dbb --- /dev/null +++ b/migration/20260102144900_add_random_set.py @@ -0,0 +1,29 @@ +import json + +async def dochange(db, rs): + await db.execute("ALTER TABLE contest ADD COLUMN start_ip character varying(64) DEFAULT '0.0.0.0';") + await db.execute("ALTER TABLE contest ADD COLUMN end_ip character varying(64) DEFAULT '0.0.0.0';") + + await db.execute( + ''' + CREATE TABLE contest_ip_joints ( + contest_id integer NOT NULL, + ip character varying(64) NOT NULL, + pro_id integer NOT NULL + ); + ''' + ) + + await db.execute( + ''' + ALTER TABLE ONLY contest_ip_joints ADD CONSTRAINT contest_problem_joints_forkey_contest_id + FOREIGN KEY (contest_id) REFERENCES contest(contest_id) ON DELETE CASCADE; + ''' + ) + + await db.execute( + ''' + ALTER TABLE ONLY contest_ip_joints ADD CONSTRAINT contest_problem_joints_forkey_pro_id + FOREIGN KEY (pro_id) REFERENCES problem(pro_id) ON DELETE CASCADE; + ''' + ) diff --git a/src/handlers/contests/manage/acct.py b/src/handlers/contests/manage/acct.py index 0e769afc..e05da9f1 100644 --- a/src/handlers/contests/manage/acct.py +++ b/src/handlers/contests/manage/acct.py @@ -4,6 +4,8 @@ from services.user import UserService from utils.numeric import parse_str_to_list +from ipaddress import IPv4Address, AddressValueError + contest_manage_acct_dispatcher = ActionDispatcher() @@ -26,6 +28,8 @@ async def get(self): contest_id=self.contest.contest_id, acct_list=acct_list, admin_list=admin_list, + start_ip=str(self.contest.start_ip), + end_ip=str(self.contest.end_ip) ) @contest_manage_acct_dispatcher.action("add") @@ -145,6 +149,30 @@ async def multi_remove_action(self): ("S", f"Accounts(#{acct_list} successfully removed from user list.") ) + @contest_manage_acct_dispatcher.action("update_ip") + async def update_ip_action(self): + start_ip = self.get_argument("start_ip") + end_ip = self.get_argument("end_ip") + + try: + start_ip = IPv4Address(start_ip) + end_ip = IPv4Address(end_ip) + except AddressValueError: + return self.error(("Eparam", "Invalid IP address format.")) + + if start_ip > end_ip: + return self.error(('Eparam', 'Invalid IP range')) + + self.contest.start_ip = start_ip + self.contest.end_ip = end_ip + await ContestService.inst.update_ip( + self.contest + ) + + return self.error( + ("S", f"Contest IP range successfully updated.") + ) + @reqenv @contest_require_permission("admin") async def post(self): diff --git a/src/handlers/contests/manage/general.py b/src/handlers/contests/manage/general.py index d1b212db..cede997d 100644 --- a/src/handlers/contests/manage/general.py +++ b/src/handlers/contests/manage/general.py @@ -90,6 +90,10 @@ async def update_action(self): if err: return self.error(err) + if contest_mode != self.contest.contest_mode and ContestMode.RANDOM_SET in (contest_mode, self.contest.contest_mode): + if len(self.contest.pro_list) != 0: + return self.error(('Echmod', 'Cannot change contest mode when problem list is not empty')) + self.contest.name = name self.contest.contest_mode = contest_mode diff --git a/src/handlers/contests/manage/pro.py b/src/handlers/contests/manage/pro.py index 36203473..f0670546 100644 --- a/src/handlers/contests/manage/pro.py +++ b/src/handlers/contests/manage/pro.py @@ -3,7 +3,7 @@ from handlers.base import reqenv, RequestHandler, ActionDispatcher from handlers.contests.base import contest_require_permission from services.chal import ChalConst, ChalService -from services.contests import ContestService, ProblemScoreType +from services.contests import ContestService, ProblemScoreType, ContestMode from services.judge import JudgeServerClusterService from services.pro import ProService, ProConst from utils.numeric import parse_str_to_list @@ -24,16 +24,28 @@ async def get(self): continue pro_list.append(pro) - await self.render( - "contests/manage/pro", - page="pro", - contest_id=self.contest.contest_id, - contest=self.contest, - pro_list=pro_list, - ) + if self.contest.contest_mode == ContestMode.RANDOM_SET: + await self.render( + "contests/manage/rand-pro", + page="pro", + contest_id=self.contest.contest_id, + contest=self.contest, + pro_sets=self.contest.pro_sets + ) + else: + await self.render( + "contests/manage/pro", + page="pro", + contest_id=self.contest.contest_id, + contest=self.contest, + pro_list=pro_list, + ) @contest_manage_pro_dispatcher.action("add") async def add_action(self): + if self.contest.contest_mode == ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot add problems to random set contests')) + pro_id = int(self.get_argument("pro_id")) if self.contest.is_pro(pro_id): @@ -51,6 +63,9 @@ async def add_action(self): @contest_manage_pro_dispatcher.action("remove") async def remove_action(self): + if self.contest.contest_mode == ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot remove problems from random set contests')) + pro_id = int(self.get_argument("pro_id")) if not self.contest.is_pro(pro_id): @@ -68,6 +83,9 @@ async def remove_action(self): @contest_manage_pro_dispatcher.action("multi_add") async def multi_add_action(self): + if self.contest.contest_mode == ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot add problems to random set contests')) + pro_id = self.get_argument("pro_id") pro_id = parse_str_to_list(pro_id) for p_id in pro_id: @@ -83,6 +101,9 @@ async def multi_add_action(self): @contest_manage_pro_dispatcher.action("multi_remove") async def multi_remove_action(self): + if self.contest.contest_mode == ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot remove problems from random set contests')) + pro_id = self.get_argument("pro_id") pro_list = parse_str_to_list(pro_id) @@ -172,6 +193,71 @@ async def public_action(self): return self.error(("S", "")) + @contest_manage_pro_dispatcher.action("add_set") + async def add_set_action(self): + ''' + Add a problem set in random set mode + pro_id should be a list of problem ids separated by comma + ''' + if self.contest.contest_mode != ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot add problem set to non-random set contests')) + + pro_ids = self.get_argument("pro_id") + pro_ids = parse_str_to_list(pro_ids) + + if len(pro_ids) < 1: + return self.error(('Eparam', 'Problem set must contain at least one problem')) + + pro_set = [(pro_id, ProblemScoreType.IOI2017) for pro_id in pro_ids] + + err, _ = await ContestService.inst.add_pro_set(self.contest, pro_set) + if err: + return self.error(err) + + return self.error(("S", "")) + + @contest_manage_pro_dispatcher.action("remove_set") + async def remove_set_action(self): + ''' + Remove a problem set in random set mode + pro_id is the problem set index + ''' + if self.contest.contest_mode != ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot remove problem set from non-random set contests')) + + pro_set_idx = int(self.get_argument("pro_id")) + if pro_set_idx < 0 or pro_set_idx > len(self.contest.pro_sets) - 1: + return self.error(('Eparam', 'Problem set index out of range')) + + err, _ = await ContestService.inst.remove_pro_set(self.contest, pro_set_idx) + if err: + return self.error(err) + + return self.error(("S", "")) + + @contest_manage_pro_dispatcher.action("update_order") + async def update_order_action(self): + ''' + Update problem order in random set mode + pro_id is a comma separated list of new problem set indices + ''' + if self.contest.contest_mode != ContestMode.RANDOM_SET: + return self.error(('Emod', 'Cannot update problem order in non-random set contests')) + + new_idxs = self.get_argument("pro_id") + new_idxs = parse_str_to_list(new_idxs) + + pro_set_len = len(self.contest.pro_sets) + + if len(new_idxs) != pro_set_len or sorted(new_idxs) != list(range(pro_set_len)): + return self.error(('Eparam', 'Invalid new indexes for problem sets')) + + err, _ = await ContestService.inst.reorder_pro_set(self.contest, new_idxs) + if err: + return self.error(err) + + return self.error(("S", "")) + @reqenv @contest_require_permission("admin") async def post(self): diff --git a/src/services/contests.py b/src/services/contests.py index a16d590e..3caec324 100644 --- a/src/services/contests.py +++ b/src/services/contests.py @@ -4,6 +4,8 @@ import pickle import asyncpg +from ipaddress import IPv4Address +import random from services.chal import Compiler from services.user import Account @@ -18,6 +20,7 @@ class RegMode(enum.IntEnum): class ContestMode(enum.IntEnum): IOI = 0 ACM = 1 + RANDOM_SET = 2 class ProblemScoreType(enum.IntEnum): IOI2013 = 0 @@ -49,6 +52,10 @@ class Contest: user_list: dict[int, dict] = field(default_factory=dict) pro_list: dict[int, dict] = field(default_factory=dict) + pro_sets: list[list[int]] = field(default_factory=list) + ip_pro_list: dict[IPv4Address, list[int]] = field(default_factory=dict) + start_ip: IPv4Address + end_ip: IPv4Address reg_mode: RegMode reg_end: datetime.datetime @@ -132,7 +139,7 @@ async def get_contest(self, contest_id: int): "desc_after_contest", "contest_mode", "contest_start", "contest_end", - "reg_mode", "reg_end", + "reg_mode", "reg_end", "start_ip", "end_ip", "allow_compilers", "is_public_scoreboard", @@ -156,11 +163,13 @@ async def get_contest(self, contest_id: int): contest.contest_start = contest.contest_start contest.contest_end = contest.contest_end contest.reg_end = contest.reg_end + contest.start_ip = IPv4Address(result['start_ip']) + contest.end_ip = IPv4Address(result['end_ip']) - result = await con.fetch('SELECT pro_id, score_type FROM contest_problem_joints WHERE contest_id = $1 ORDER BY "order";', contest_id) - for pro_id, score_type in result: + result = await con.fetch('SELECT "pro_id", "score_type", "order" FROM contest_problem_joints WHERE contest_id = $1 ORDER BY "order";', contest_id) + for pro_id, score_type, order in result: contest.pro_list[pro_id] = { - "score_type": ProblemScoreType(int(score_type)) + "score_type": ProblemScoreType(int(score_type)), } result = await con.fetch('SELECT acct_id, status FROM contest_users WHERE contest_id = $1 ORDER BY acct_id', contest_id) @@ -169,6 +178,35 @@ async def get_contest(self, contest_id: int): "status": UserStatus(int(status)) } + if contest.contest_mode == ContestMode.RANDOM_SET: + result = await con.fetch( + ''' + SELECT ip, contest_ip_joints.pro_id + FROM contest_ip_joints INNER JOIN contest_problem_joints + ON contest_ip_joints.contest_id = contest_problem_joints.contest_id + AND contest_ip_joints.pro_id = contest_problem_joints.pro_id + WHERE contest_ip_joints.contest_id = $1 ORDER BY contest_ip_joints.ip, contest_problem_joints."order" ASC; + ''', + contest_id + ) + for ip_raw, pro_id in result: + ip = IPv4Address(ip_raw) + if ip not in contest.ip_pro_list: + contest.ip_pro_list[ip] = [] + contest.ip_pro_list[ip].append(pro_id) + + pro_set_count = await con.fetchval(''' + SELECT COUNT(DISTINCT "order") FROM contest_problem_joints + WHERE contest_id = $1; + ''', contest_id) + for order in range(pro_set_count): + result = await con.fetch(''' + SELECT pro_id FROM contest_problem_joints + WHERE contest_id = $1 AND "order" = $2; + ''', contest_id, order) + pro_set = [pro_id for pro_id, in result] + contest.pro_sets.append(pro_set) + if contest.is_running(): b_contest = pickle.dumps(contest) await self.rs.hset('contest', str(contest_id), b_contest) @@ -268,6 +306,8 @@ async def update_contest(self, acct: Account, contest: Contest, prolist_updated= ) if prolist_updated: + if contest.contest_mode == ContestMode.RANDOM_SET: + return ('Eprolist', 'Cannot update problem list for random set contests here'), None order = 0 failed = [] for pro_id, v in contest.pro_list.items(): @@ -313,6 +353,155 @@ async def update_contest(self, acct: Account, contest: Contest, prolist_updated= return None, None + async def update_ip(self, contest: Contest): + async with self.db.acquire() as con: + await con.execute(''' + UPDATE contest + SET start_ip = $1, end_ip = $2 + WHERE contest_id = $3; + ''', str(contest.start_ip), str(contest.end_ip), contest.contest_id) + + if contest.contest_mode != ContestMode.RANDOM_SET: + # Updated, but not random set contest, nothing more to do + return None, None + + # Clear existting ip + contest.ip_pro_list.clear() + async with self.db.acquire() as con: + await con.execute('DELETE FROM contest_ip_joints WHERE contest_id = $1;', contest.contest_id) + + # Readd IPs + for ip_int in range(int(contest.start_ip), int(contest.end_ip) + 1): + ip = IPv4Address(ip_int) + contest.ip_pro_list[ip] = [] + for pro_set in contest.pro_sets: + await self.add_random_pro(contest, pro_set) + + await self.rs.hset('contest', str(contest.contest_id), pickle.dumps(contest)) + + return None, None + + async def add_pro_set(self, contest: Contest , pro_set: list[tuple[int, ProblemScoreType]]): + for pro_id, _ in pro_set: + if pro_id in contest.pro_list: + return ('Eexist', f'Problem {pro_id} already in contest'), None + contest.pro_list[pro_id] = {} # Add dummy dict for avoiding repeated problem id + + pro_order = len(contest.pro_sets) + async with self.db.acquire() as con: + try: + # Insert problems into contest_problem_joints + await con.executemany( + ''' + INSERT INTO contest_problem_joints ("contest_id", "pro_id", "score_type", "order") + VALUES ($1, $2, $3, $4) + ''', + [(contest.contest_id, pro_id, int(score_type), pro_order) for pro_id, score_type in pro_set] + ) + except asyncpg.ForeignKeyViolationError: + return ('Enoext', 'One or more problem IDs do not exist'), None + + contest.pro_sets.append([pro_id for pro_id, _ in pro_set]) + + for pro_id, score_type in pro_set: + contest.pro_list[pro_id] = { + "score_type": score_type, + } + + await self.add_random_pro(contest, [pro_id for pro_id, _ in pro_set]) + await self.rs.hset('contest', str(contest.contest_id), pickle.dumps(contest)) + return None, None + + async def add_random_pro(self, contest: Contest, pro_set: list[int]): + ''' + Append a random problem in pro_set to each ip's problem list + ''' + pro_size = len(pro_set) + if pro_size == 1: + for pro_list in contest.ip_pro_list.values(): + pro_list.append(pro_set[0]) + elif pro_size == 2: + for pro_list in contest.ip_pro_list.values(): + pro_list.append(pro_set[random.randint(0, 1)]) + else: + idx = 0 + for pro_list in contest.ip_pro_list.values(): + idx = (idx + random.randint(1,pro_size-1)) % pro_size + pro_list.append(pro_set[idx]) + + async with self.db.acquire() as con: + # Insert por_id into contest_ip_joints + await con.executemany( + ''' + INSERT INTO contest_ip_joints ("contest_id", "ip", "pro_id") + VALUES ($1, $2, $3) + ''', + [(contest.contest_id, str(ip), pro_list[-1]) for ip, pro_list in contest.ip_pro_list.items()] + ) + + + + async def remove_pro_set(self, contest: Contest , pro_set_idx: int): + for pro_list in contest.ip_pro_list.values(): + pro_list.pop(pro_set_idx) + + remove_pro_ids = contest.pro_sets.pop(pro_set_idx) + for pro_id in remove_pro_ids: + contest.pro_list.pop(pro_id) + await self.rs.hset('contest', str(contest.contest_id), pickle.dumps(contest)) + + async with self.db.acquire() as con: + # Remove problems from contest_problem_joints + await con.execute( + ''' + DELETE FROM contest_problem_joints + WHERE "contest_id" = $1 AND "order" = $2; + ''', + contest.contest_id, pro_set_idx + ) + # Subtract order for problems with order > pro_set_idx + await con.execute( + ''' + UPDATE contest_problem_joints + SET "order" = "order" - 1 + WHERE "contest_id" = $1 AND "order" > $2 + ''', + contest.contest_id, pro_set_idx + ) + # Remove pro_id from contest_ip_joints + await con.executemany( + ''' + DELETE FROM contest_ip_joints + WHERE "contest_id" = $1 AND "pro_id" = $2 + ''', + [(contest.contest_id, pro_id) for pro_id in remove_pro_ids] + ) + return None, None + + async def reorder_pro_set(self, contest: Contest, new_idxs: list[int]): + # Reorder pro_sets + contest.pro_sets = [contest.pro_sets[i] for i in new_idxs] + # Reorder ip_pro_list + for ip in contest.ip_pro_list: + contest.ip_pro_list[ip] = [contest.ip_pro_list[ip][i] for i in new_idxs] + + await self.rs.hset('contest', str(contest.contest_id), pickle.dumps(contest)) + + async with self.db.acquire() as con: + # Update contest_problem_joints + for order, pro_ids in enumerate(contest.pro_sets): + if new_idxs[order] == order: + continue + await con.executemany( + ''' + UPDATE contest_problem_joints + SET "order" = $3 + WHERE "contest_id" = $1 AND "pro_id" = $2 + ''', + [(contest.contest_id, pro_id, order) for pro_id in pro_ids] + ) + return None, None + async def add_announce(self, contest_id: int, acct_id: int, subject: str, content: str): res = await self.db.fetch('INSERT INTO contest_announcement ("contest_id", "acct_id", "subject", "content", "timestamp") VALUES ($1, $2, $3, $4, NOW()) RETURNING announce_id', contest_id, acct_id, subject, content) diff --git a/src/static/templ/contests/manage/acct.html b/src/static/templ/contests/manage/acct.html index 2fbc7c7f..e0879c40 100644 --- a/src/static/templ/contests/manage/acct.html +++ b/src/static/templ/contests/manage/acct.html @@ -30,6 +30,36 @@ }); }; + var j_form = $('#ip-form'); + const ip_re = /^(((?!25?[6-9])[12]\d|[1-9])?\d\.?\b){4}$/; + j_form.find('button.submit').on('click', function(e) { + var type = j_form.find('#type').val(); + const start_ip = j_form.find('#start_ip').val().trim(); + const end_ip = j_form.find('#end_ip').val().trim(); + + if((start_ip.length > 0 || end_ip.length > 0) && (!ip_re.test(start_ip) || !ip_re.test(end_ip))) { + index.show_notify_dialog('IP format error, please input correct IP address.', index.DIALOG_TYPE.error); + return; + } + if(start_ip.length == 0 && end_ip.length == 0) { + start_ip = end_ip = '0.0.0.0'; + } + + $.post(`{{ url(f'/be/contests/{ contest_id }/manage/acct') }}`, { + 'reqtype': 'update_ip', + 'start_ip': start_ip, + 'end_ip': end_ip, + }, function(res) { + res = JSON.parse(res); + if (res.status == 'S') { + index.show_notify_dialog('Update Successfully', index.DIALOG_TYPE.success); + index.reload(); + } else { + index.show_notify_dialog(res.data, index.DIALOG_TYPE.error); + } + }); + }); + document.querySelectorAll('button.remove').forEach(el => { el.addEventListener('click', () => post_func('remove', $(el).attr('acct_id'), $(el).hasClass('normal') ? "normal" : "admin" @@ -153,6 +183,32 @@
| Pro Order | +Problem Set | +Operation | +
|---|---|---|
| {{ id + 1 }} | ++ {{ ','.join(str(pro_id) for pro_id in pro_set) }} + | ++ + + + | +