diff --git a/packages/hydrooj/src/handler/contest.ts b/packages/hydrooj/src/handler/contest.ts index 2b49e7abb..4d6b75d0d 100644 --- a/packages/hydrooj/src/handler/contest.ts +++ b/packages/hydrooj/src/handler/contest.ts @@ -10,8 +10,8 @@ import { } from '@hydrooj/utils/lib/utils'; import { Context, Service } from '../context'; import { - BadRequestError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, ContestNotLiveError, - ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, + BadRequestError, ContestAlreadyAttendedError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError, + ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError, InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError, } from '../error'; import { ContestStatusDoc, FileInfo, ScoreboardConfig, Tdoc } from '../interface'; @@ -78,14 +78,16 @@ export class ContestListHandler extends Handler { export class ContestDetailBaseHandler extends Handler { tdoc?: Tdoc; tsdoc?: ContestStatusDoc; + team?: number; @param('tid', Types.ObjectId, true) async __prepare(domainId: string, tid: ObjectId) { if (!tid) return; // ProblemDetailHandler also extends from ContestDetailBaseHandler - [this.tdoc, this.tsdoc] = await Promise.all([ - contest.get(domainId, tid), - contest.getStatus(domainId, tid, this.user._id), - ]); + this.tdoc = await contest.get(domainId, tid); + if (this.tdoc.allowTeam) { + this.team = await contest.getTeamVid(domainId, tid, this.user._id) || undefined; + } + this.tsdoc = await contest.getStatus(domainId, tid, this.team ?? this.user._id); if (this.tdoc.assign?.length && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST)) { const groups = await user.listGroup(domainId, this.user._id); if (!new Set(this.tdoc.assign).intersection(new Set(groups.map((i) => i.name))).size) { @@ -103,7 +105,11 @@ export class ContestDetailBaseHandler extends Handler { tsdocAsPublic() { if (!this.tsdoc) return null; - return pick(this.tsdoc, ['attend', 'subscribe', 'startAt', ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : [])]); + return pick(this.tsdoc, [ + 'attend', 'subscribe', 'startAt', + 'displayName', 'members', // for team + ...(this.tdoc.duration || this.tsdoc.endAt ? ['endAt'] : []), + ]); } @param('tid', Types.ObjectId, true) @@ -158,11 +164,17 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { this.response.template = 'contest_detail.html'; - const udict = await user.getList(domainId, [this.tdoc.owner]); + const [udict, teamVdocs] = await Promise.all([ + user.getList(domainId, [this.tdoc.owner]), + this.tdoc.allowTeam && this.user.hasPriv(PRIV.PRIV_USER_PROFILE) + ? user.getVusersByMember(this.user._id, { _id: 1, displayName: 1, members: 1 }) + : [], + ]); this.response.body = { tdoc: this.tdoc, tsdoc: this.tsdocAsPublic(), udict, + team_vdocs: teamVdocs, files: (this.tsdoc?.attend && !contest.isNotStarted(this.tdoc)) ? sortFiles(this.tdoc.privateFiles || []) : [], urlForFile: (filename: string) => this.url('contest_file_download', { tid, filename, type: 'private' }), }; @@ -174,11 +186,31 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('tid', Types.ObjectId) @param('code', Types.String, true) - async postAttend(domainId: string, tid: ObjectId, code = '') { + @param('vuid', Types.Int, true) + async postAttend(domainId: string, tid: ObjectId, code = '', vuid?: number) { this.checkPerm(PERM.PERM_ATTEND_CONTEST); if (contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); if (this.tdoc._code && code !== this.tdoc._code) throw new InvalidTokenError('Contest Invitation', code); - await contest.attend(domainId, tid, this.user._id, { subscribe: 1 }); + if (vuid) { + if (!this.tdoc.allowTeam) throw new ValidationError('allowTeam'); + const v = await user.getVuserById(vuid); + if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST); + const conflicts = await Promise.all(v.members.map(async (uid) => + ((await contest.getStatus(domainId, tid, uid))?.attend + || await contest.getTeamVid(domainId, tid, uid)) ? uid : null)); + const conflict = conflicts.find((c) => c !== null); + if (conflict != null) throw new ContestAlreadyAttendedError(tid, conflict); + await contest.attend(domainId, tid, vuid, { + subscribe: 1, + displayName: v.displayName, + members: v.members, + }); + } else { + if (this.tdoc.allowTeam && await contest.getTeamVid(domainId, tid, this.user._id)) { + throw new ContestAlreadyAttendedError(tid, this.user._id); + } + await contest.attend(domainId, tid, this.user._id, { subscribe: 1 }); + } this.back(); } @@ -186,7 +218,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { @param('subscribe', Types.Boolean) async postSubscribe(domainId: string, tid: ObjectId, subscribe = false) { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); - await contest.setStatus(domainId, tid, this.user._id, { subscribe: subscribe ? 1 : 0 }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { subscribe: subscribe ? 1 : 0 }); this.back(); } @@ -196,7 +228,7 @@ export class ContestDetailHandler extends ContestDetailBaseHandler { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(domainId, tid); const now = new Date(); - await contest.setStatus(domainId, tid, this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { endAt: now, ...(!this.tsdoc.startAt ? { startAt: now } : {}) }); this.back(); } } @@ -298,7 +330,7 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { const [pdict, udict, tcdocs] = await Promise.all([ problem.getList(domainId, this.tdoc.pids, true, true, problem.PROJECTION_CONTEST_LIST), user.getList(domainId, [this.tdoc.owner, this.user._id]), - contest.getMultiClarification(domainId, tid, this.user._id), + contest.getMultiClarification(domainId, tid, this.tsdoc?.members?.length ? this.tsdoc.members : this.user._id), ]); this.response.body = { pdict, psdict: {}, udict, rdict: {}, tdoc: this.tdoc, tcdocs, @@ -307,7 +339,7 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { this.response.body.showScore = Object.values(this.tdoc.score || {}).some((i) => i && i !== 100); if (!this.tsdoc) return; if (this.tsdoc.attend && !this.tsdoc.startAt && contest.isOngoing(this.tdoc)) { - await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() }); this.tsdoc.startAt = new Date(); } this.response.body.tsdoc = this.tsdocAsPublic(); @@ -324,10 +356,12 @@ export class ContestProblemListHandler extends ContestDetailBaseHandler { rids.push(...Object.values(correction).map((i) => i.rid)); this.response.body.correction = correction; } + const uidFilter = this.team && this.tsdoc?.members?.length + ? { $in: this.tsdoc.members } : this.user._id; [this.response.body.rdict, this.response.body.rdocs] = canViewRecord ? await Promise.all([ record.getList(domainId, rids), - record.getMulti(domainId, { contest: tid, uid: this.user._id }) + record.getMulti(domainId, { contest: tid, uid: uidFilter }) .sort({ _id: -1 }).toArray(), ]) : [Object.fromEntries(psdocs.map((i) => [i.rid, { _id: i.rid }])), []]; @@ -417,16 +451,18 @@ export class ContestEditHandler extends Handler { @param('allowViewCode', Types.Boolean) @param('allowPrint', Types.Boolean) @param('keepScoreboardHidden', Types.Boolean) + @param('allowTeam', Types.Boolean) @param('langs', Types.CommaSeperatedArray, true) async postUpdate( domainId: string, tid: ObjectId, beginAtDate: string, beginAtTime: string, duration: number, title: string, content: string, rule: string, _pids: string, rated = false, _code = '', autoHide = false, assign: string[] = [], lock: number = null, contestDuration: number = null, maintainer: number[] = [], allowViewCode = false, allowPrint = false, - keepScoreboardHidden = false, langs: string[] = [], + keepScoreboardHidden = false, allowTeam = false, langs: string[] = [], ) { if (!Object.keys(contest.RULES).includes(rule) || contest.RULES[rule].hidden) throw new ValidationError('rule'); if (autoHide) this.checkPerm(PERM.PERM_EDIT_PROBLEM); + allowTeam ||= !!this.tdoc?.allowTeam; const pids = _pids.replace(/,/g, ',').split(',').map((i) => +i).filter((i) => i); const beginAtMoment = moment.tz(`${beginAtDate} ${beginAtTime}`, this.user.timeZone); if (!beginAtMoment.isValid()) throw new ValidationError('beginAtDate', 'beginAtTime'); @@ -465,7 +501,7 @@ export class ContestEditHandler extends Handler { }); } await contest.edit(domainId, tid, { - assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, langs, + assign, _code, autoHide, lockAt, maintainer, allowViewCode, allowPrint, keepScoreboardHidden, allowTeam, langs, }); this.response.body = { tid }; this.response.redirect = this.url('contest_detail', { tid }); @@ -665,7 +701,7 @@ class ContestClarificationHandler extends ContestManagementBaseHandler { ]); } else { const tsdocs = await contest.getMultiStatus(domainId, { docId: tid, subscribe: 1 }).toArray(); - const uids = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); + const uids = Array.from(new Set(tsdocs.flatMap((tsdoc) => (tsdoc.members?.length ? tsdoc.members : [tsdoc.uid])))); const flag = contest.isOngoing(this.tdoc) ? message.FLAG_ALERT : message.FLAG_UNREAD; await Promise.all([ contest.addClarification(domainId, tid, 0, content, this.request.ip, subject), @@ -692,7 +728,7 @@ export class ContestFileDownloadHandler extends ContestDetailBaseHandler { if (type === 'private' && !this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { if (!this.tsdoc?.attend) throw new ContestNotAttendedError(domainId, tid); if (!contest.isOngoing(this.tdoc) && !contest.isDone(this.tdoc)) throw new ContestNotLiveError(domainId, tid); - if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.user._id, { startAt: new Date() }); + if (!this.tsdoc.startAt) await contest.setStatus(domainId, tid, this.team ?? this.user._id, { startAt: new Date() }); } this.response.addHeader('Cache-Control', 'public'); const target = `contest/${domainId}/${tid}/${type}/${filename}`; @@ -711,7 +747,7 @@ export class ContestUserHandler extends ContestManagementBaseHandler { @param('tid', Types.ObjectId) async get(domainId: string, tid: ObjectId) { const tsdocs = await contest.getMultiStatus(domainId, { docId: tid }).project({ - uid: 1, attend: 1, startAt: 1, unrank: 1, endAt: 1, + uid: 1, attend: 1, startAt: 1, unrank: 1, endAt: 1, displayName: 1, members: 1, }).toArray(); for (const tsdoc of tsdocs) { if (this.tdoc.duration && tsdoc.startAt) { @@ -722,8 +758,9 @@ export class ContestUserHandler extends ContestManagementBaseHandler { ]).toDate(); } } + const memberUids = tsdocs.flatMap((tsdoc) => tsdoc.members || []); const udict = await user.getListForRender( - domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid)], + domainId, [this.tdoc.owner, ...tsdocs.map((i) => i.uid), ...memberUids], this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO), ); this.response.body = { tdoc: this.tdoc, tsdocs, udict }; @@ -917,9 +954,94 @@ declare module 'cordis' { } } +class ContestTeamHandler extends Handler { + async prepare() { + this.checkPriv(PRIV.PRIV_USER_PROFILE); + } + + async get({ domainId }) { + const [mine, invites] = await Promise.all([ + user.getVusersByMember(this.user._id), + user.getVusersByInvite(this.user._id), + ]); + const udict = await user.getList(domainId, mine.flatMap((t) => [...t.members, ...(t.invite || [])])); + this.response.template = 'contest_team.html'; + this.response.body = { mine, invites, udict, page_name: 'contest_team' }; + } + + private async mustMember(vuid: number) { + const v = await user.getVuserById(vuid); + if (!v?.members?.includes(this.user._id)) throw new PermissionError(PERM.PERM_ATTEND_CONTEST); + return v; + } + + @param('name', Types.String, true) + async postCreate(domainId: string, name?: string) { + await user.createVuser(`team:${this.user._id}:${randomstring(6)}`, { + displayName: name?.trim() || this.user.uname, + members: [this.user._id], + }); + this.back(); + } + + @param('vuid', Types.Int) + @param('name', Types.String) + async postRename(domainId: string, vuid: number, name: string) { + await this.mustMember(vuid); + await user.updateVuserById(vuid, { $set: { displayName: name.trim() } }); + this.back(); + } + + @param('vuid', Types.Int) + @param('uid', Types.Int) + async postInvite(domainId: string, vuid: number, uid: number) { + if (uid <= 0 || !await user.getById(domainId, uid)) throw new ValidationError('uid'); + const v = await this.mustMember(vuid); + if (v.members.includes(uid) || v.invite?.includes(uid)) throw new ValidationError('uid'); + await user.updateVuserById(vuid, { $addToSet: { invite: uid } }); + await message.send(this.user._id, uid, + `${this.user.uname} invites you to join team "${v.displayName}": ${this.url('contest_team')}`, + message.FLAG_RICHTEXT); + this.back(); + } + + @param('vuid', Types.Int) + async postAccept(domainId: string, vuid: number) { + const v = await user.getVuserById(vuid); + if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid'); + await user.updateVuserById(vuid, { $pull: { invite: this.user._id }, $addToSet: { members: this.user._id } }); + this.back(); + } + + @param('vuid', Types.Int) + async postReject(domainId: string, vuid: number) { + const v = await user.getVuserById(vuid); + if (!v?.invite?.includes(this.user._id)) throw new ValidationError('vuid'); + await user.updateVuserById(vuid, { $pull: { invite: this.user._id } }); + this.back(); + } + + @param('vuid', Types.Int) + @param('uid', Types.Int, true) + async postLeave(domainId: string, vuid: number, uid?: number) { + const target = uid ?? this.user._id; + if (target === this.user._id) { + const v = await user.getVuserById(vuid); + if (!v?.members?.includes(this.user._id) && !v?.invite?.includes(this.user._id)) { + throw new PermissionError(PERM.PERM_ATTEND_CONTEST); + } + } else { + await this.mustMember(vuid); + } + await user.updateVuserById(vuid, { $pull: { members: target, invite: target } }); + this.back(); + } +} + export async function apply(ctx: Context) { ctx.Route('contest_create', '/contest/create', ContestEditHandler); ctx.Route('contest_main', '/contest', ContestListHandler, PERM.PERM_VIEW_CONTEST); + ctx.Route('contest_team', '/contest/team', ContestTeamHandler); // before /contest/:tid: "team" is not a valid ObjectId ctx.Route('contest_detail', '/contest/:tid', ContestDetailHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_problemlist', '/contest/:tid/problems', ContestProblemListHandler, PERM.PERM_VIEW_CONTEST); ctx.Route('contest_edit', '/contest/:tid/edit', ContestEditHandler, PERM.PERM_VIEW_CONTEST); @@ -1016,8 +1138,8 @@ export async function apply(ctx: Context) { teams.map((i, idx) => { const showName = this.user.hasPerm(PERM.PERM_VIEW_USER_PRIVATE_INFO) && udict[i.uid].displayName ? udict[i.uid].displayName : udict[i.uid].uname; - const teamName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(showName)}`; - return `@t ${idx + 1},0,1,"${teamName}"`; + const displayName = `${i.rank ? '*' : ''}${escape(udict[i.uid].school || unknownSchool)}-${escape(showName)}`; + return `@t ${idx + 1},0,1,"${displayName}"`; }), submissions, ); diff --git a/packages/hydrooj/src/handler/judge.ts b/packages/hydrooj/src/handler/judge.ts index 8d8bb6c89..f0f4a5dc9 100644 --- a/packages/hydrooj/src/handler/judge.ts +++ b/packages/hydrooj/src/handler/judge.ts @@ -122,8 +122,10 @@ export class JudgeResultCallbackContext { if (rdoc.contest?.toString().startsWith('0'.repeat(23))) return; const accept = rdoc.status === builtin.STATUS.STATUS_ACCEPTED; const updated = await problem.updateStatus(rdoc.domainId, rdoc.pid, rdoc.uid, rdoc._id, rdoc.status, rdoc.score); - if (rdoc.contest) await contest.updateStatus(rdoc.domainId, rdoc.contest, rdoc.uid, rdoc._id, rdoc.pid, rdoc); - else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); + if (rdoc.contest) { + const teamVid = await contest.getTeamVid(rdoc.domainId, rdoc.contest, rdoc.uid); + await contest.updateStatus(rdoc.domainId, rdoc.contest, teamVid ?? rdoc.uid, rdoc._id, rdoc.pid, rdoc); + } else if (accept && updated) await domain.incUserInDomain(rdoc.domainId, rdoc.uid, 'nAccept', 1); const isNormalSubmission = ![ STATUS.STATUS_ETC, STATUS.STATUS_HACK_SUCCESSFUL, STATUS.STATUS_HACK_UNSUCCESSFUL, STATUS.STATUS_FORMAT_ERROR, STATUS.STATUS_SYSTEM_ERROR, STATUS.STATUS_CANCELED, diff --git a/packages/hydrooj/src/handler/problem.ts b/packages/hydrooj/src/handler/problem.ts index a2bd37421..1ef490eb6 100644 --- a/packages/hydrooj/src/handler/problem.ts +++ b/packages/hydrooj/src/handler/problem.ts @@ -533,7 +533,7 @@ export class ProblemSubmitHandler extends ProblemDetailHandler { await Promise.all([ problem.inc(domainId, this.pdoc.docId, 'nSubmit', 1), domain.incUserInDomain(domainId, this.user._id, 'nSubmit'), - tid && contest.updateStatus(domainId, tid, this.user._id, rid, this.pdoc.docId), + tid && contest.updateStatus(domainId, tid, this.team ?? this.user._id, rid, this.pdoc.docId), ]); } if (tid && !pretest && !contest.canShowSelfRecord.call(this, this.tdoc)) { @@ -560,8 +560,13 @@ export class ProblemHackHandler extends ProblemDetailHandler { if (this.tdoc.rule !== 'codeforces') throw new HackFailedError('This contest is not hackable.'); if (!contest.isOngoing(this.tdoc, this.tsdoc)) throw new ContestNotLiveError(this.tdoc.docId); } - if (this.rdoc.uid === this.user._id) throw new HackFailedError('You cannot hack your own submission'); - if (this.psdoc?.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You must accept this problem before hacking.'); + if (this.rdoc.uid === this.user._id + || (tid && await contest.isSameTeam(domainId, tid, this.rdoc.uid, this.user._id))) { + throw new HackFailedError('You cannot hack your own submission'); + } + const accepted = this.psdoc?.status === STATUS.STATUS_ACCEPTED + || this.tsdoc?.detail?.[this.pdoc.docId]?.status === STATUS.STATUS_ACCEPTED; + if (!accepted) throw new HackFailedError('You must accept this problem before hacking.'); if (this.rdoc.status !== STATUS.STATUS_ACCEPTED) throw new HackFailedError('You cannot hack a unsuccessful submission.'); } diff --git a/packages/hydrooj/src/handler/record.ts b/packages/hydrooj/src/handler/record.ts index 115bad631..9a4fa780a 100644 --- a/packages/hydrooj/src/handler/record.ts +++ b/packages/hydrooj/src/handler/record.ts @@ -7,7 +7,7 @@ import { PermissionError, PretestRejudgeFailedError, ProblemConfigError, ProblemNotFoundError, RecordNotFoundError, UserNotFoundError, } from '../error'; -import { RecordDoc, Tdoc } from '../interface'; +import { ContestStatusDoc, RecordDoc, Tdoc } from '../interface'; import { PERM, PRIV, STATUS } from '../model/builtin'; import * as contest from '../model/contest'; import problem, { ProblemDoc } from '../model/problem'; @@ -53,16 +53,23 @@ export class RecordListHandler extends ContestDetailBaseHandler { if (udoc) q.uid = udoc._id; else invalid = true; } - if (q.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + // Team: a member viewing the contest record list sees the whole team's submissions. + let teamMembers: number[] | null = null; + if (tid && this.team && (q.uid === undefined || q.uid === this.user._id)) { + // this.tsdoc is already the team vuser's tsdoc (swapped in __prepare); no extra DB fetch needed + teamMembers = this.tsdoc?.members?.length ? this.tsdoc.members : null; + } + if (q.uid !== this.user._id && !teamMembers) this.checkPerm(PERM.PERM_VIEW_RECORD); if (tid) { tdoc = await contest.get(domainId, tid); this.tdoc = tdoc; if (!tdoc) throw new ContestNotFoundError(domainId, pid); if (!contest.canShowScoreboard.call(this, tdoc, true)) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); - if (!contest[q.uid === this.user._id ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true)) { + const viewingSelf = q.uid === this.user._id || !!teamMembers; + if (!contest[viewingSelf ? 'canShowSelfRecord' : 'canShowRecord'].call(this, tdoc, true)) { throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); } - if (!(await contest.getStatus(domainId, tid, this.user._id))?.attend) { + if (!this.team && !(await contest.getStatus(domainId, tid, this.user._id))?.attend) { const name = tdoc.rule === 'homework' ? "You haven't claimed this homework yet." : "You haven't attended this contest yet."; @@ -89,6 +96,7 @@ export class RecordListHandler extends ContestDetailBaseHandler { delete q.contest; q._id = { $gt: Time.getObjectID(new Date(Date.now() - 10 * Time.week)) }; } + if (teamMembers && !all && !allDomain) q.uid = { $in: teamMembers }; let cursor = record.getMulti(allDomain ? '' : domainId, q).sort('_id', -1); if (!full) cursor = cursor.project(buildProjection(record.PROJECTION_LIST)); const limit = full ? 10 : system.get('pagination.record'); @@ -136,7 +144,7 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { async prepare(domainId: string, rid: ObjectId) { this.rdoc = await record.get(domainId, rid); if (!this.rdoc) throw new RecordNotFoundError(rid); - if (this.rdoc.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + if (!(await contest.isOwnOrTeammateRecord(domainId, this.rdoc, this.user._id))) this.checkPerm(PERM.PERM_VIEW_RECORD); } async download() { @@ -164,6 +172,7 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { if (rev && allRevs[rev.toString()]) { rdoc = { ...rdoc, ...omit(await record.collHistory.findOne({ _id: rev }), ['_id']), progress: null }; } + const isOwnOrTeammate = await contest.isOwnOrTeammateRecord(domainId, rdoc, this.user._id); let canViewDetail = true; if (rdoc.contest?.toString().startsWith('0'.repeat(23))) { if (rdoc.uid !== this.user._id) throw new PermissionError(PERM.PERM_READ_RECORD_CODE); @@ -171,8 +180,8 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { this.tdoc = await contest.get(domainId, rdoc.contest); let canView = this.user.own(this.tdoc); canView ||= contest.canShowRecord.call(this, this.tdoc); - canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && rdoc.uid === this.user._id; - if (!canView && rdoc.uid !== this.user._id) throw new PermissionError(rid); + canView ||= contest.canShowSelfRecord.call(this, this.tdoc, true) && isOwnOrTeammate; + if (!canView && !isOwnOrTeammate) throw new PermissionError(rid); canViewDetail = canView; this.args.tid = this.tdoc.docId; if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { @@ -187,12 +196,13 @@ export class RecordDetailHandler extends ContestDetailBaseHandler { user.getById(domainId, rdoc.uid), ]); - let canViewCode = rdoc.uid === this.user._id; + let canViewCode = isOwnOrTeammate; canViewCode ||= this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE); canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE_ACCEPT) && self?.status === STATUS.STATUS_ACCEPTED; if (this.tdoc) { - this.tsdoc = await contest.getStatus(domainId, this.tdoc.docId, this.user._id); + const teamVid = this.tdoc.allowTeam ? await contest.getTeamVid(domainId, this.tdoc.docId, this.user._id) : null; + this.tsdoc = await contest.getStatus(domainId, this.tdoc.docId, teamVid ?? this.user._id); canViewCode ||= this.user.own(this.tdoc); if (this.tdoc.allowViewCode && contest.isDone(this.tdoc)) { canViewCode ||= !!this.tsdoc?.attend; @@ -261,6 +271,7 @@ export class RecordMainConnectionHandler extends ConnectionHandler { status: number; pretest = false; tdoc: Tdoc; + teamMembers?: number[]; applyProjection = false; noTemplate = false; queue: Map Promise> = new Map(); @@ -299,7 +310,18 @@ export class RecordMainConnectionHandler extends ConnectionHandler { else throw new UserNotFoundError(uidOrName); } } - if (this.uid !== this.user._id) this.checkPerm(PERM.PERM_VIEW_RECORD); + if (this.tdoc?.allowTeam && !pretest && !all && !allDomain && (this.uid === undefined || this.uid === this.user._id)) { + const teamVid = await contest.getTeamVid(domainId, this.tdoc.docId, this.user._id); + if (teamVid) { + const tsdoc = await contest.getStatus(domainId, this.tdoc.docId, teamVid); + this.teamMembers = tsdoc?.members?.length ? tsdoc.members : undefined; + } + } + if (this.uid !== this.user._id && !this.teamMembers) { + const sameTeam = this.tdoc?.allowTeam && typeof this.uid === 'number' + && await contest.isSameTeam(domainId, this.tdoc.docId, this.uid, this.user._id); + if (!sameTeam) this.checkPerm(PERM.PERM_VIEW_RECORD); + } if (pid) { const pdoc = await problem.get(domainId, pid); if (pdoc) this.pid = pdoc.docId; @@ -336,13 +358,16 @@ export class RecordMainConnectionHandler extends ConnectionHandler { if (!rdoc.contest && this.tid) return; if (rdoc.contest && ![this.tid, '000000000000000000000000'].includes(rdoc.contest.toString())) return; if (this.tid && rdoc.contest?.toString() !== '0'.repeat(24)) { - if (rdoc.uid !== this.user._id && !contest.canShowRecord.call(this, this.tdoc, true)) return; - if (rdoc.uid === this.user._id && !contest.canShowSelfRecord.call(this, this.tdoc, true)) return; + const own = await contest.isOwnOrTeammateRecord(this.args.domainId, rdoc, this.user._id); + if (!own && !contest.canShowRecord.call(this, this.tdoc, true)) return; + if (own && !contest.canShowSelfRecord.call(this, this.tdoc, true)) return; } } } if (typeof this.pid === 'number' && rdoc.pid !== this.pid) return; - if (typeof this.uid === 'number' && rdoc.uid !== this.uid) return; + if (this.teamMembers) { + if (!this.teamMembers.includes(rdoc.uid)) return; + } else if (typeof this.uid === 'number' && rdoc.uid !== this.uid) return; let [udoc, pdoc] = await Promise.all([ user.getById(this.args.domainId, rdoc.uid), @@ -381,6 +406,7 @@ export class RecordMainConnectionHandler extends ConnectionHandler { export class RecordDetailConnectionHandler extends ConnectionHandler { pdoc: ProblemDoc; tdoc?: Tdoc; + tsdoc?: ContestStatusDoc; rid: string = ''; disconnectTimeout: NodeJS.Timeout; throttleSend: any; @@ -393,29 +419,30 @@ export class RecordDetailConnectionHandler extends ConnectionHandler { async prepare(domainId: string, rid: ObjectId, noTemplate = false) { const rdoc = await record.get(domainId, rid); if (!rdoc) return; + const isOwnOrTeammate = await contest.isOwnOrTeammateRecord(domainId, rdoc, this.user._id); if (rdoc.contest && ![record.RECORD_GENERATE, record.RECORD_PRETEST].some((i) => i.toHexString() === rdoc.contest.toHexString())) { this.tdoc = await contest.get(domainId, rdoc.contest); let canView = this.user.own(this.tdoc); canView ||= contest.canShowRecord.call(this, this.tdoc); - canView ||= this.user._id === rdoc.uid && contest.canShowSelfRecord.call(this, this.tdoc); + canView ||= isOwnOrTeammate && contest.canShowSelfRecord.call(this, this.tdoc); if (!canView) throw new PermissionError(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); if (!this.user.own(this.tdoc) && !this.user.hasPerm(PERM.PERM_EDIT_CONTEST)) { this.applyProjection = true; } + const teamVid = this.tdoc.allowTeam ? await contest.getTeamVid(domainId, this.tdoc.docId, this.user._id) : null; + this.tsdoc = await contest.getStatus(domainId, this.tdoc.docId, teamVid ?? this.user._id); } const [pdoc, self] = await Promise.all([ problem.get(rdoc.domainId, rdoc.pid), problem.getStatus(domainId, rdoc.pid, this.user._id), ]); - this.canViewCode = rdoc.uid === this.user._id; + this.canViewCode = isOwnOrTeammate; this.canViewCode ||= this.user.hasPriv(PRIV.PRIV_READ_RECORD_CODE); this.canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE); this.canViewCode ||= this.user.hasPerm(PERM.PERM_READ_RECORD_CODE_ACCEPT) && self?.status === STATUS.STATUS_ACCEPTED; - if (!rdoc.contest || this.user._id !== rdoc.uid) { - if (!problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); - } + if (!this.tsdoc?.attend && pdoc && !problem.canViewBy(pdoc, this.user)) throw new PermissionError(PERM.PERM_VIEW_PROBLEM_HIDDEN); this.pdoc = pdoc; this.noTemplate = noTemplate; diff --git a/packages/hydrooj/src/interface.ts b/packages/hydrooj/src/interface.ts index a8034123a..4fc81e30d 100644 --- a/packages/hydrooj/src/interface.ts +++ b/packages/hydrooj/src/interface.ts @@ -92,7 +92,7 @@ export interface Udoc extends Record { loginip: string; } -export interface VUdoc { +export interface VUdoc extends Record { _id: number; mail: string; mailLower: string; @@ -106,6 +106,11 @@ export interface VUdoc { loginat: Date; ip: ['127.0.0.1']; loginip: '127.0.0.1'; + + // for contest team + displayName?: string; + members?: number[]; + invite?: number[]; } export interface GDoc { @@ -277,6 +282,7 @@ export interface Tdoc extends Document { balloon?: Record; score?: Record; langs?: string[]; + allowTeam?: boolean; /** * In hours @@ -464,6 +470,8 @@ export interface ContestStatusDoc extends StatusDocBase, ContestStat { startAt?: Date; endAt?: Date; // 灵活时间模式的结束时间,或者是提前结束比赛的时间 rev?: number; + displayName?: string; + members?: number[]; } export interface TrainingStatusDoc extends StatusDocBase, Record { diff --git a/packages/hydrooj/src/model/contest.ts b/packages/hydrooj/src/model/contest.ts index 0c5622858..7e95508ff 100644 --- a/packages/hydrooj/src/model/contest.ts +++ b/packages/hydrooj/src/model/contest.ts @@ -85,6 +85,23 @@ export function isExtended(tdoc: Tdoc) { return tdoc.penaltySince.getTime() <= now && now < tdoc.endAt.getTime(); } +async function getScoreboardUdict( + tdoc: Tdoc, rankedTsdocs: [number, ContestStatusDoc][], uids: number[], showDisplayName: boolean, +) { + const memberUids = tdoc.allowTeam + ? rankedTsdocs.flatMap(([, tsdoc]) => tsdoc.members || []) + : []; + const udict = await UserModel.getListForRender(tdoc.domainId, [...uids, ...memberUids], showDisplayName ? ['displayName'] : []); + if (tdoc.allowTeam) { + for (const [, tsdoc] of rankedTsdocs) { + if (tsdoc.members?.length && udict[tsdoc.uid]) { + (udict[tsdoc.uid] as any).teamMembers = tsdoc.members.filter((uid) => udict[uid]); + } + } + } + return udict; +} + export function buildContestRule(def: Optional, 'applyProjection'>): ContestRule; export function buildContestRule(def: Partial>, baseRule: ContestRule): ContestRule; export function buildContestRule(def: Partial>, baseRule: ContestRule = {} as any) { @@ -240,7 +257,7 @@ const acm = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0) && (a.time || 0) === (b.time || 0)); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await getScoreboardUdict(tdoc, rankedTsdocs, uids, config.showDisplayName); // Find first accept const first = {}; const data = await document.collStatus.aggregate([ @@ -415,7 +432,7 @@ const oi = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => (a.score || 0) === (b.score || 0)); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await getScoreboardUdict(tdoc, rankedTsdocs, uids, config.showDisplayName); const psdict = {}; const first = {}; const useRelativeTime = !!tdoc.duration; @@ -785,7 +802,7 @@ const homework = buildContestRule({ async scoreboard(config, _, tdoc, pdict, cursor) { const rankedTsdocs = await db.ranked(cursor, (a, b) => a.score === b.score); const uids = rankedTsdocs.map(([, tsdoc]) => tsdoc.uid); - const udict = await UserModel.getListForRender(tdoc.domainId, uids, config.showDisplayName ? ['displayName'] : []); + const udict = await getScoreboardUdict(tdoc, rankedTsdocs, uids, config.showDisplayName); const columns = await this.scoreboardHeader(config, _, tdoc, pdict); const rows: ScoreboardRow[] = [ columns, @@ -903,6 +920,15 @@ export async function getStatus(domainId: string, tid: ObjectId, uid: number) { return await document.getStatus(domainId, document.TYPE_CONTEST, tid, uid); } +export function getMultiStatus(domainId: string, query: any) { + return document.getMultiStatus(domainId, document.TYPE_CONTEST, query); +} + +export async function getTeamVid(domainId: string, tid: ObjectId, uid: number): Promise { + const s = await getMultiStatus(domainId, { docId: tid, members: uid }).project({ uid: 1 }).limit(1).next(); + return s?.uid ?? null; +} + export async function updateStatus( domainId: string, tid: ObjectId, uid: number, rid: ObjectId, pid: number, { @@ -924,8 +950,18 @@ export async function updateStatus( export async function getListStatus(domainId: string, uid: number, tids: ObjectId[]) { const r = {}; - // eslint-disable-next-line no-await-in-loop - for (const tid of tids) r[tid.toHexString()] = await getStatus(domainId, tid, uid); + for (const tid of tids) { + // eslint-disable-next-line no-await-in-loop + const tsdoc = await getStatus(domainId, tid, uid); + if (tsdoc?.attend) { + r[tid.toHexString()] = tsdoc; + continue; + } + // eslint-disable-next-line no-await-in-loop + const teamVid = await getTeamVid(domainId, tid, uid); + // eslint-disable-next-line no-await-in-loop + r[tid.toHexString()] = teamVid ? await getStatus(domainId, tid, teamVid) : tsdoc; + } return r; } @@ -939,10 +975,6 @@ export async function attend(domainId: string, tid: ObjectId, uid: number, paylo return {}; } -export function getMultiStatus(domainId: string, query: any) { - return document.getMultiStatus(domainId, document.TYPE_CONTEST, query); -} - export function setStatus(domainId: string, tid: ObjectId, uid: number, $set?: any, $unset?: any) { return document.setStatus(domainId, document.TYPE_CONTEST, tid, uid, $set, $unset); } @@ -997,6 +1029,19 @@ export async function unlockScoreboard(domainId: string, tid: ObjectId) { await recalcStatus(domainId, tid); } +export async function isSameTeam(domainId: string, tid: ObjectId, a: number, b: number): Promise { + if (a === b) return true; + const [va, vb] = await Promise.all([getTeamVid(domainId, tid, a), getTeamVid(domainId, tid, b)]); + return !!va && va === vb; +} + +export async function isOwnOrTeammateRecord(domainId: string, rdoc: RecordDoc, uid: number): Promise { + if (rdoc.uid === uid) return true; + if (!rdoc.contest) return false; + if (RecordModel.RECORD_PRETEST.equals(rdoc.contest) || RecordModel.RECORD_GENERATE.equals(rdoc.contest)) return false; + return isSameTeam(domainId, rdoc.contest, rdoc.uid, uid); +} + export function canViewHiddenScoreboard(this: { user: User }, tdoc: Tdoc) { if (this.user.own(tdoc)) return true; if (tdoc.rule === 'homework') return this.user.hasPerm(PERM.PERM_VIEW_HOMEWORK_HIDDEN_SCOREBOARD); @@ -1060,10 +1105,13 @@ export function getClarification(domainId: string, did: ObjectId) { return document.get(domainId, document.TYPE_CONTEST_CLARIFICATION, did); } -export function getMultiClarification(domainId: string, tid: ObjectId, owner?: number) { +export function getMultiClarification(domainId: string, tid: ObjectId, owner?: number | number[]) { + const ownerFilter = owner === undefined + ? {} + : { owner: { $in: [0, ...(Array.isArray(owner) ? owner : [owner])] } }; return document.getMulti( domainId, document.TYPE_CONTEST_CLARIFICATION, - { parentType: document.TYPE_CONTEST, parentId: tid, ...(typeof owner === 'number' ? { owner: { $in: [owner, 0] } } : {}) }, + { parentType: document.TYPE_CONTEST, parentId: tid, ...ownerFilter }, ).sort('_id', -1).toArray(); } @@ -1118,7 +1166,7 @@ export async function apply(ctx: Context) { if (!bdoc.first) return; (async () => { const tsdocs = await getMultiStatus(domainId, { docId: tid, subscribe: 1 }).toArray(); - const uids = Array.from(new Set(tsdocs.map((tsdoc) => tsdoc.uid))); + const uids = Array.from(new Set(tsdocs.flatMap((tsdoc) => (tsdoc.members?.length ? tsdoc.members : [tsdoc.uid])))); const [team, tdoc, pdoc] = await Promise.all([ UserModel.getById(domainId, bdoc.uid), get(domainId, tid), @@ -1147,6 +1195,9 @@ global.Hydro.model.contest = { add, getListStatus, getMultiStatus, + getTeamVid, + isSameTeam, + isOwnOrTeammateRecord, attend, edit, del, diff --git a/packages/hydrooj/src/model/user.ts b/packages/hydrooj/src/model/user.ts index 727abb65e..2bb24783e 100644 --- a/packages/hydrooj/src/model/user.ts +++ b/packages/hydrooj/src/model/user.ts @@ -379,28 +379,63 @@ class UserModel { @ArgMethod static async ensureVuser(uname: string) { - const [[min], current] = await Promise.all([ - collV.find({}).sort({ _id: 1 }).limit(1).toArray(), - collV.findOne({ unameLower: uname.toLowerCase() }), - ]); + const current = await collV.findOne({ unameLower: uname.toLowerCase() }); if (current) return current._id; - const uid = min?._id ? min._id - 1 : -1000; - await collV.insertOne({ - _id: uid, - mail: `${-uid}@vuser.local`, - mailLower: `${-uid}@vuser.local`, - uname, - unameLower: uname.trim().toLowerCase(), - hash: '', - salt: '', - hashType: 'hydro', - regat: new Date(), - ip: ['127.0.0.1'], - loginat: new Date(), - loginip: '127.0.0.1', - priv: 0, - }); - return uid; + return UserModel.createVuser(uname); + } + + @ArgMethod + static async createVuser(uname: string, extra: Record = {}) { + const [min] = await collV.find({}).sort({ _id: 1 }).limit(1).toArray(); + let uid = min?._id ? min._id - 1 : -1000; + while (true) { + try { + // eslint-disable-next-line no-await-in-loop + await collV.insertOne({ + ...extra, + _id: uid, + mail: `${-uid}@vuser.local`, + mailLower: `${-uid}@vuser.local`, + uname, + unameLower: uname.trim().toLowerCase(), + hash: '', + salt: '', + hashType: 'hydro', + regat: new Date(), + ip: ['127.0.0.1'], + loginat: new Date(), + loginip: '127.0.0.1', + priv: 0, + }); + return uid; + } catch (e) { + // Duplicate _id from a concurrent createVuser/ensureVuser: pick the next slot. + if (e?.code === 11000 && JSON.stringify(e.keyPattern) === '{"_id":1}') { + uid -= 1; + continue; + } + throw e; + } + } + } + + static getVuserById(uid: number) { + return collV.findOne({ _id: uid }); + } + + static getVusersByMember(uid: number, projection?: Partial>) { + const cursor = collV.find({ members: uid }); + return projection ? cursor.project(projection).toArray() : cursor.toArray(); + } + + static getVusersByInvite(uid: number) { + return collV.find({ invite: uid }).toArray(); + } + + static async updateVuserById(uid: number, update: any) { + const vdoc = await collV.findOneAndUpdate({ _id: uid }, update, { returnDocument: 'after' }); + deleteUserCache(vdoc); + return vdoc; } static getMulti(params: Filter = {}, projection?: (keyof Udoc)[]) { diff --git a/packages/onsite-toolkit/submit.ts b/packages/onsite-toolkit/submit.ts index 691164fbd..985b8f31f 100644 --- a/packages/onsite-toolkit/submit.ts +++ b/packages/onsite-toolkit/submit.ts @@ -73,7 +73,7 @@ Language ${SettingModel.langs[lang].display} (${lang}) await Promise.all([ ProblemModel.inc(domainId, this.pdoc.docId, 'nSubmit', 1), DomainModel.incUserInDomain(domainId, this.user._id, 'nSubmit'), - ContestModel.updateStatus(domainId, this.tdoc.docId, this.user._id, rid, this.pdoc.docId), + ContestModel.updateStatus(domainId, this.tdoc.docId, this.team ?? this.user._id, rid, this.pdoc.docId), ]); return { rid }; } diff --git a/packages/ui-default/components/contest/contest.page.ts b/packages/ui-default/components/contest/contest.page.ts index fe98096a3..7c2e0a40d 100644 --- a/packages/ui-default/components/contest/contest.page.ts +++ b/packages/ui-default/components/contest/contest.page.ts @@ -1,17 +1,66 @@ import $ from 'jquery'; +import { ActionDialog } from 'vj/components/dialog'; import Notification from 'vj/components/notification'; import { AutoloadPage } from 'vj/misc/Page'; import { delay, i18n, request } from 'vj/utils'; const contestPage = new AutoloadPage('contestPage', () => { - $('[data-contest-code]').on('click', (ev) => { + const $dialogBody = $('.dialog__body--contest-attend > div'); + if (!$dialogBody.length) return; + + function selectMode(mode: 'personal' | 'team') { + const $button = $dialogBody.find(`[data-contest-attend-mode="${mode}"]`); + if ($button.prop('disabled')) return; + $dialogBody.find('[name="contest_attend_mode"]').val(mode); + $dialogBody.find('[data-contest-attend-mode]').removeClass('primary'); + $button.addClass('primary'); + $dialogBody.find('[name="contest_attend_vuid"]').prop('disabled', mode !== 'team'); + } + + const attendDialog = new ActionDialog({ + $body: $dialogBody, + onDispatch(action) { + if (action !== 'ok') return true; + const mode = $dialogBody.find('[name="contest_attend_mode"]').val(); + const vuid = $dialogBody.find('[name="contest_attend_vuid"]').val(); + if (mode === 'team' && !vuid) { + Notification.error(i18n('Please select a team.')); + return false; + } + const $code = $dialogBody.find('[name="contest_attend_code"]'); + if ($code.length && !$code.val()?.toString().trim()) { + Notification.error(i18n('Invitation code is required.')); + return false; + } + return true; + }, + }); + + attendDialog.clear = function () { + selectMode('personal'); + this.$dom.find('[name="contest_attend_code"]').val(''); + return this; + }; + + $dialogBody.on('click', '[data-contest-attend-mode]', (ev) => { + selectMode($(ev.currentTarget).attr('data-contest-attend-mode') as 'personal' | 'team'); + }); + + $('[data-contest-attend-form]').on('submit', async (ev) => { ev.preventDefault(); - // eslint-disable-next-line no-alert - const code = prompt(i18n('Invitation code:')); - request.post('', { - operation: 'attend', - code, - }).then(() => { + const $form = $(ev.currentTarget); + const params: Record = { operation: 'attend' }; + + if ($form.is('[data-contest-needs-dialog]')) { + const action = await attendDialog.clear().open(); + if (action !== 'ok') return; + const mode = $dialogBody.find('[name="contest_attend_mode"]').val(); + if (mode === 'team') params.vuid = $dialogBody.find('[name="contest_attend_vuid"]').val(); + const code = $dialogBody.find('[name="contest_attend_code"]').val(); + if (code) params.code = code.toString().trim(); + } + + request.post($form.attr('action') || '', params).then(() => { Notification.success(i18n('Successfully attended')); delay(1000).then(() => window.location.reload()); }).catch((e) => { diff --git a/packages/ui-default/components/contest/contest_sidebar.page.styl b/packages/ui-default/components/contest/contest_sidebar.page.styl index 8e684f686..ee8ca5f01 100644 --- a/packages/ui-default/components/contest/contest_sidebar.page.styl +++ b/packages/ui-default/components/contest/contest_sidebar.page.styl @@ -19,3 +19,23 @@ .contest-sidebar__status margin-top: rem(20px) + +.contest-attend-dialog__modes + display: grid + grid-template-columns: repeat(2, minmax(0, 1fr)) + gap: rem(10px) + margin-bottom: rem(12px) + + .button + margin-bottom: 0 + text-align: center + white-space: normal + + +mobile() + grid-template-columns: 1fr + +.contest-attend-dialog__hint + margin-bottom: rem(12px) + +.contest-attend-dialog__team-select + margin-top: rem(12px) diff --git a/packages/ui-default/templates/contest_edit.html b/packages/ui-default/templates/contest_edit.html index 833f8b2d5..dc55325c9 100644 --- a/packages/ui-default/templates/contest_edit.html +++ b/packages/ui-default/templates/contest_edit.html @@ -174,6 +174,15 @@

{{ _('Contest Settings') }}

value:tdoc.allowPrint|default(false), row:false }) }} + {{ form.form_checkbox({ + columns:4, + label:'Allow Team', + name:'allowTeam', + placeholder:_('Allow team participation (users register as a team at /contest/team)'), + value:tdoc.allowTeam|default(false), + row:false, + disabled:tdoc.allowTeam|default(false) + }) }} {% endif %} + {% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %} +
+
+

+ {{ _('My Teams') }} +

+
+ +
+ {% endif %} {% endblock %} diff --git a/packages/ui-default/templates/contest_team.html b/packages/ui-default/templates/contest_team.html new file mode 100644 index 000000000..4882b85c5 --- /dev/null +++ b/packages/ui-default/templates/contest_team.html @@ -0,0 +1,113 @@ +{% import "components/user.html" as user with context %} +{% extends "layout/basic.html" %} +{% block content %} +
+
+
+
+

{{ _('Team Participation') }}

+
+
+ +

{{ _('Create a Team') }}

+
+ + + +
+ + {% if invites.length %} +
+

{{ _('Invitations') }}

+ + + + {% for v in invites %} + + + + + {% endfor %} + +
{{ _('Team') }}{{ _('Action') }}
{{ v.displayName }} +
+ + + +
+
+ + + +
+
+ {% endif %} + +
+

{{ _('My Teams') }}

+ {% if not mine.length %} +

{{ _('You are not in any team. Create one above, or ask a teammate to invite you by your User ID.') }}

+ {% endif %} + {% for v in mine %} +
+

{{ v.displayName }} (ID {{ v._id }})

+ +
+ + + + +
+ +

{{ _('Members') }}

+
    + {% for m in v.members %} +
  • + {{ user.render_inline(udict[m], avatar=false, badge=false) }} +
    + + + + +
    +
  • + {% endfor %} +
+ + {% if v.invite and v.invite.length %} +

{{ _('Pending Invites') }}

+
    + {% for uid in v.invite %} +
  • + {{ user.render_inline(udict[uid], avatar=false, badge=false) }} +
    + + + + +
    +
  • + {% endfor %} +
+ {% endif %} + +
+ + + + +
+ +
+ + + +
+
+ {% endfor %} + +
+
+
+
+{% endblock %} diff --git a/packages/ui-default/templates/partials/contest_sidebar.html b/packages/ui-default/templates/partials/contest_sidebar.html index 522594045..2d1111e92 100644 --- a/packages/ui-default/templates/partials/contest_sidebar.html +++ b/packages/ui-default/templates/partials/contest_sidebar.html @@ -44,12 +44,58 @@

{{ tdoc.title }}

{% if not (tsdoc.attend) and not model.contest.isDone(tdoc) %}