From 2b74d3d0424bd7d55df65274c6870c7d97f33874 Mon Sep 17 00:00:00 2001 From: tobiichi3227 <86729076+tobiichi3227@users.noreply.github.com> Date: Thu, 24 Oct 2024 17:07:38 +0800 Subject: [PATCH] feat: support special score and score precision (#98) * feat: add special score and score precision support In this [commit](d466616aca), NTOJ-Judge supports checker overwriting the score and state. Therefore, we need to update our backend to support this feature. * feat: add cms special score support In this PR, we add support for special score styles in CMS (groupmin). We can precisely control the score in each subtask. Multiply the subtask score by a decimal between 0 and 1. * ci: fix pip no such option: --break-system-packages * ci: fix postgresql authentication failed * fix: race condition on challenge dispatching This is a hotfix, reported by @blameazu * impr: catch more specific exception `InvalidOperation` instead of `DecimalException` * impr: simplify the problem of uploading by using a loop * refactor: extract the duplicate code and loop through the add groups and test cases instead of hardcoding * refactor(test): use a base URL to replace all hardcoded URLs --- .github/workflows/tests.yml | 9 +- migration/20241012000000_special_score.py | 94 +++++++++ src/handlers/chal.py | 10 +- src/handlers/contests/manage/pro.py | 2 +- src/handlers/contests/scoreboard.py | 6 +- src/handlers/manage/pro.py | 7 + src/handlers/rank.py | 45 +++-- src/handlers/submit.py | 2 +- src/services/chal.py | 86 ++++++-- src/services/contests.py | 14 +- src/services/judge.py | 36 ++-- src/services/pro.py | 63 +++--- src/services/rate.py | 74 +++---- src/static/index.css | 32 +-- src/static/static.code-workspace | 22 ++ src/static/templ/chal.html | 20 +- src/static/templ/challist.html | 41 ++-- src/static/templ/manage/pro/update.html | 8 +- src/static/templ/pro-rank.html | 2 + src/tests/e2e/acct.py | 22 +- src/tests/e2e/board.py | 20 +- src/tests/e2e/bulletin.py | 18 +- src/tests/e2e/chal.py | 18 +- src/tests/e2e/contest.py | 190 +++++++++--------- src/tests/e2e/main.py | 28 +-- src/tests/e2e/manage/acct.py | 4 +- src/tests/e2e/manage/pro/filemanager.py | 34 ++-- src/tests/e2e/manage/pro/specialscore.py | 189 +++++++++++++++++ src/tests/e2e/manage/pro/update.py | 37 ++-- src/tests/e2e/manage/pro/updatetests.py | 46 ++--- src/tests/e2e/pro.py | 14 +- src/tests/e2e/proclass.py | 100 ++++----- src/tests/e2e/proset.py | 8 +- src/tests/e2e/ques.py | 28 +-- src/tests/e2e/rank.py | 4 +- src/tests/e2e/submit.py | 12 +- src/tests/e2e/util.py | 31 ++- .../static_file/special_score/res/check/build | 3 + .../special_score/res/check/check.cpp | 14 ++ .../special_score_cms/res/check/build | 3 + .../special_score_cms/res/check/check.cpp | 21 ++ 41 files changed, 928 insertions(+), 489 deletions(-) create mode 100644 migration/20241012000000_special_score.py create mode 100644 src/static/static.code-workspace create mode 100644 src/tests/e2e/manage/pro/specialscore.py create mode 100644 src/tests/static_file/special_score/res/check/build create mode 100644 src/tests/static_file/special_score/res/check/check.cpp create mode 100644 src/tests/static_file/special_score_cms/res/check/build create mode 100644 src/tests/static_file/special_score_cms/res/check/check.cpp diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 01baae40..50d9196b 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -9,7 +9,7 @@ permissions: jobs: e2etest: - runs-on: ubuntu-latest + runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v5 @@ -26,9 +26,10 @@ jobs: echo deb [arch=amd64 signed-by=/usr/share/keyrings/postgresql.gpg] http://apt.postgresql.org/pub/repos/apt/ $(lsb_release -cs)-pgdg main | sudo tee /etc/apt/sources.list.d/postgresql.list sudo apt update -y sudo apt install -f -y postgresql-16 postgresql-client-16 redis dos2unix - sudo sed -i 's/peer/trust/' /etc/postgresql/16/main/pg_hba.conf - sudo service postgresql start - sudo service redis-server start + sudo sed -i 's/peer/trust/g' /etc/postgresql/16/main/pg_hba.conf + sudo sed -i 's/scram-sha-256/trust/g' /etc/postgresql/16/main/pg_hba.conf + sudo service postgresql restart + sudo service redis-server restart - name: Install Coverage run: | diff --git a/migration/20241012000000_special_score.py b/migration/20241012000000_special_score.py new file mode 100644 index 00000000..23d4910a --- /dev/null +++ b/migration/20241012000000_special_score.py @@ -0,0 +1,94 @@ +class NewStatus: + STATE_AC = 1 + STATE_PC = 2 + STATE_WA = 3 + STATE_RE = 4 + STATE_RESIG = 5 + STATE_TLE = 6 + STATE_MLE = 7 + STATE_OLE = 8 + STATE_CE = 9 + STATE_CLE = 10 + STATE_ERR = 11 + STATE_SJE = 12 + STATE_JUDGE = 100 + STATE_NOTSTARTED = 101 + +OldStatusMapper = { + 1: NewStatus.STATE_AC, + 2: NewStatus.STATE_WA, + 3: NewStatus.STATE_RE, + 9: NewStatus.STATE_RESIG, + 4: NewStatus.STATE_TLE, + 5: NewStatus.STATE_MLE, + 6: NewStatus.STATE_CE, + 10: NewStatus.STATE_CLE, + 7: NewStatus.STATE_ERR, + 8: NewStatus.STATE_OLE, + 100: NewStatus.STATE_JUDGE, + 101: NewStatus.STATE_NOTSTARTED, +} + +async def dochange(db, rs): + + res = await db.fetch('SELECT chal_id, test_idx, state FROM test;') + for test in res: + chal_id, test_idx, old_state = test['chal_id'], test['test_idx'], test['state'] + await db.execute('UPDATE test SET state = $1 WHERE chal_id = $2 AND test_idx = $3', + OldStatusMapper[old_state], chal_id, test_idx) + await db.execute(f'SELECT update_challenge_state({chal_id});') + + await db.execute('ALTER TABLE test ADD COLUMN rate NUMERIC(10, 3);') + await db.execute('ALTER TABLE problem ADD COLUMN rate_precision INTEGER DEFAULT 0;') + await db.execute('ALTER TABLE challenge_state ALTER COLUMN rate TYPE NUMERIC(10, 3);') + + await rs.delete('rate') + + await db.execute( + ''' + CREATE OR REPLACE FUNCTION update_challenge_state(p_chal_id INTEGER) + RETURNS VOID AS $$ + BEGIN + WITH challenge_summary AS ( + SELECT + t.chal_id, + MAX(t.state) AS max_state, + SUM(t.runtime) AS total_runtime, + SUM(t.memory) AS total_memory, + SUM( + CASE + WHEN (t.state = 1 OR t.state = 2) AND t.rate IS NOT NULL THEN t.rate -- special score + WHEN t.state = 1 AND t.rate IS NULL THEN tvr.rate -- default score + ELSE 0 + END + ) AS total_rate + FROM test t + LEFT JOIN test_valid_rate tvr ON t.pro_id = tvr.pro_id AND t.test_idx = tvr.test_idx + WHERE t.chal_id = p_chal_id + GROUP BY t.chal_id + ) + INSERT INTO challenge_state (chal_id, state, runtime, memory, rate) + SELECT + chal_id, + max_state, + total_runtime, + total_memory, + total_rate + FROM challenge_summary + ON CONFLICT (chal_id) DO UPDATE + SET + state = EXCLUDED.state, + runtime = EXCLUDED.runtime, + memory = EXCLUDED.memory, + rate = EXCLUDED.rate + WHERE + challenge_state.state != EXCLUDED.state OR + challenge_state.runtime != EXCLUDED.runtime OR + challenge_state.memory != EXCLUDED.memory OR + challenge_state.rate != EXCLUDED.rate; + + RETURN; + END; + $$ LANGUAGE plpgsql; + ''' + ) diff --git a/src/handlers/chal.py b/src/handlers/chal.py index 667e0576..7705e5cd 100644 --- a/src/handlers/chal.py +++ b/src/handlers/chal.py @@ -1,4 +1,5 @@ import asyncio +import decimal import json import tornado.web @@ -132,6 +133,11 @@ async def get(self, chal_id): await self.render('chal', pro=pro, chal=chal, rechal=rechal) return +class _Encoder(json.JSONEncoder): + def default(self, o): + if isinstance(o, decimal.Decimal): + return str(o) + return super().default(o) class ChalListNewChalHandler(WebSocketSubHandler): async def listen_challistnewchal(self): @@ -156,7 +162,7 @@ async def listen_challiststate(self): chal_id = int(msg['data']) if self.first_chal_id <= chal_id <= self.last_chal_id: _, new_state = await ChalService.inst.get_single_chal_state_in_list(chal_id, self.acct) - await self.write_message(json.dumps(new_state)) + await self.write_message(json.dumps(new_state, cls=_Encoder)) async def open(self): self.first_chal_id = -1 @@ -189,7 +195,7 @@ async def listen_chalstate(self): if int(msg['data']) == self.chal_id: _, chal_states = await ChalService.inst.get_chal_state(self.chal_id) - await self.write_message(json.dumps(chal_states)) + await self.write_message(json.dumps(chal_states, cls=_Encoder)) async def open(self): self.chal_id = -1 diff --git a/src/handlers/contests/manage/pro.py b/src/handlers/contests/manage/pro.py index fe6f2f8e..4137a863 100644 --- a/src/handlers/contests/manage/pro.py +++ b/src/handlers/contests/manage/pro.py @@ -90,7 +90,7 @@ async def post(self): self.error('Ejudge') return - err, pro = await ProService.inst.get_pro(pro_id, self.acct) + err, pro = await ProService.inst.get_pro(pro_id, self.acct, is_contest=True) if err: self.error(err) return diff --git a/src/handlers/contests/scoreboard.py b/src/handlers/contests/scoreboard.py index 717c2c59..4ae0471d 100644 --- a/src/handlers/contests/scoreboard.py +++ b/src/handlers/contests/scoreboard.py @@ -25,7 +25,7 @@ def default(self, o): return f"{minutes}:{seconds:02}" elif isinstance(o, Decimal): - return int(o) + return float(o) else: return json.JSONEncoder.default(self, o) @@ -36,6 +36,9 @@ def _encoder(self, obj): if isinstance(obj, datetime.datetime): return obj.timestamp() + elif isinstance(obj, Decimal): + return str(obj) + return obj @reqenv @@ -97,6 +100,7 @@ async def post(self): for pro_score in s[pro_id].values(): pro_score['timestamp'] = datetime.datetime.fromtimestamp(pro_score['timestamp']).replace( tzinfo=UTC8) + pro_score['score'] = Decimal(pro_score['score']) if is_ended: await self.rs.expire(cache_name, time=60 * 60) diff --git a/src/handlers/manage/pro.py b/src/handlers/manage/pro.py index 5b1b1540..f07999ac 100644 --- a/src/handlers/manage/pro.py +++ b/src/handlers/manage/pro.py @@ -775,6 +775,11 @@ async def post(self, page=None): tags = self.get_argument('tags') allow_submit = self.get_argument('allow_submit') == "true" # NOTE: test config + rate_precision = int(self.get_argument('rate_precision')) + if rate_precision > ProConst.RATE_PRECISION_MAX or rate_precision < ProConst.RATE_PRECISION_MIN: + self.error('Eparam') + return + is_makefile = self.get_argument('is_makefile') == "true" check_type = int(self.get_argument('check_type')) @@ -811,6 +816,7 @@ async def post(self, page=None): if check_type == ProConst.CHECKER_IOREDIR: chalmeta = json.dumps(chalmeta) + pro['testm_conf']['rate_precision'] = rate_precision await ProService.inst.update_test_config(pro_id, pro['testm_conf']) await LogService.inst.add_log( f"{self.acct.name} has sent a request to update the problem #{pro_id}", 'manage.pro.update.pro', @@ -822,6 +828,7 @@ async def post(self, page=None): 'is_makefile': is_makefile, 'chalmeta': chalmeta, 'check_type': check_type, + 'rate_precision': rate_precision, } ) if err: diff --git a/src/handlers/rank.py b/src/handlers/rank.py index 33ecd259..33734a01 100644 --- a/src/handlers/rank.py +++ b/src/handlers/rank.py @@ -4,6 +4,7 @@ from handlers.base import RequestHandler, reqenv from services.user import UserConst, UserService, Account +from services.chal import ChalConst class ProRankHandler(RequestHandler): @@ -26,26 +27,33 @@ async def get(self, pro_id): async with self.db.acquire() as con: result = await con.fetch( - ''' + f''' SELECT * FROM ( SELECT DISTINCT ON ("challenge"."acct_id") - "challenge"."chal_id", - "challenge"."acct_id", - "challenge"."timestamp", - "account"."name" AS "acct_name", - "challenge_state"."runtime", - "challenge_state"."memory" - FROM "challenge" - INNER JOIN "account" - ON "challenge"."acct_id"="account"."acct_id" - INNER JOIN "challenge_state" - ON "challenge"."chal_id"="challenge_state"."chal_id" - WHERE "challenge"."pro_id"= $1 - AND "challenge_state"."state"=1 - ORDER BY "challenge"."acct_id" ASC, - "challenge_state"."runtime" ASC, "challenge_state"."memory" ASC, - "challenge"."timestamp" ASC, "challenge"."acct_id" ASC + "challenge"."chal_id", + "challenge"."acct_id", + "challenge"."timestamp", + "account"."name" AS "acct_name", + "challenge_state"."runtime", + "challenge_state"."memory", + ROUND("challenge_state"."rate", "problem"."rate_precision") + + FROM "challenge" + INNER JOIN "account" + ON "challenge"."acct_id"="account"."acct_id" + + INNER JOIN "challenge_state" + ON "challenge"."chal_id"="challenge_state"."chal_id" + + INNER JOIN "problem" + ON "challenge"."pro_id" = $1 + + WHERE "challenge_state"."state"={ChalConst.STATE_AC} + + ORDER BY "challenge"."acct_id" ASC, "challenge_state"."rate" ASC, + "challenge_state"."runtime" ASC, "challenge_state"."memory" ASC, + "challenge"."timestamp" ASC ) temp ORDER BY "runtime" ASC, "memory" ASC, "timestamp" ASC, "acct_id" ASC OFFSET $2 LIMIT $3; @@ -73,7 +81,7 @@ async def get(self, pro_id): total_cnt = total_cnt[0]['count'] chal_list = [] - for rank, (chal_id, acct_id, timestamp, acct_name, runtime, memory) in enumerate(result): + for rank, (chal_id, acct_id, timestamp, acct_name, runtime, memory, rate) in enumerate(result): chal_list.append( { 'rank': rank + pageoff + 1, @@ -82,6 +90,7 @@ async def get(self, pro_id): 'acct_name': acct_name, 'runtime': int(runtime), 'memory': int(memory), + 'rate': rate, 'timestamp': timestamp.astimezone(tz), } ) diff --git a/src/handlers/submit.py b/src/handlers/submit.py index 398a60f7..e8a3cef5 100644 --- a/src/handlers/submit.py +++ b/src/handlers/submit.py @@ -133,7 +133,7 @@ async def post(self): pro_id = chal['pro_id'] comp_type = chal['comp_type'] - err, pro = await ProService.inst.get_pro(pro_id, self.acct) + err, pro = await ProService.inst.get_pro(pro_id, self.acct, is_contest=self.contest is not None) if err: self.finish(err) return diff --git a/src/services/chal.py b/src/services/chal.py index b2e85dda..bd38e5f7 100644 --- a/src/services/chal.py +++ b/src/services/chal.py @@ -11,20 +11,23 @@ class ChalConst: STATE_AC = 1 - STATE_WA = 2 - STATE_RE = 3 - STATE_RESIG = 9 - STATE_TLE = 4 - STATE_MLE = 5 - STATE_CE = 6 - STATE_CLE = 10 - STATE_ERR = 7 + STATE_PC = 2 + STATE_WA = 3 + STATE_RE = 4 + STATE_RESIG = 5 + STATE_TLE = 6 + STATE_MLE = 7 STATE_OLE = 8 + STATE_CE = 9 + STATE_CLE = 10 + STATE_ERR = 11 + STATE_SJE = 12 STATE_JUDGE = 100 STATE_NOTSTARTED = 101 STATE_STR = { STATE_AC: 'AC', + STATE_PC: 'PC', STATE_WA: 'WA', STATE_RE: 'RE', STATE_RESIG: 'RE(SIG)', @@ -33,12 +36,14 @@ class ChalConst: STATE_CE: 'CE', STATE_CLE: 'CLE', STATE_OLE: 'OLE', + STATE_SJE: 'SJE', STATE_ERR: 'IE', STATE_JUDGE: 'JDG', } STATE_LONG_STR = { STATE_AC: 'Accepted', + STATE_PC: 'Partial Correct', STATE_WA: 'Wrong Answer', STATE_RE: 'Runtime Error', STATE_RESIG: 'Runtime Error (Killed by signal)', @@ -48,6 +53,7 @@ class ChalConst: STATE_CE: 'Compile Error', STATE_CLE: 'Compilation Limit Exceed', STATE_ERR: 'Internal Error', + STATE_SJE: 'Special Judge Error', STATE_JUDGE: 'Challenging', STATE_NOTSTARTED: 'Not Started', } @@ -114,7 +120,7 @@ def get_sql_query_str(self): if self.contest != 0: query += f' AND "challenge"."contest_id"={self.contest} ' else: - query += f' AND "challenge"."contest_id"=0 ' + query += ' AND "challenge"."contest_id"=0 ' return query @@ -196,15 +202,24 @@ async def get_chal_state(self, chal_id): async with self.db.acquire() as con: result = await con.fetch( ''' - SELECT "test_idx", "state", "runtime", "memory", "response" + SELECT "test"."test_idx", "state", "runtime", "memory", "response", + ROUND(COALESCE(test.rate, tvr.rate), problem.rate_precision) FROM "test" + INNER JOIN test_valid_rate AS tvr + ON test.pro_id = tvr.pro_id AND test.test_idx = tvr.test_idx + INNER JOIN problem + ON test.pro_id = problem.pro_id WHERE "chal_id" = $1 ORDER BY "test_idx" ASC; ''', chal_id, ) tests = [] - for test_idx, state, runtime, memory, response in result: + for test_idx, state, runtime, memory, response, rate in result: + r = 0 + if state in [ChalConst.STATE_AC, ChalConst.STATE_PC]: + r = rate + tests.append( { 'test_idx': test_idx, @@ -212,6 +227,7 @@ async def get_chal_state(self, chal_id): 'runtime': int(runtime), 'memory': int(memory), 'response': response, + 'rate': r, } ) @@ -249,24 +265,34 @@ async def get_chal(self, chal_id): async with self.db.acquire() as con: result = await con.fetch( ''' - SELECT "test_idx", "state", "runtime", "memory", "response" + SELECT "test"."test_idx", "state", "runtime", "memory", "response", + ROUND(COALESCE(test.rate, tvr.rate), problem.rate_precision) FROM "test" + INNER JOIN test_valid_rate AS tvr + ON test.pro_id = tvr.pro_id AND test.test_idx = tvr.test_idx + INNER JOIN problem + ON test.pro_id = problem.pro_id WHERE "chal_id" = $1 ORDER BY "test_idx" ASC; ''', chal_id, ) testl = [] - for test_idx, state, runtime, memory, response in result: + for test_idx, state, runtime, memory, response, rate in result: if final_response == "": final_response = response + r = 0 + if state in [ChalConst.STATE_AC, ChalConst.STATE_PC]: + r = rate + testl.append( { 'test_idx': test_idx, 'state': state, 'runtime': int(runtime), 'memory': int(memory), + 'rate': r, } ) @@ -336,7 +362,7 @@ async def emit_chal(self, chal_id, pro_id, testm_conf, comp_type, pri: int): if not os.path.isfile(f"code/{chal_id}/main.{file_ext}"): for test in testl: - await self.update_test(chal_id, test['test_idx'], ChalConst.STATE_ERR, 0, 0, '', refresh_db=False) + await self.update_test(chal_id, test['test_idx'], ChalConst.STATE_ERR, 0, 0, None, '', refresh_db=False) await self.update_challenge_state(chal_id) return None, None @@ -375,7 +401,8 @@ async def list_chal(self, off, num, acct: Account, flt: ChalSearchingParam): f''' SELECT "challenge"."chal_id", "challenge"."pro_id", "challenge"."acct_id", "challenge"."contest_id", "challenge"."compiler_type", "challenge"."timestamp", "account"."name" AS "acct_name", - "challenge_state"."state", "challenge_state"."runtime", "challenge_state"."memory" + "challenge_state"."state", "challenge_state"."runtime", "challenge_state"."memory", + ROUND("challenge_state"."rate", problem.rate_precision) FROM "challenge" INNER JOIN "account" ON "challenge"."acct_id" = "account"."acct_id" @@ -392,7 +419,7 @@ async def list_chal(self, off, num, acct: Account, flt: ChalSearchingParam): ) challist = [] - for chal_id, pro_id, acct_id, contest_id, comp_type, timestamp, acct_name, state, runtime, memory in result: + for chal_id, pro_id, acct_id, contest_id, comp_type, timestamp, acct_name, state, runtime, memory, rate in result: if state is None: state = ChalConst.STATE_NOTSTARTED @@ -420,6 +447,7 @@ async def list_chal(self, off, num, acct: Account, flt: ChalSearchingParam): 'state': state, 'runtime': runtime, 'memory': memory, + 'rate': rate, } ) @@ -436,7 +464,9 @@ async def get_single_chal_state_in_list( async with self.db.acquire() as con: result = await con.fetch( ''' - SELECT "challenge"."chal_id", "challenge_state"."state", "challenge_state"."runtime", "challenge_state"."memory" + SELECT "challenge"."chal_id", + "challenge_state"."state", "challenge_state"."runtime", "challenge_state"."memory", + ROUND("challenge_state"."rate", problem.rate_precision) AS "rate" FROM "challenge" INNER JOIN "account" ON "challenge"."acct_id" = "account"."acct_id" INNER JOIN "problem" ON "challenge"."pro_id" = "problem"."pro_id" @@ -456,6 +486,7 @@ async def get_single_chal_state_in_list( 'state': result['state'], 'runtime': int(result['runtime']), 'memory': int(result['memory']), + 'rate': result['rate'], } async def get_stat(self, acct: Account, flt: ChalSearchingParam): @@ -479,23 +510,38 @@ async def get_stat(self, acct: Account, flt: ChalSearchingParam): total_chal = result[0]['count'] return None, {'total_chal': total_chal} - async def update_test(self, chal_id, test_idx, state, runtime, memory, response, refresh_db=True): + async def update_test(self, chal_id, test_idx, state, runtime, memory, rate, response, rate_is_cms_type=False, refresh_db=True): chal_id = int(chal_id) async with self.db.acquire() as con: await con.execute( ''' UPDATE "test" - SET "state" = $1, "runtime" = $2, "memory" = $3, "response" = $4 - WHERE "chal_id" = $5 AND "test_idx" = $6; + SET "state" = $1, "runtime" = $2, "memory" = $3, "response" = $4, "rate" = $5 + WHERE "chal_id" = $6 AND "test_idx" = $7; ''', state, runtime, memory, response, + rate, chal_id, test_idx, ) + if rate_is_cms_type: + await con.execute( + ''' + UPDATE "test" + SET "rate" = $1::decimal * "test_config"."weight"::decimal + FROM "test_config" + WHERE "test"."chal_id" = $2 AND + "test_config"."pro_id" = "test"."pro_id" AND + "test_config"."test_idx" = $3 AND + "test"."test_idx" = $3; + ''', + rate, chal_id, test_idx + ) + if refresh_db: await self.update_challenge_state(chal_id) diff --git a/src/services/contests.py b/src/services/contests.py index 443f725e..381d1fd8 100644 --- a/src/services/contests.py +++ b/src/services/contests.py @@ -247,7 +247,7 @@ async def get_ioi2013_scores(self, contest_id: int, pro_id: int, before_time: da "challenge"."pro_id", "challenge"."acct_id", "challenge"."timestamp", - "challenge_state"."rate", + ROUND("challenge_state"."rate", problem.rate_precision), ROW_NUMBER() OVER ( PARTITION BY "challenge"."pro_id", "challenge"."acct_id" @@ -263,7 +263,8 @@ async def get_ioi2013_scores(self, contest_id: int, pro_id: int, before_time: da INNER JOIN "challenge_state" ON "challenge"."contest_id" = $1 AND "challenge"."acct_id" in ({user}) AND "challenge"."pro_id" = $2 AND "challenge"."timestamp" < $3 AND "challenge"."chal_id" = "challenge_state"."chal_id" - + INNER JOIN "problem" + ON "problem"."pro_id" = $2 ) SELECT acct_id, @@ -312,7 +313,11 @@ async def get_ioi2017_scores(self, contest_id: int, pro_id: int, before_time: da pt.pro_id, pt.test_idx, pt.weight, - CASE WHEN t.state = 1 THEN pt.weight ELSE 0 END AS rate, + CASE + WHEN t.state = 1 AND t.rate IS NULL THEN pt.weight + WHEN t.state = 1 AND t.rate IS NOT NULL THEN t.rate + ELSE 0 + END AS rate, t.timestamp FROM problem_tests pt JOIN test t ON pt.pro_id = t.pro_id AND pt.test_idx = t.test_idx @@ -363,12 +368,13 @@ async def get_ioi2017_scores(self, contest_id: int, pro_id: int, before_time: da SELECT ar.acct_id, ar.last_chal_id AS chal_id, - ar.total_rate AS score, + ROUND(ar.total_rate, problem.rate_precision) AS score, ar.best_timestamp, cc.challenges_before FROM aggregated_results ar JOIN challenge_counts cc ON ar.acct_id = cc.acct_id AND ar.pro_id = cc.pro_id JOIN account a ON ar.acct_id = a.acct_id + INNER JOIN problem ON problem.pro_id = $2 ORDER BY ar.acct_id, ar.pro_id; ''', contest_id, pro_id, before_time) diff --git a/src/services/judge.py b/src/services/judge.py index 21e15701..e04c4cf6 100644 --- a/src/services/judge.py +++ b/src/services/judge.py @@ -1,9 +1,9 @@ -import asyncio import json +import decimal +import asyncio import smtplib from email.header import Header from email.mime.text import MIMEText -from queue import PriorityQueue from typing import Dict, List, Literal, Union from tornado.websocket import websocket_connect @@ -51,13 +51,23 @@ async def connect_server(self): await self.response_handle(ret) async def response_handle(self, ret): - from services.chal import ChalService + from services.chal import ChalService, ChalConst res = json.loads(ret) if res['results'] is not None: for test_idx, result in enumerate(res['results']): - # INFO: CE會回傳 result['verdict'] + + score = None + is_cms_type = False + if 'score_type' in result and result['score_type'] in ["CMS", "CF"]: + is_cms_type = result['score_type'] == "CMS" + if 'score' in result: + try: + score = decimal.Decimal(result['score']) + except decimal.InvalidOperation: + score = None + result['status'] = ChalConst.STATE_SJE _, ret = await ChalService.inst.update_test( res['chal_id'], @@ -65,7 +75,9 @@ async def response_handle(self, ret): result['status'], int(result['time'] / 10 ** 6), # ns to ms result['memory'], + score, result['verdict'], + rate_is_cms_type=is_cms_type, refresh_db=False, ) @@ -145,7 +157,7 @@ async def offline_notice(self): class JudgeServerClusterService: def __init__(self, rs, server_urls: List[Dict]) -> None: JudgeServerClusterService.inst = self - self.queue = PriorityQueue() + self.queue = asyncio.PriorityQueue() self.rs = rs self.servers: List[JudgeServerService] = [] self.idx = 0 @@ -173,7 +185,7 @@ def __init__(self, rs, server_urls: List[Dict]) -> None: async def start(self) -> None: for idx, judge_server in enumerate(self.servers): - self.queue.put([0, idx]) + await self.queue.put([0, idx]) await judge_server.start() async def connect_server(self, idx) -> Literal['Eparam', 'Ejudge', 'S']: @@ -186,7 +198,7 @@ async def connect_server(self, idx) -> Literal['Eparam', 'Ejudge', 'S']: if not self.servers[idx].status: return 'Ejudge' - self.queue.put([0, idx]) + await self.queue.put([0, idx]) return 'S' async def disconnect_server(self, idx) -> Literal['Eparam', 'Ejudge', 'S']: @@ -201,7 +213,7 @@ async def disconnect_server(self, idx) -> Literal['Eparam', 'Ejudge', 'S']: async def disconnect_all_server(self) -> None: for server in self.servers: - self.queue.get() + await self.queue.get() await server.disconnect_server() def get_server_status(self, idx): @@ -233,8 +245,8 @@ async def send(self, data, pro_id, contest_id) -> None: if not self.is_server_online(): return - while not self.queue.empty(): - running_cnt, idx = self.queue.get() + while True: + running_cnt, idx = await self.queue.get() _, status = self.get_server_status(idx) if not status['status']: continue @@ -242,13 +254,13 @@ async def send(self, data, pro_id, contest_id) -> None: judge_id = status['judge_id'] if data['chal_id'] in self.servers[judge_id].chal_map: - self.queue.put([running_cnt, idx]) + await self.queue.put([running_cnt, idx]) break await self.servers[judge_id].send(data) _, status = self.get_server_status(idx) - self.queue.put([status['running_chal_cnt'], judge_id]) + await self.queue.put([status['running_chal_cnt'], judge_id]) self.servers[idx].chal_map[data['chal_id']] = {"pro_id": pro_id, "contest_id": contest_id} break diff --git a/src/services/pro.py b/src/services/pro.py index d9d84a1e..e9dc06fe 100644 --- a/src/services/pro.py +++ b/src/services/pro.py @@ -13,6 +13,10 @@ class ProConst: NAME_MIN = 1 NAME_MAX = 64 CODE_MAX = 16384 + + RATE_PRECISION_MIN = 0 + RATE_PRECISION_MAX = 3 + STATUS_ONLINE = 0 STATUS_CONTEST = 1 STATUS_HIDDEN = 2 @@ -73,7 +77,7 @@ async def get_pro(self, pro_id, acct: Account | None = None, is_contest: bool = result = await con.fetch( """ SELECT "name", "status", "tags", "allow_submit", - "check_type", "is_makefile", "chalmeta", "limit" + "check_type", "is_makefile", "chalmeta", "limit", "rate_precision" FROM "problem" WHERE "pro_id" = $1 AND "status" <= $2; """, pro_id, @@ -83,13 +87,14 @@ async def get_pro(self, pro_id, acct: Account | None = None, is_contest: bool = return "Enoext", None result = result[0] - name, status, tags, allow_submit, check_type, is_makefile, limit, chalmeta = ( + name, status, tags, allow_submit, check_type, is_makefile, rate_precision, limit, chalmeta = ( result["name"], result["status"], result["tags"], result["allow_submit"], result["check_type"], result["is_makefile"], + result["rate_precision"], json.loads(result["limit"]), json.loads(result["chalmeta"]), ) @@ -115,6 +120,7 @@ async def get_pro(self, pro_id, acct: Account | None = None, is_contest: bool = "check_type": check_type, "is_makefile": is_makefile, "test_group": test_groups, + "rate_precision": rate_precision, } return ( @@ -259,6 +265,7 @@ async def update_test_config(self, pro_id, testm_conf: dict): check_type = testm_conf['check_type'] chalmeta = testm_conf['chalmeta'] limit = testm_conf['limit'] + rate_precision = testm_conf['rate_precision'] for test_group_idx, test_group_conf in testm_conf['test_group'].items(): weight = test_group_conf['weight'] @@ -268,8 +275,8 @@ async def update_test_config(self, pro_id, testm_conf: dict): async with self.db.acquire() as con: await con.execute('DELETE FROM "test_config" WHERE "pro_id" = $1;', int(pro_id)) await con.execute( - 'UPDATE "problem" SET is_makefile = $1, check_type = $2, chalmeta = $3, "limit" = $4 WHERE pro_id = $5', - is_makefile, check_type, json.dumps(chalmeta), json.dumps(limit), pro_id + 'UPDATE "problem" SET is_makefile = $1, check_type = $2, chalmeta = $3, "limit" = $4, "rate_precision" = $5 WHERE pro_id = $6', + is_makefile, check_type, json.dumps(chalmeta), json.dumps(limit), rate_precision, pro_id ) if insert_sql: @@ -303,37 +310,7 @@ def get_acct_limit(self, acct: Account | None = None, contest=False): async def unpack_pro(self, pro_id, pack_type, pack_token): from services.chal import ChalConst - def _clean_cont(prefix): - try: - os.remove(f"{prefix}cont.html") - - except OSError: - pass - - try: - os.remove(f"{prefix}cont.pdf") - - except OSError: - pass - - if ( - pack_type != ProService.PACKTYPE_FULL - and pack_type != ProService.PACKTYPE_CONTHTML - and pack_type != ProService.PACKTYPE_CONTPDF - ): - return "Eparam", None - - if pack_type == ProService.PACKTYPE_CONTHTML: - prefix = f"problem/{pro_id}/http/" - _clean_cont(prefix) - await PackService.inst.direct_copy(pack_token, f"{prefix}cont.html") - - elif pack_type == ProService.PACKTYPE_CONTPDF: - prefix = f"problem/{pro_id}/http/" - _clean_cont(prefix) - await PackService.inst.direct_copy(pack_token, f"{prefix}cont.pdf") - - elif pack_type == ProService.PACKTYPE_FULL: + if pack_type == ProService.PACKTYPE_FULL: err, _ = await PackService.inst.unpack(pack_token, f"problem/{pro_id}", True) if err: return err, None @@ -354,9 +331,13 @@ def _clean_cont(prefix): except json.decoder.JSONDecodeError: return "Econf", None - is_makefile = conf["compile"] == 'makefile' + is_makefile = False + if 'compile' in conf: + is_makefile = conf["compile"] == 'makefile' + elif 'is_makefile' in conf: + is_makefile = conf["is_makefile"] + check_type = self._get_check_type(conf["check"]) - chalmeta = conf["metadata"] # INFO: ioredir data ALLOW_COMPILERS = list(ChalConst.ALLOW_COMPILERS) + ['default'] if is_makefile: @@ -364,13 +345,19 @@ def _clean_cont(prefix): if "limit" in conf: limit = {lang: lim for lang, lim in conf["limit"].items() if lang in ALLOW_COMPILERS} - else: + elif 'timelimit' in conf and 'memlimit' in conf: limit = { 'default': { 'timelimit': conf["timelimit"], 'memlimit': conf["memlimit"] * 1024 } } + else: + return "Econf", None + + chalmeta = {} + if 'metadata' in conf: + chalmeta = conf["metadata"] # INFO: ioredir data async with self.db.acquire() as con: await con.execute('DELETE FROM "test_config" WHERE "pro_id" = $1;', int(pro_id)) diff --git a/src/services/rate.py b/src/services/rate.py index e1bd5232..c356e9dc 100644 --- a/src/services/rate.py +++ b/src/services/rate.py @@ -1,3 +1,4 @@ +import decimal import datetime from collections import defaultdict @@ -19,52 +20,48 @@ async def get_acct_rate_and_chal_cnt(self, acct: Account): if (rate_data := await self.rs.hget(key, acct_id)) is None: async with self.db.acquire() as con: - all_chal_cnt = await con.fetchrow('SELECT COUNT(*) FROM "challenge" WHERE "acct_id" = $1', acct_id) - all_chal_cnt = all_chal_cnt['count'] - - ac_chal_cnt = await con.fetchrow( - ''' - SELECT COUNT(*) FROM "challenge" - INNER JOIN "challenge_state" - ON "challenge"."chal_id" = "challenge_state"."chal_id" - AND "challenge_state"."state" = $1 - WHERE "acct_id" = $2 + result = await con.fetch( + f''' + SELECT + COUNT(*) AS all_chal_cnt, + COUNT(CASE WHEN challenge_state.state = {ChalConst.STATE_AC} THEN 1 END) AS ac_chal_cnt + FROM challenge + INNER JOIN challenge_state + ON challenge_state.chal_id = challenge.chal_id AND challenge.acct_id = $1 ''', - ChalConst.STATE_AC, acct_id, ) - ac_chal_cnt = ac_chal_cnt['count'] + if len(result) != 1: + return 'Eunk', None + result = result[0] + + ac_chal_cnt, all_chal_cnt = ( + result['ac_chal_cnt'], + result['all_chal_cnt'], + ) result = await con.fetch( ''' - SELECT - SUM("test_valid_rate"."rate") AS "rate" FROM "test_valid_rate" - INNER JOIN ( - SELECT "test"."pro_id","test"."test_idx", - MIN("test"."timestamp") AS "timestamp" - FROM "test" - INNER JOIN "account" - ON "test"."acct_id" = "account"."acct_id" - INNER JOIN "problem" - ON "test"."pro_id" = "problem"."pro_id" - WHERE "account"."acct_id" = $1 - AND "test"."state" = $2 - GROUP BY "test"."pro_id","test"."test_idx" - ) AS "valid_test" - ON "test_valid_rate"."pro_id" = "valid_test"."pro_id" - AND "test_valid_rate"."test_idx" = "valid_test"."test_idx"; + SELECT SUM(max_rate) AS total_rate + FROM ( + SELECT MAX(cs.rate) AS max_rate + FROM public.account a + JOIN public.challenge c ON a.acct_id = c.acct_id + JOIN public.challenge_state cs ON c.chal_id = cs.chal_id + WHERE a.acct_id = $1 + GROUP BY c.pro_id + ) AS subquery; ''', - acct_id, - int(ChalConst.STATE_AC), + acct_id ) if len(result) != 1: return 'Eunk', None - - if (rate := result[0]['rate']) is None: + rate = result[0]['total_rate'] + if rate is None: rate = 0 rate_data = { - 'rate': rate, + 'rate': str(rate), 'ac_cnt': ac_chal_cnt, 'all_cnt': all_chal_cnt, } @@ -72,6 +69,8 @@ async def get_acct_rate_and_chal_cnt(self, acct: Account): else: rate_data = unpackb(rate_data) + rate_data['rate'] = decimal.Decimal(rate_data['rate']) + return None, rate_data async def get_pro_ac_rate(self, pro_id): @@ -155,8 +154,10 @@ async def map_rate_acct( async with self.db.acquire() as con: result = await con.fetch( f''' - SELECT "challenge"."pro_id", MAX("challenge_state"."rate") AS "score", - COUNT("challenge_state") AS "count", MIN("challenge_state"."state") as "state" + SELECT "challenge"."pro_id", + ROUND(MAX("challenge_state"."rate"), (SELECT rate_precision FROM problem WHERE pro_id = challenge.pro_id)) AS "score", + COUNT("challenge_state") AS "count", + MIN("challenge_state"."state") as "state" FROM "challenge" INNER JOIN "challenge_state" ON "challenge"."chal_id" = "challenge_state"."chal_id" AND "challenge"."acct_id" = $1 @@ -191,7 +192,8 @@ async def map_rate(self, starttime='1970-01-01 00:00:00.000', endtime='2100-01-0 async with self.db.acquire() as con: result = await con.fetch( ''' - SELECT "challenge"."acct_id", "challenge"."pro_id", MAX("challenge_state"."rate") AS "score", + SELECT "challenge"."acct_id", "challenge"."pro_id", + ROUND(MAX("challenge_state"."rate"), (SELECT rate_precision FROM problem WHERE pro_id = challenge.pro_id)) AS "rate", COUNT("challenge_state") AS "count" FROM "challenge" INNER JOIN "challenge_state" diff --git a/src/static/index.css b/src/static/index.css index f4899601..d28a2bd3 100644 --- a/src/static/index.css +++ b/src/static/index.css @@ -6,42 +6,50 @@ a:hover a:visited a:link a:active { text-decoration: none !important; } -.state-1 { +.state-1 { /* AC */ color: #0f0 !important; } -.state-2 { +.state-2 { /* PC */ + color: #0f0 !important; +} + +.state-3 { /* WA */ color: #ed784a !important; } -.state-3 { +.state-4 { /* RE */ color: #27fff6 !important; } -.state-4 { +.state-5 { /* RESIG */ + color: #237ac7 !important; +} + +.state-6 { /* TLE */ color: #70649a !important; } -.state-5 { +.state-7 { /* MLE */ color: #e83015 !important; } -.state-6 { +.state-8 { /* OLE */ color: #ffeb00 !important; } -.state-8 { +.state-9 { /* CE */ color: #ffeb00 !important; } -.state-9 { - color: #237ac7 !important; -} - -.state-10 { +.state-10 { /* CLE */ color: #ffeb00 !important; } +/* .state-11 IE */ +/* .state-100 JUDGE */ +/* .state-101 NOTSTARTED */ + .panel { margin-bottom: 20px; border: 1px solid transparent; diff --git a/src/static/static.code-workspace b/src/static/static.code-workspace new file mode 100644 index 00000000..e56cfc87 --- /dev/null +++ b/src/static/static.code-workspace @@ -0,0 +1,22 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "files.associations": { + "*.templ": "handlebars" + }, + + "css.styleSheets": [ + "https://cdnjs.cloudflare.com/ajax/libs/bootstrap/5.3.2/css/bootstrap.min.css" + ], + + "editor.quickSuggestions": { + "other": "on", + "comments": "off", + "strings": "on" + } + } +} \ No newline at end of file diff --git a/src/static/templ/chal.html b/src/static/templ/chal.html index 819fe672..d0c34534 100644 --- a/src/static/templ/chal.html +++ b/src/static/templ/chal.html @@ -4,18 +4,9 @@