Skip to content

Commit a10b266

Browse files
committed
wip: contest team (1/n)
1 parent bf8c838 commit a10b266

7 files changed

Lines changed: 197 additions & 41 deletions

File tree

packages/hydrooj/src/handler/contest.ts

Lines changed: 123 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ import {
1010
} from '@hydrooj/utils/lib/utils';
1111
import { Context, Service } from '../context';
1212
import {
13-
BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError,
14-
ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
13+
BadRequestError, ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError,
14+
ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
1515
InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
1616
} from '../error';
1717
import { ContestStatusDoc, FileInfo, ScoreboardConfig, Tdoc } from '../interface';
@@ -25,7 +25,7 @@ import problem from '../model/problem';
2525
import record from '../model/record';
2626
import ScheduleModel from '../model/schedule';
2727
import storage from '../model/storage';
28-
import user from '../model/user';
28+
import user, { collV, deleteUserCache } from '../model/user';
2929
import {
3030
Handler, param, post, Type, Types,
3131
} from '../service/server';
@@ -66,6 +66,7 @@ export class ContestListHandler extends Handler {
6666
const [tdocs, tpcount] = await this.paginate(cursor, page, 'contest');
6767
const tids = [];
6868
for (const tdoc of tdocs) tids.push(tdoc.docId);
69+
// FIXME: Team status need to be queried here.
6970
const tsdict = await contest.getListStatus(domainId, this.user._id, tids);
7071
const groupsFilter = groups.filter((i) => !Number.isSafeInteger(+i));
7172
this.response.template = 'contest_main.html';
@@ -78,14 +79,16 @@ export class ContestListHandler extends Handler {
7879
export class ContestDetailBaseHandler extends Handler {
7980
tdoc?: Tdoc;
8081
tsdoc?: ContestStatusDoc;
82+
team?: number;
8183

8284
@param('tid', Types.ObjectId, true)
8385
async __prepare(domainId: string, tid: ObjectId) {
8486
if (!tid) return; // ProblemDetailHandler also extends from ContestDetailBaseHandler
85-
[this.tdoc, this.tsdoc] = await Promise.all([
86-
contest.get(domainId, tid),
87-
contest.getStatus(domainId, tid, this.user._id),
88-
]);
87+
this.tdoc = await contest.get(domainId, tid);
88+
if (this.tdoc.allowTeam) {
89+
this.team = await contest.getTeamVuser(domainId, tid, this.user._id) || undefined;
90+
}
91+
this.tsdoc = await contest.getStatus(domainId, tid, this.team ?? this.user._id);
8992
if (this.tdoc.assign?.length && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST)) {
9093
const groups = await user.listGroup(domainId, this.user._id);
9194
if (!new Set(this.tdoc.assign).intersection(new Set(groups.map((i) => i.name))).size) {
@@ -103,7 +106,11 @@ export class ContestDetailBaseHandler extends Handler {
103106

104107
tsdocAsPublic() {
105108
if (!this.tsdoc) return null;
106-
return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : [])]);
109+
return pick(this.tsdoc, [
110+
'attend', 'subscribe', 'startAt',
111+
'teamName', 'members', // for team
112+
...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : []),
113+
]);
107114
}
108115

109116
@param('tid', Types.ObjectId, true)
@@ -174,19 +181,34 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
174181

175182
@param('tid', Types.ObjectId)
176183
@param('code', Types.String, true)
177-
async postAttend(domainId: string, tid: ObjectId, code = '') {
184+
@param('vuid', Types.Int, true)
185+
async postAttend(domainId: string, tid: ObjectId, code = '', vuid?: number) {
178186
this.checkPerm(PERM.PERM_ATTEND_CONTEST);
179187
if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
180188
if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code);
181-
await contest.attend(domainId, tid, this.user._id, { subscribe: 1 });
189+
if (vuid) {
190+
if (!this.tdoc.allowTeam) throw new ValidationError('allowTeam');
191+
const v = await collV.findOne({ _id: vuid });
192+
if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST);
193+
const conflicts = await Promise.all(v.members.map(async (uid) => ({ uid, vuser: await contest.getTeamVuser(domainId, tid, uid) })));
194+
const conflict = conflicts.find((c) => c.vuser);
195+
if (conflict) throw new ContestAlreadyAttendedError(tid, conflict.uid);
196+
await contest.attend(domainId, tid, vuid, {
197+
subscribe: 1,
198+
teamName: v.teamName,
199+
members: v.members,
200+
});
201+
} else {
202+
await contest.attend(domainId, tid, this.user._id, { subscribe: 1 });
203+
}
182204
this.back();
183205
}
184206

185207
@param('tid', Types.ObjectId)
186208
@param('subscribe', Types.Boolean)
187209
async postSubscribe(domainId: string, tid: ObjectId, subscribe = false) {
188210
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
189-
await contest.setStatus(domainId, tid, this.user._id, { subscribe: subscribe ? 1 : 0 });
211+
await contest.setStatus(domainId, tid, this.team ?? this.user._id, { subscribe: subscribe ? 1 : 0 });
190212
this.back();
191213
}
192214

@@ -196,7 +218,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler {
196218
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
197219
if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid);
198220
const now = new Date();
199-
await contest.setStatus(domainId, tid, this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) });
221+
await contest.setStatus(domainId, tid, this.team ?? this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) });
200222
this.back();
201223
}
202224
}
@@ -307,7 +329,7 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler {
307329
this.response.body.showScore = Object.values(this.tdoc.score || {}).some((i) => i && i !== 100);
308330
if (!this.tsdoc) return;
309331
if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) {
310-
await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
332+
await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() });
311333
this.tsdoc.startAt = new Date();
312334
}
313335
this.response.body.tsdoc = this.tsdocAsPublic();
@@ -417,13 +439,14 @@ export class ContestEditHandler extends Handler {
417439
@param('allowViewCode', Types.Boolean)
418440
@param('allowPrint', Types.Boolean)
419441
@param('keepScoreboardHidden', Types.Boolean)
442+
@param('allowTeam', Types.Boolean)
420443
@param('langs', Types.CommaSeperatedArray, true)
421444
async postUpdate(
422445
domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number,
423446
title: string, content: string, rule: string, _pids: string, rated = false,
424447
_code = '', autoHide = false, assign: string[] = [], lock: number = null,
425448
contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, allowPrint = false,
426-
keepScoreboardHidden = false, langs: string[] = [],
449+
keepScoreboardHidden = false, allowTeam = false, langs: string[] = [],
427450
) {
428451
if (!Object.keys(contest.RULES).includes(rule) || contest.RULES[rule].hidden) throw new ValidationError('rule');
429452
if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM);
@@ -464,8 +487,9 @@ export class ContestEditHandler extends Handler {
464487
executeAfter: endAt,
465488
});
466489
}
490+
// FIXME: allowTeam cannot be disabled once enabled.
467491
await contest.edit(domainId, tid, {
468-
assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, langs,
492+
assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, allowTeam, langs,
469493
});
470494
this.response.body = { tid };
471495
this.response.redirect = this.url('contest_detail', { tid });
@@ -692,7 +716,7 @@ export class ContestFileDownloadHandler extends ContestDetailBaseHandler {
692716
if (type === 'private' && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) {
693717
if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid);
694718
if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid);
695-
if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() });
719+
if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() });
696720
}
697721
this.response.addHeader('Cache-Control', 'public');
698722
const target = `contest/${domainId}/${tid}/${type}/${filename}`;
@@ -917,9 +941,92 @@ declare module 'cordis' {
917941
}
918942
}
919943

944+
class ContestTeamHandler extends Handler {
945+
async prepare() {
946+
this.checkPriv(PRIV.PRIV_USER_PROFILE);
947+
}
948+
949+
async get({ domainId }) {
950+
const [mine, invites] = await Promise.all([
951+
collV.find({ members: this.user._id }).toArray(),
952+
collV.find({ invite: this.user._id }).toArray(),
953+
]);
954+
const udict = await user.getList(domainId, mine.flatMap((t) => [...t.members, ...(t.invite || [])]));
955+
this.response.template = 'contest_team.html';
956+
this.response.body = { mine, invites, udict, page_name: 'contest_team' };
957+
}
958+
959+
private async mustMember(vuid: number) {
960+
const v = await collV.findOne({ _id: vuid });
961+
if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST);
962+
return v;
963+
}
964+
965+
private async mut(vuid: number, update: any) {
966+
const v = await collV.findOneAndUpdate({ _id: vuid }, update, { returnDocument: 'after' });
967+
deleteUserCache(v);
968+
return v;
969+
}
970+
971+
@param('name', Types.String, true)
972+
async postCreate(domainId: string, name?: string) {
973+
await user.createVuser(`team:${this.user._id}:${randomstring(6)}`, {
974+
teamName: name?.trim() || this.user.uname,
975+
members: [this.user._id],
976+
});
977+
this.back();
978+
}
979+
980+
@param('vuid', Types.Int)
981+
@param('name', Types.String)
982+
async postRename(domainId: string, vuid: number, name: string) {
983+
await this.mustMember(vuid);
984+
await this.mut(vuid, { $set: { teamName: name.trim() } });
985+
this.back();
986+
}
987+
988+
@param('vuid', Types.Int)
989+
@param('uid', Types.Int)
990+
async postInvite(domainId: string, vuid: number, uid: number) {
991+
const v = await this.mustMember(vuid);
992+
if (v.members.includes(uid) || v.invite?.includes(uid)) throw new ValidationError('uid');
993+
await this.mut(vuid, { $addToSet: { invite: uid } });
994+
await message.send(this.user._id, uid,
995+
`${this.user.uname} invites you to join team "${v.teamName}": ${this.url('contest_team')}`,
996+
message.FLAG_RICHTEXT);
997+
this.back();
998+
}
999+
1000+
@param('vuid', Types.Int)
1001+
async postAccept(domainId: string, vuid: number) {
1002+
const v = await collV.findOne({ _id: vuid });
1003+
if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid');
1004+
await this.mut(vuid, { $pull: { invite: this.user._id }, $addToSet: { members: this.user._id } });
1005+
this.back();
1006+
}
1007+
1008+
@param('vuid', Types.Int)
1009+
async postReject(domainId: string, vuid: number) {
1010+
const v = await collV.findOne({ _id: vuid });
1011+
if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid');
1012+
await this.mut(vuid, { $pull: { invite: this.user._id } });
1013+
this.back();
1014+
}
1015+
1016+
@param('vuid', Types.Int)
1017+
@param('uid', Types.Int, true)
1018+
async postLeave(domainId: string, vuid: number, uid?: number) {
1019+
// FIXME: remove uid in invite[]
1020+
await this.mustMember(vuid);
1021+
await this.mut(vuid, { $pull: { members: uid ?? this.user._id } });
1022+
this.back();
1023+
}
1024+
}
1025+
9201026
export async function apply(ctx: Context) {
9211027
ctx.Route('contest_create', '/contest/create', ContestEditHandler);
9221028
ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST);
1029+
ctx.Route('contest_team', '/contest/team', ContestTeamHandler); // before /contest/:tid: "team" is not a valid ObjectId
9231030
ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST);
9241031
ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST);
9251032
ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST);

packages/hydrooj/src/handler/problem.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -533,7 +533,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler {
533533
await Promise.all([
534534
problem.inc(domainId, this.pdoc.docId, 'nSubmit', 1),
535535
domain.incUserInDomain(domainId, this.user._id, 'nSubmit'),
536-
tid && contest.updateStatus(domainId, tid, this.user._id, rid, this.pdoc.docId),
536+
tid && contest.updateStatus(domainId, tid, this.team ?? this.user._id, rid, this.pdoc.docId),
537537
]);
538538
}
539539
if (tid && !pretest && !contest.canShowSelfRecord.call(this, this.tdoc)) {
@@ -560,7 +560,10 @@ export class ProblemHackHandler extends ProblemDetailHandler {
560560
if (this.tdoc.rule !== 'codeforces') throw new HackFailedError('This contest is not hackable.');
561561
if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId);
562562
}
563-
if (this.rdoc.uid === this.user._id) throw new HackFailedError('You cannot hack your own submission');
563+
if (this.rdoc.uid === this.user._id
564+
|| (tid && await contest.isSameTeam(domainId, tid, this.rdoc.uid, this.user._id))) {
565+
throw new HackFailedError('You cannot hack your own submission');
566+
}
564567
if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You must accept this problem before hacking.');
565568
if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You cannot hack a unsuccessful submission.');
566569
}

packages/hydrooj/src/interface.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ export interface Udoc extends Record<string, any> {
9292
loginip: string;
9393
}
9494

95-
export interface VUdoc {
95+
export interface VUdoc extends Record<string, any> {
9696
_id: number;
9797
mail: string;
9898
mailLower: string;
@@ -106,6 +106,11 @@ export interface VUdoc {
106106
loginat: Date;
107107
ip: ['127.0.0.1'];
108108
loginip: '127.0.0.1';
109+
110+
// for contest team
111+
teamName?: string;
112+
members?: number[];
113+
invite?: number[];
109114
}
110115

111116
export interface GDoc {
@@ -277,6 +282,7 @@ export interface Tdoc extends Document {
277282
balloon?: Record<number, string | { color: string, name: string }>;
278283
score?: Record<number, number>;
279284
langs?: string[];
285+
allowTeam?: boolean;
280286

281287
/**
282288
* In hours
@@ -464,6 +470,8 @@ export interface ContestStatusDoc extends StatusDocBase, ContestStat {
464470
startAt?: Date;
465471
endAt?: Date; // 灵活时间模式的结束时间,或者是提前结束比赛的时间
466472
rev?: number;
473+
teamName?: string;
474+
members?: number[];
467475
}
468476

469477
export interface TrainingStatusDoc extends StatusDocBase, Record<string, any> {

packages/hydrooj/src/model/contest.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -997,6 +997,17 @@ export async function unlockScoreboard(domainId: string, tid: ObjectId) {
997997
await recalcStatus(domainId, tid);
998998
}
999999

1000+
export async function getTeamVuser(domainId: string, tid: ObjectId, uid: number): Promise<number | null> {
1001+
const s = await getMultiStatus(domainId, { docId: tid, members: uid }).project({ uid: 1 }).limit(1).next();
1002+
return s?.uid ?? null;
1003+
}
1004+
1005+
export async function isSameTeam(domainId: string, tid: ObjectId, a: number, b: number): Promise<boolean> {
1006+
if (a === b) return true;
1007+
const [va, vb] = await Promise.all([getTeamVuser(domainId, tid, a), getTeamVuser(domainId, tid, b)]);
1008+
return !!va && va === vb;
1009+
}
1010+
10001011
export function canViewHiddenScoreboard(this: { user: User }, tdoc: Tdoc) {
10011012
if (this.user.own(tdoc)) return true;
10021013
if (tdoc.rule === 'homework') return this.user.hasPerm(PERM.PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD);
@@ -1147,6 +1158,8 @@ global.Hydro.model.contest = {
11471158
add,
11481159
getListStatus,
11491160
getMultiStatus,
1161+
getTeamVuser,
1162+
isSameTeam,
11501163
attend,
11511164
edit,
11521165
del,

packages/hydrooj/src/model/user.ts

Lines changed: 37 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -379,28 +379,44 @@ class UserModel {
379379

380380
@ArgMethod
381381
static async ensureVuser(uname: string) {
382-
const [[min], current] = await Promise.all([
383-
collV.find({}).sort({ _id: 1 }).limit(1).toArray(),
384-
collV.findOne({ unameLower: uname.toLowerCase() }),
385-
]);
382+
const current = await collV.findOne({ unameLower: uname.toLowerCase() });
386383
if (current) return current._id;
387-
const uid = min?._id ? min._id - 1 : -1000;
388-
await collV.insertOne({
389-
_id: uid,
390-
mail: `${-uid}@vuser.local`,
391-
mailLower: `${-uid}@vuser.local`,
392-
uname,
393-
unameLower: uname.trim().toLowerCase(),
394-
hash: '',
395-
salt: '',
396-
hashType: 'hydro',
397-
regat: new Date(),
398-
ip: ['127.0.0.1'],
399-
loginat: new Date(),
400-
loginip: '127.0.0.1',
401-
priv: 0,
402-
});
403-
return uid;
384+
return UserModel.createVuser(uname);
385+
}
386+
387+
@ArgMethod
388+
static async createVuser(uname: string, extra: Record<string, any> = {}) {
389+
const [min] = await collV.find({}).sort({ _id: 1 }).limit(1).toArray();
390+
let uid = min?._id ? min._id - 1 : -1000;
391+
while (true) {
392+
try {
393+
// eslint-disable-next-line no-await-in-loop
394+
await collV.insertOne({
395+
...extra,
396+
_id: uid,
397+
mail: `${-uid}@vuser.local`,
398+
mailLower: `${-uid}@vuser.local`,
399+
uname,
400+
unameLower: uname.trim().toLowerCase(),
401+
hash: '',
402+
salt: '',
403+
hashType: 'hydro',
404+
regat: new Date(),
405+
ip: ['127.0.0.1'],
406+
loginat: new Date(),
407+
loginip: '127.0.0.1',
408+
priv: 0,
409+
});
410+
return uid;
411+
} catch (e) {
412+
// Duplicate _id from a concurrent createVuser/ensureVuser: pick the next slot.
413+
if (e?.code === 11000 && JSON.stringify(e.keyPattern) === '{"_id":1}') {
414+
uid -= 1;
415+
continue;
416+
}
417+
throw e;
418+
}
419+
}
404420
}
405421

406422
static getMulti(params: Filter<Udoc> = {}, projection?: (keyof Udoc)[]) {

0 commit comments

Comments
 (0)