mirror of
https://gitcode.com/gh_mirrors/hy/Hydro.git
synced 2026-10-17 09:36:08 +08:00
core: contest team (#1183)
This commit is contained in:
1 parent
238b1263bf
commit
78e898fdfc
22 files changed
+934
-96
No files matched your search
@@ -489,6 +489,40 @@ Materials: 材料
|
||||
Max files for user: 单用户最大文件数量
|
||||
Max files per problem: 单题最大文件数量
|
||||
Max files size per problem: 单题最大总文件大小
|
||||
Max members per contest team: 每个参赛队伍最大成员数
|
||||
Accept: 接受
|
||||
Reject: 拒绝
|
||||
Allow Team: 允许团队
|
||||
Allow team participation: 允许团队参赛
|
||||
Choose how you want to participate in this contest.: 选择你参加本次比赛的方式。
|
||||
Create a Team: 创建队伍
|
||||
'Create a team, invite teammates by their username, then choose "Team Participation" when attending an allow-team contest.': 创建队伍,按用户名邀请队友,之后在报名允许团队的比赛时选择“团队参赛”。
|
||||
How It Works: 使用说明
|
||||
Invitation code: 邀请码
|
||||
Invitation code is required.: 必须填写邀请码。
|
||||
Invitation sent: 邀请已发送
|
||||
Invite Member: 邀请成员
|
||||
Leave Team: 退出队伍
|
||||
Leave this team? You may lose access to team contests.: 确定退出该队伍吗?退出后你可能无法访问该队伍参加的比赛。
|
||||
Manage Teams: 管理队伍
|
||||
Members: 成员
|
||||
My Teams: 我的队伍
|
||||
Once enabled, team participation cannot be disabled for this contest.: 一旦启用,该比赛将无法关闭团队参赛。
|
||||
Pending Invites: 待处理邀请
|
||||
Personal Participation: 个人参赛
|
||||
Please select a team.: 请选择一个队伍。
|
||||
Rename: 重命名
|
||||
Revoke: 撤销
|
||||
Save: 保存
|
||||
Successfully attended: 报名成功
|
||||
Team: 队伍
|
||||
Team Participation: 团队参赛
|
||||
Team name: 队伍名称
|
||||
Team participation is not allowed for this contest.: 该比赛不允许团队参赛。
|
||||
Teams let multiple users participate in a contest together, sharing one set of submissions.: 队伍允许多名用户共同参加比赛,共享同一份提交记录。
|
||||
You are not in any team.: 你还没有加入任何队伍。
|
||||
You are not in any team yet. Create one from the panel on the right, or ask a teammate to invite you by your username.: 你还没有加入任何队伍。在右侧面板创建一个,或请队友按你的用户名邀请你。
|
||||
'You have {0} pending team invite(s)': '你有 {0} 个待处理的队伍邀请'
|
||||
Max pretest count per minute: 每分钟自测数量限制
|
||||
Max submission count per minute: 每分钟提交数量限制
|
||||
Max total file size for user: 单用户最大总文件大小
|
||||
|
||||
@@ -486,6 +486,40 @@ Materials: 材料
|
||||
Max files for user: 單使用者最大檔案數量
|
||||
Max files per problem: 單題最大檔案數量
|
||||
Max files size per problem: 單題最大總檔案大小
|
||||
Max members per contest team: 每個參賽隊伍最大成員數
|
||||
Accept: 接受
|
||||
Reject: 拒絕
|
||||
Allow Team: 允許團隊
|
||||
Allow team participation: 允許團隊參賽
|
||||
Choose how you want to participate in this contest.: 選擇你參加本次比賽的方式。
|
||||
Create a Team: 建立隊伍
|
||||
'Create a team, invite teammates by their username, then choose "Team Participation" when attending an allow-team contest.': 建立隊伍,以使用者名稱邀請隊友,之後在報名允許團隊的比賽時選擇「團隊參賽」。
|
||||
How It Works: 使用說明
|
||||
Invitation code: 邀請碼
|
||||
Invitation code is required.: 必須填寫邀請碼。
|
||||
Invitation sent: 邀請已發送
|
||||
Invite Member: 邀請成員
|
||||
Leave Team: 退出隊伍
|
||||
Leave this team? You may lose access to team contests.: 確定退出該隊伍嗎?退出後你可能無法存取該隊伍參加的比賽。
|
||||
Manage Teams: 管理隊伍
|
||||
Members: 成員
|
||||
My Teams: 我的隊伍
|
||||
Once enabled, team participation cannot be disabled for this contest.: 一旦啟用,該比賽將無法關閉團隊參賽。
|
||||
Pending Invites: 待處理邀請
|
||||
Personal Participation: 個人參賽
|
||||
Please select a team.: 請選擇一個隊伍。
|
||||
Rename: 重新命名
|
||||
Revoke: 撤銷
|
||||
Save: 儲存
|
||||
Successfully attended: 報名成功
|
||||
Team: 隊伍
|
||||
Team Participation: 團隊參賽
|
||||
Team name: 隊伍名稱
|
||||
Team participation is not allowed for this contest.: 該比賽不允許團隊參賽。
|
||||
Teams let multiple users participate in a contest together, sharing one set of submissions.: 隊伍允許多名使用者共同參加比賽,共享同一份提交記錄。
|
||||
You are not in any team.: 你還沒有加入任何隊伍。
|
||||
You are not in any team yet. Create one from the panel on the right, or ask a teammate to invite you by your username.: 你還沒有加入任何隊伍。在右側面板建立一個,或請隊友以你的使用者名稱邀請你。
|
||||
'You have {0} pending team invite(s)': '你有 {0} 個待處理的隊伍邀請'
|
||||
Max pretest count per minute: 每分鐘自測數量限制
|
||||
Max submission count per minute: 每分鐘提交數量限制
|
||||
Max total file size for user: 單使用者最大總檔案大小
|
||||
|
||||
@@ -34,6 +34,7 @@ export const PrivilegeError = Err('PrivilegeError', ForbiddenError, function (th
|
||||
export const ContestNotAttendedError = Err('ContestNotAttendedError', ForbiddenError, "You haven't attended this contest yet.");
|
||||
export const RequireProError = Err('RequireProError', ForbiddenError, 'RequireProError');
|
||||
export const ContestAlreadyAttendedError = Err('ContestAlreadyAttendedError', ForbiddenError, "You've already attended this contest.");
|
||||
export const TeamMemberLimitError = Err('TeamMemberLimitError', ForbiddenError, 'Team is full (maximum {0} members).');
|
||||
export const ContestNotLiveError = Err('ContestNotLiveError', ForbiddenError, 'This contest is not live.');
|
||||
export const ContestAlreadyStartedError = Err('ContestAlreadyStartedError', ForbiddenError, 'This contest has already started.');
|
||||
export const ContestNotEndedError = Err('ContestNotEndedError', ForbiddenError, 'This contest is not ended.');
|
||||
@@ -48,7 +49,12 @@ export const DomainJoinAlreadyMemberError = Err('DomainJoinAlreadyMemberError',
|
||||
export const InvalidJoinInvitationCodeError = Err('InvalidJoinInvitationCodeError', ForbiddenError, 'The invitation code you provided is invalid.');
|
||||
export const CurrentPasswordError = Err('CurrentPasswordError', ForbiddenError, "Current password doesn't match.");
|
||||
export const DiscussionLockedError = Err('DiscussionLockedError', ForbiddenError, 'The discussion is locked, you can not reply anymore.');
|
||||
export const NotAssignedError = Err('NotAssignedError', ForbiddenError, 'You are not assigned to this {0}.');
|
||||
export const NotAssignedError = Err('NotAssignedError', ForbiddenError, function (this: HydroError) {
|
||||
if (this.params.length >= 3) {
|
||||
return 'Team member {2} is not assigned to this {0}.';
|
||||
}
|
||||
return 'You are not assigned to this {0}.';
|
||||
});
|
||||
export const FileLimitExceededError = Err('FileLimitExceededError', ForbiddenError, 'File {0} limit exceeded.');
|
||||
export const FileUploadError = Err('FileUploadError', ForbiddenError, 'File upload failed.');
|
||||
export const FileExistsError = Err('FileExistsError', ForbiddenError, 'File {0} already exists.');
|
||||
|
||||
@@ -10,9 +10,10 @@ import {
|
||||
} from '@hydrooj/utils/lib/utils';
|
||||
import { Context, Service } from '../context';
|
||||
import {
|
||||
BadRequestError, ContestAlreadyStartedError, ContestNotAttendedError, ContestNotEndedError, ContestNotFoundError,
|
||||
ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
|
||||
InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, ValidationError,
|
||||
BadRequestError, ContestAlreadyAttendedError, ContestAlreadyStartedError, ContestNotAttendedError, ContestNotEndedError,
|
||||
ContestNotFoundError, ContestNotLiveError, ContestScoreboardHiddenError, FileLimitExceededError, FileUploadError,
|
||||
InvalidTokenError, MethodNotAllowedError, NotAssignedError, NotFoundError, PermissionError, TeamMemberLimitError,
|
||||
ValidationError,
|
||||
} from '../error';
|
||||
import { ContestStatusDoc, FileInfo, ScoreboardConfig, Tdoc } from '../interface';
|
||||
import { PERM, PRIV, STATUS } from '../model/builtin';
|
||||
@@ -78,14 +79,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 +106,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 +165,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 +187,42 @@ 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);
|
||||
if (this.tdoc.assign?.length) {
|
||||
const memberUdict = await user.getList(domainId, v.members);
|
||||
for (const uid of v.members) {
|
||||
if (uid === this.tdoc.owner) continue;
|
||||
const m = memberUdict[uid];
|
||||
if (m?.hasPerm(PERM.PERM_VIEW_HIDDEN_CONTEST)) continue;
|
||||
if (!new Set(this.tdoc.assign).intersection(new Set(m?.group || [])).size) {
|
||||
throw new NotAssignedError('contest', tid, uid);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 +230,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 +240,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 +342,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 +351,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 +368,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 +463,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 +513,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 +713,7 @@ class ContestClarificationHandler extends ContestManagementBaseHandler {
|
||||
]);
|
||||
} else {
|
||||
const tsdocs = await contest.getMultiStatus(domainId, { docId: tid, subscribe: 1 }).toArray();
|
||||
const uids = Array.from<number>(new Set(tsdocs.map((tsdoc) => tsdoc.uid)));
|
||||
const uids = Array.from<number>(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 +740,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 +759,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 +770,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 };
|
||||
@@ -931,9 +980,105 @@ 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)
|
||||
async postCreate(domainId: string, name: string) {
|
||||
const displayName = name.trim();
|
||||
if (!displayName) throw new ValidationError('name');
|
||||
await user.createVuser(`team:${this.user._id}:${randomstring(6)}`, {
|
||||
displayName,
|
||||
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);
|
||||
const displayName = name.trim();
|
||||
if (!displayName) throw new ValidationError('name');
|
||||
await user.updateVuserById(vuid, { $set: { displayName } });
|
||||
this.back();
|
||||
}
|
||||
|
||||
@param('vuid', Types.Int)
|
||||
@param('uid', Types.Int)
|
||||
async postInvite(domainId: string, vuid: number, uid: number) {
|
||||
await this.limitRate('contest_team_invite', 3600, 20);
|
||||
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');
|
||||
const teamMembersLimit = this.ctx.setting.get('limit.team_members');
|
||||
if (v.members.length + (v.invite?.length || 0) >= teamMembersLimit) {
|
||||
throw new TeamMemberLimitError(teamMembersLimit);
|
||||
}
|
||||
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');
|
||||
const teamMembersLimit = this.ctx.setting.get('limit.team_members');
|
||||
if (v.members.length >= teamMembersLimit) throw new TeamMemberLimitError(teamMembersLimit);
|
||||
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);
|
||||
@@ -1030,8 +1175,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,
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -534,7 +534,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)) {
|
||||
@@ -561,8 +561,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.');
|
||||
}
|
||||
|
||||
|
||||
@@ -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<string, () => Promise<any>> = 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;
|
||||
|
||||
@@ -38,6 +38,7 @@ export interface SystemKeys {
|
||||
'server.port': number;
|
||||
'server.language': string;
|
||||
'limit.problem_files_max': number;
|
||||
'limit.team_members': number;
|
||||
'problem.categories': string;
|
||||
'session.keys': string[];
|
||||
'session.saved_expire_seconds': number;
|
||||
@@ -92,7 +93,7 @@ export interface Udoc extends Record<string, any> {
|
||||
loginip: string;
|
||||
}
|
||||
|
||||
export interface VUdoc {
|
||||
export interface VUdoc extends Record<string, any> {
|
||||
_id: number;
|
||||
mail: string;
|
||||
mailLower: string;
|
||||
@@ -106,6 +107,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 +283,7 @@ export interface Tdoc extends Document {
|
||||
balloon?: Record<number, string | { color: string, name: string }>;
|
||||
score?: Record<number, number>;
|
||||
langs?: string[];
|
||||
allowTeam?: boolean;
|
||||
|
||||
/**
|
||||
* In hours
|
||||
@@ -464,6 +471,8 @@ export interface ContestStatusDoc extends StatusDocBase, ContestStat {
|
||||
startAt?: Date;
|
||||
endAt?: Date; // 灵活时间模式的结束时间,或者是提前结束比赛的时间
|
||||
rev?: number;
|
||||
displayName?: string;
|
||||
members?: number[];
|
||||
}
|
||||
|
||||
export interface TrainingStatusDoc extends StatusDocBase, Record<string, any> {
|
||||
|
||||
@@ -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<T>(def: Optional<ContestRule<T>, 'applyProjection'>): ContestRule<T>;
|
||||
export function buildContestRule<T>(def: Partial<ContestRule<T>>, baseRule: ContestRule<T>): ContestRule<T>;
|
||||
export function buildContestRule<T>(def: Partial<ContestRule<T>>, baseRule: ContestRule<T> = {} 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<number | null> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -945,10 +981,6 @@ export async function cancelAttend(domainId: string, tid: ObjectId, uid: number)
|
||||
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);
|
||||
}
|
||||
@@ -1003,6 +1035,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<boolean> {
|
||||
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<boolean> {
|
||||
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);
|
||||
@@ -1066,10 +1111,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();
|
||||
}
|
||||
|
||||
@@ -1124,7 +1172,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<number>(new Set(tsdocs.map((tsdoc) => tsdoc.uid)));
|
||||
const uids = Array.from<number>(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),
|
||||
@@ -1153,6 +1201,9 @@ global.Hydro.model.contest = {
|
||||
add,
|
||||
getListStatus,
|
||||
getMultiStatus,
|
||||
getTeamVid,
|
||||
isSameTeam,
|
||||
isOwnOrTeammateRecord,
|
||||
attend,
|
||||
cancelAttend,
|
||||
edit,
|
||||
|
||||
@@ -449,6 +449,7 @@ export async function apply(ctx: Context) {
|
||||
{ key: { domainId: 1, docType: 1, docId: 1, status: 1, rid: 1, rp: 1 }, name: 'rp', sparse: true },
|
||||
{ key: { domainId: 1, docType: 1, docId: 1, score: -1 }, name: 'contestRuleOI', sparse: true },
|
||||
{ key: { domainId: 1, docType: 1, docId: 1, accept: -1, time: 1 }, name: 'contestRuleACM', sparse: true },
|
||||
{ key: { domainId: 1, docType: 1, docId: 1, members: 1 }, name: 'contestTeam', partialFilterExpression: { members: { $exists: true } } },
|
||||
{ key: { domainId: 1, docType: 1, uid: 1, enroll: 1, docId: 1 }, name: 'training', sparse: true },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,6 +345,7 @@ SystemSetting(
|
||||
Setting('setting_limits', 'limit.user_files_size', 128 * 1024 * 1024, 'number', 'limit.user_files_size', 'Max total file size for user'),
|
||||
Setting('setting_limits', 'limit.contest_files', 100, 'number', 'limit.contest_files', 'Max files for contest or training'),
|
||||
Setting('setting_limits', 'limit.contest_files_size', 128 * 1024 * 1024, 'number', 'limit.contest_files_size', 'Max total file size for contest or training'),
|
||||
Setting('setting_limits', 'limit.team_members', 5, 'number', 'limit.team_members', 'Max members per contest team'),
|
||||
Setting('setting_limits', 'limit.submission', 60, 'number', 'limit.submission', 'Max submission count per minute'),
|
||||
Setting('setting_limits', 'limit.submission_user', 15, 'number', 'limit.submission_user', 'Max submission count per user per minute'),
|
||||
Setting('setting_limits', 'limit.pretest', 60, 'number', 'limit.pretest', 'Max pretest count per minute'),
|
||||
|
||||
@@ -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.trim().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<string, any> = {}) {
|
||||
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: uname.trim(),
|
||||
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<Record<keyof VUdoc, 0 | 1>>) {
|
||||
const cursor = collV.find({ members: uid });
|
||||
return projection ? cursor.project<VUdoc>(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<Udoc> = {}, projection?: (keyof Udoc)[]) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -1,17 +1,100 @@
|
||||
import $ from 'jquery';
|
||||
import { ActionDialog, prompt } 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) => {
|
||||
$('[data-contest-team-invite]').on('click', async (ev) => {
|
||||
const vuid = $(ev.currentTarget).attr('data-vuid');
|
||||
const res = await prompt(i18n('Invite Member'), {
|
||||
user: {
|
||||
type: 'userId',
|
||||
label: i18n('Username / UID'),
|
||||
required: true,
|
||||
autofocus: true,
|
||||
},
|
||||
});
|
||||
if (!res?.user) return;
|
||||
request.post(location.pathname, { operation: 'invite', vuid, uid: res.user })
|
||||
.then(() => {
|
||||
Notification.success(i18n('Invitation sent'));
|
||||
delay(1000).then(() => window.location.reload());
|
||||
})
|
||||
.catch((e) => {
|
||||
Notification.error(e.message || e);
|
||||
});
|
||||
});
|
||||
|
||||
$('[data-contest-team-rename]').on('click', function () {
|
||||
const $card = $(this).closest('[data-contest-team]');
|
||||
$card.find('[data-contest-team-rename-form]').removeClass('is-hidden')
|
||||
.find('input[name="name"]').trigger('focus');
|
||||
$(this).addClass('is-hidden');
|
||||
});
|
||||
|
||||
$('[data-contest-team-rename-cancel]').on('click', function () {
|
||||
const $card = $(this).closest('[data-contest-team]');
|
||||
$card.find('[data-contest-team-rename-form]').addClass('is-hidden');
|
||||
$card.find('[data-contest-team-rename]').removeClass('is-hidden');
|
||||
});
|
||||
|
||||
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').attr('aria-pressed', 'false');
|
||||
$button.addClass('primary').attr('aria-pressed', 'true');
|
||||
$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<string, any> = { 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) => {
|
||||
|
||||
@@ -19,3 +19,26 @@
|
||||
|
||||
.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__desc
|
||||
margin: rem(4px) 0 rem(12px)
|
||||
|
||||
.contest-attend-dialog__team-select
|
||||
margin-top: rem(12px)
|
||||
@@ -0,0 +1,131 @@
|
||||
.page--contest_team
|
||||
.is-hidden
|
||||
display: none !important
|
||||
|
||||
.team-invites-banner
|
||||
background: lighten($progress-color, 38%)
|
||||
border: 1px solid lighten($progress-color, 26%)
|
||||
border-left: 4px solid $progress-color
|
||||
border-radius: rem(4px)
|
||||
padding: rem(12px) rem(14px)
|
||||
margin-bottom: rem($section-margin)
|
||||
|
||||
&__head
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: rem(8px)
|
||||
font-weight: 600
|
||||
color: darken($progress-color, 28%)
|
||||
margin-bottom: rem(8px)
|
||||
|
||||
.icon
|
||||
font-size: rem(18px)
|
||||
|
||||
&__list
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
&__row
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: rem(10px)
|
||||
padding: rem(5px) 0
|
||||
|
||||
&:not(:last-child)
|
||||
border-bottom: 1px solid rgba(#000, 0.06)
|
||||
|
||||
&__actions
|
||||
display: flex
|
||||
gap: rem(8px)
|
||||
|
||||
form
|
||||
margin: 0
|
||||
|
||||
.team-card
|
||||
background: $content-bg-color
|
||||
border: 1px solid $border-2-color
|
||||
border-radius: rem(4px)
|
||||
padding: rem(14px) rem(16px)
|
||||
margin-bottom: rem($section-gap-v)
|
||||
|
||||
&__header
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: rem(10px)
|
||||
|
||||
&__title
|
||||
h3
|
||||
font-size: rem($font-size-title)
|
||||
margin: 0
|
||||
display: inline
|
||||
|
||||
&__meta
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: rem(8px)
|
||||
|
||||
&__count
|
||||
display: inline-flex
|
||||
align-items: center
|
||||
gap: rem(4px)
|
||||
color: $text-3-color
|
||||
font-size: rem($font-size-small)
|
||||
|
||||
&__rename
|
||||
display: flex
|
||||
align-items: center
|
||||
gap: rem(8px)
|
||||
margin: rem(10px) 0 0
|
||||
|
||||
.textbox
|
||||
flex: 1
|
||||
|
||||
&__section
|
||||
margin-top: rem(12px)
|
||||
|
||||
&__label
|
||||
color: $text-3-color
|
||||
font-size: rem($font-size-small)
|
||||
margin: 0 0 rem(6px)
|
||||
|
||||
&__list
|
||||
list-style: none
|
||||
margin: 0
|
||||
padding: 0
|
||||
|
||||
&__row
|
||||
display: flex
|
||||
align-items: center
|
||||
justify-content: space-between
|
||||
gap: rem(10px)
|
||||
padding: rem(5px) 0
|
||||
|
||||
&:not(:last-child)
|
||||
border-bottom: 1px solid rgba(#000, 0.05)
|
||||
|
||||
&__actions
|
||||
display: flex
|
||||
flex-wrap: wrap
|
||||
gap: rem(8px)
|
||||
margin-top: rem(14px)
|
||||
padding-top: rem(12px)
|
||||
border-top: 1px solid $table-border-color
|
||||
|
||||
form
|
||||
margin: 0
|
||||
|
||||
.team-create-form
|
||||
.textbox
|
||||
margin-bottom: rem(8px)
|
||||
|
||||
+mobile()
|
||||
.team-card
|
||||
&__header
|
||||
flex-wrap: wrap
|
||||
|
||||
&__actions
|
||||
.button
|
||||
flex: 1
|
||||
@@ -174,6 +174,16 @@
|
||||
value:tdoc.allowPrint|default(false),
|
||||
row:false
|
||||
}) }}
|
||||
{{ form.form_checkbox({
|
||||
columns:4,
|
||||
label:'Allow Team',
|
||||
name:'allowTeam',
|
||||
placeholder:_('Allow team participation'),
|
||||
help_text:_('Once enabled, team participation cannot be disabled for this contest.'),
|
||||
value:tdoc.allowTeam|default(false),
|
||||
row:false,
|
||||
disabled:tdoc.allowTeam|default(false)
|
||||
}) }}
|
||||
<div style="display: none" class="contest-rule-settings contest-rule--oi contest-rule--strictioi">
|
||||
{{ form.form_checkbox({
|
||||
columns:4,
|
||||
|
||||
@@ -141,6 +141,18 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
|
||||
<div class="section side">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">
|
||||
{{ _('My Teams') }}
|
||||
</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<p><a href="{{ url('contest_team') }}" class="expanded button"><span class="icon icon-user--multiple"></span> {{ _('Manage Teams') }}</a></p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,152 @@
|
||||
{% import "components/user.html" as user with context %}
|
||||
{% extends "layout/basic.html" %}
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="medium-9 columns">
|
||||
<div class="section">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('Team Participation') }}</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
|
||||
{% if invites.length %}
|
||||
<div class="team-invites-banner">
|
||||
<div class="team-invites-banner__head">
|
||||
<span class="icon icon-mail"></span>
|
||||
<span>{{ _('You have {0} pending team invite(s)').format(invites.length) }}</span>
|
||||
</div>
|
||||
<ul class="team-invites-banner__list">
|
||||
{% for v in invites %}
|
||||
<li class="team-invites-banner__row">
|
||||
<span class="team-invites-banner__name">{{ v.displayName }} <small class="supplementary">(ID {{ v._id }})</small></span>
|
||||
<span class="team-invites-banner__actions">
|
||||
<form method="post" action="{{ url('contest_team') }}">
|
||||
<input type="hidden" name="operation" value="accept">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<button type="submit" class="primary button small">{{ _('Accept') }}</button>
|
||||
</form>
|
||||
<form method="post" action="{{ url('contest_team') }}">
|
||||
<input type="hidden" name="operation" value="reject">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<button type="submit" class="button small">{{ _('Reject') }}</button>
|
||||
</form>
|
||||
</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if not mine.length and not invites.length %}
|
||||
{{ nothing.render('You are not in any team yet. Create one from the panel on the right, or ask a teammate to invite you by your username.') }}
|
||||
{% endif %}
|
||||
|
||||
{% for v in mine %}
|
||||
<div class="team-card" data-contest-team>
|
||||
<div class="team-card__header">
|
||||
<div class="team-card__title">
|
||||
<h3>{{ v.displayName }}</h3>
|
||||
<small class="supplementary">(ID {{ v._id }})</small>
|
||||
</div>
|
||||
<div class="team-card__meta">
|
||||
<span class="team-card__count" data-tooltip="{{ _('Members') }}">
|
||||
<span class="icon icon-user--multiple"></span> {{ v.members.length }}
|
||||
</span>
|
||||
<button type="button" class="button small compact" data-contest-team-rename data-tooltip="{{ _('Rename') }}">
|
||||
<span class="icon icon-edit"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form class="team-card__rename is-hidden" method="post" action="{{ url('contest_team') }}" data-contest-team-rename-form>
|
||||
<input type="hidden" name="operation" value="rename">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<input type="text" class="textbox" name="name" value="{{ v.displayName }}">
|
||||
<button type="submit" class="primary button small">{{ _('Save') }}</button>
|
||||
<button type="button" class="button small" data-contest-team-rename-cancel>{{ _('Cancel') }}</button>
|
||||
</form>
|
||||
|
||||
<div class="team-card__section">
|
||||
<p class="team-card__label">{{ _('Members') }}</p>
|
||||
<ul class="team-card__list">
|
||||
{% for m in v.members %}
|
||||
<li class="team-card__row">
|
||||
<span class="team-card__user">{{ user.render_inline(udict[m], badge=false) }}</span>
|
||||
<form class="team-card__row-action" method="post" action="{{ url('contest_team') }}" onsubmit='return confirm({{ _("Remove this member from the team?")|json|safe }});'>
|
||||
<input type="hidden" name="operation" value="leave">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<input type="hidden" name="uid" value="{{ m }}">
|
||||
<button type="submit" class="button small compact" data-tooltip="{{ _('Remove') }}">
|
||||
<span class="icon icon-delete"></span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
{% if v.invite and v.invite.length %}
|
||||
<div class="team-card__section">
|
||||
<p class="team-card__label">{{ _('Pending Invites') }}</p>
|
||||
<ul class="team-card__list">
|
||||
{% for uid in v.invite %}
|
||||
<li class="team-card__row">
|
||||
<span class="team-card__user">{{ user.render_inline(udict[uid], badge=false) }}</span>
|
||||
<form class="team-card__row-action" method="post" action="{{ url('contest_team') }}">
|
||||
<input type="hidden" name="operation" value="leave">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<input type="hidden" name="uid" value="{{ uid }}">
|
||||
<button type="submit" class="button small compact" data-tooltip="{{ _('Revoke') }}">
|
||||
<span class="icon icon-close"></span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="team-card__actions">
|
||||
<button type="button" class="button small" data-contest-team-invite data-vuid="{{ v._id }}">
|
||||
<span class="icon icon-send"></span> {{ _('Invite Member') }}
|
||||
</button>
|
||||
<form method="post" action="{{ url('contest_team') }}" onsubmit='return confirm({{ _("Leave this team? You may lose access to team contests.")|json|safe }});'>
|
||||
<input type="hidden" name="operation" value="leave">
|
||||
<input type="hidden" name="vuid" value="{{ v._id }}">
|
||||
<button type="submit" class="button small">
|
||||
<span class="icon icon-logout"></span> {{ _('Leave Team') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="medium-3 columns">
|
||||
<div class="section side">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('Create a Team') }}</h1>
|
||||
</div>
|
||||
<div class="section__body">
|
||||
<form method="post" action="{{ url('contest_team') }}" class="team-create-form">
|
||||
<input type="hidden" name="operation" value="create">
|
||||
<input type="text" class="textbox" name="name" placeholder="{{ _('Team name') }}" required>
|
||||
<button type="submit" class="primary button expanded">{{ _('Create') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
<div class="section side">
|
||||
<div class="section__header">
|
||||
<h1 class="section__title">{{ _('How It Works') }}</h1>
|
||||
</div>
|
||||
<div class="section__body typo supplementary">
|
||||
<p>{{ _('Teams let multiple users participate in a contest together, sharing one set of submissions.') }}</p>
|
||||
<p>{{ _('Create a team, invite teammates by their username, then choose "Team Participation" when attending an allow-team contest.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -44,12 +44,61 @@
|
||||
{% if not (tsdoc.attend) and not model.contest.isDone(tdoc) %}
|
||||
<li class="menu__item">
|
||||
{% if handler.user.hasPerm(perm.PERM_ATTEND_CONTEST) and handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
|
||||
<form action="{{ url('contest_detail', tid=tdoc.docId) }}" method="POST">
|
||||
{% set team_vdocs = team_vdocs|default([]) %}
|
||||
<form action="{{ url('contest_detail', tid=tdoc.docId) }}" method="POST" data-contest-attend-form{% if tdoc.allowTeam or tdoc._code %} data-contest-needs-dialog{% endif %}>
|
||||
<input type="hidden" name="operation" value="attend">
|
||||
<button class="menu__link" {% if tdoc._code %}data-contest-code{% endif %} type="submit">
|
||||
<button class="menu__link" data-contest-attend type="submit">
|
||||
<span class="icon icon-add"></span> {{ _('Attend Contest') }}
|
||||
</button>
|
||||
</form>
|
||||
<div style="display:none" class="dialog__body--contest-attend">
|
||||
<div>
|
||||
<div class="row"><div class="columns">
|
||||
<h1>{{ _('Attend Contest') }}</h1>
|
||||
<p class="supplementary contest-attend-dialog__desc">{{ _('Choose how you want to participate in this contest.') }}</p>
|
||||
</div></div>
|
||||
<input type="hidden" name="contest_attend_mode" value="personal">
|
||||
<div class="contest-attend-dialog__modes">
|
||||
<button type="button" class="primary rounded button" data-contest-attend-mode="personal" aria-pressed="true">
|
||||
<span class="icon icon-user"></span> {{ _('Personal Participation') }}
|
||||
</button>
|
||||
<button type="button" class="rounded button{% if not tdoc.allowTeam %} disabled{% endif %}" data-contest-attend-mode="team" aria-pressed="false"{% if teamDisabled %} disabled{% endif %}>
|
||||
<span class="icon icon-user--multiple"></span> {{ _('Team Participation') }}
|
||||
</button>
|
||||
</div>
|
||||
{% if not tdoc.allowTeam %}
|
||||
<p class="supplementary contest-attend-dialog__hint">{{ _('Team participation is not allowed for this contest.') }}</p>
|
||||
{% elif not team_vdocs.length %}
|
||||
<p class="supplementary contest-attend-dialog__hint">
|
||||
{{ _('You are not in any team.') }}
|
||||
<a class="typo-a" href="{{ url('contest_team') }}">{{ _('Manage Teams') }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if team_vdocs.length %}
|
||||
<div class="row contest-attend-dialog__team-select"><div class="columns">
|
||||
<label>
|
||||
{{ _('Team') }}
|
||||
<select class="select" name="contest_attend_vuid" disabled>
|
||||
{% for v in team_vdocs %}
|
||||
<option value="{{ v._id }}">{{ v.displayName }} (ID {{ v._id }})</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</div></div>
|
||||
<p class="supplementary contest-attend-dialog__hint">
|
||||
<a class="typo-a" href="{{ url('contest_team') }}">{{ _('Manage Teams') }}</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if tdoc._code %}
|
||||
<div class="row"><div class="columns">
|
||||
<label class="material textbox">
|
||||
{{ _('Invitation code') }}
|
||||
<input name="contest_attend_code" type="text" data-autofocus>
|
||||
</label>
|
||||
</div></div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% elif not handler.user.hasPriv(PRIV.PRIV_USER_PROFILE) %}
|
||||
<a class="menu__link" href="javascript:showSignInDialog()">
|
||||
<span class="icon icon-add"></span> {{ _('Login to Attend Contest') }}
|
||||
|
||||
@@ -4,7 +4,22 @@
|
||||
{%- for tsdoc in tsdocs -%}
|
||||
<tr data-uid="{{ tsdoc.uid }}">
|
||||
<td class="col--uid">{{ tsdoc.uid }}</td>
|
||||
<td class="col--user">{{ user.render_inline(udict[tsdoc.uid], badge=false) }}</td>
|
||||
<td class="col--user">
|
||||
{{ user.render_inline(udict[tsdoc.uid], badge=false) }}
|
||||
{% if tsdoc.displayName or (tsdoc.members and tsdoc.members.length) %}
|
||||
<br><small class="supplementary">
|
||||
{% if tsdoc.members and tsdoc.members.length %}
|
||||
{{ _('Team') }}: {{ tsdoc.displayName }} —
|
||||
{% for uid in tsdoc.members %}
|
||||
{%- if not loop.first -%}, {% endif -%}
|
||||
{{ user.render_inline(udict[uid], avatar=false, badge=false) }}
|
||||
{%- endfor -%}
|
||||
{% else %}
|
||||
{{ tsdoc.displayName }}
|
||||
{% endif %}
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="col--start-time">{{ contest.render_time(tsdoc.startAt or tdoc.beginAt) }}</td>
|
||||
<td class="col--end-time">{{ contest.render_time(tsdoc.endAt or tdoc.endAt) }}</td>
|
||||
<td class="col--rank">{{ _('UnRank') if tsdoc.unrank else _('Rank') }}</td>
|
||||
|
||||
@@ -49,12 +49,25 @@
|
||||
{%- if column.type == 'rank' -%}
|
||||
<span class="{{ 'rank--unrank' if column.value == '0' else 'rank--normal' }}">{{ '*' if column.value == '0' else column.value|string|nl2br|safe }}</span>
|
||||
{%- elif column.type == 'user' -%}
|
||||
{%- set canView = canViewAll or handler.user._id == column.raw -%}
|
||||
{%- set canView = canViewAll or handler.user._id == column.raw or (handler.team and handler.team == column.raw) -%}
|
||||
<button class="star user--{{ column.raw }}" data-uid="{{ column.raw }}">
|
||||
<span class="starred--hide"><span class="icon icon-star--outline" data-tooltip="{{ _('Star') }}"></span></span>
|
||||
<span class="starred--show"><span class="icon icon-star" data-tooltip="{{ _('Unstar') }}"></span></span>
|
||||
</button>
|
||||
{{ user.render_inline(udict[column.raw], badge=false) }}
|
||||
{%- if udict[column.raw].teamMembers -%}
|
||||
<span class="user-profile-link">
|
||||
<span class="icon icon-user--multiple"></span>
|
||||
<span class="user-profile-name">{{ udict[column.raw].displayName }}</span>
|
||||
</span>
|
||||
<br><small class="supplementary">
|
||||
{%- for memberUid in udict[column.raw].teamMembers -%}
|
||||
{%- if loop.index0 -%}, {% endif -%}
|
||||
{{ user.render_inline(udict[memberUid], avatar=false, badge=false) }}
|
||||
{%- endfor -%}
|
||||
</small>
|
||||
{%- else -%}
|
||||
{{ user.render_inline(udict[column.raw], badge=false) }}
|
||||
{%- endif -%}
|
||||
{%- elif column.type == 'record' and column.raw -%}
|
||||
{{ renderRecord(column, canView) }}
|
||||
{%- elif column.type == 'records' -%}
|
||||
@@ -76,4 +89,4 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
Reference in new issue
Block a user