Skip to content

Commit

Permalink
feat: rule system improvements (#94)
Browse files Browse the repository at this point in the history
  • Loading branch information
thezzisu authored Apr 20, 2024
1 parent 11a3099 commit d317390
Show file tree
Hide file tree
Showing 21 changed files with 383 additions and 71 deletions.
3 changes: 3 additions & 0 deletions .yarn/versions/d253d201.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
releases:
"@aoi-js/frontend": minor
"@aoi-js/server": minor
41 changes: 26 additions & 15 deletions apps/frontend/src/components/common/RulesEditor.vue
Original file line number Diff line number Diff line change
@@ -1,25 +1,34 @@
<template>
<AsyncState :state="rules">
<template v-slot="{ value }">
<VExpansionPanels flat>
<VExpansionPanel v-for="rule in value" :key="rule[0]" :title="rule[0]">
<VExpansionPanelText>
<RuleEditor
:endpoint="`${endpoint}/${rule[0]}`"
:id="`${id}.${rule[0]}`"
:schema="rule[1]"
/>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</template>
</AsyncState>
<VCard variant="flat">
<VCardTitle class="d-flex align-center">
{{ t('term.rules') }}
<HelpBtn category="rule" />
</VCardTitle>
<VDivider />
<AsyncState :state="rules">
<template v-slot="{ value }">
<VExpansionPanels flat>
<VExpansionPanel v-for="rule in value" :key="rule[0]" :title="rule[0]">
<VExpansionPanelText>
<RuleEditor
:endpoint="`${endpoint}/${rule[0]}`"
:id="`${id}.${rule[0]}`"
:schema="rule[1]"
/>
</VExpansionPanelText>
</VExpansionPanel>
</VExpansionPanels>
</template>
</AsyncState>
</VCard>
</template>

<script setup lang="ts">
import { useAsyncState } from '@vueuse/core'
import { useI18n } from 'vue-i18n'
import AsyncState from '../utils/AsyncState.vue'
import HelpBtn from '../utils/HelpBtn.vue'
import RuleEditor from './RuleEditor.vue'
Expand All @@ -30,6 +39,8 @@ const props = defineProps<{
id: string
}>()
const { t } = useI18n()
const rules = useAsyncState(async () => {
const mapping = await http.get(props.endpoint).json<Record<string, unknown>>()
return Object.entries(mapping)
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/components/contest/ProblemTabAdmin.vue
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ const submitAllTask = useAsyncTask(async () => {
return withMessage(t('msg.submit-all-success', { count: modifiedCount }))
})
const pull = ref(false)
const pull = ref(true)
const rejudgeAllTask = useAsyncTask(async () => {
const { modifiedCount } = await http
.post(`contest/${props.contestId}/problem/${props.problem._id}/rejudge-all`, {
Expand Down
13 changes: 13 additions & 0 deletions apps/frontend/src/components/utils/HelpBtn.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<template>
<VBtn :href="href" target="_blank" variant="text" color="info" icon="mdi-help-circle-outline" />
</template>

<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
category: string
}>()
const href = computed(() => `https://aoi.fedstack.org/${props.category}`)
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,8 @@
v-model="rejudgeOptions.problemId"
:label="t('term.problem-id')"
:rules="[isUUID, isNotEmpty]"
:append-icon="rejudgeOptions.problemId === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.problemId = undefined"
:append-inner-icon="rejudgeOptions.problemId !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.problemId = undefined"
/>
</VCol>
<VCol cols="6">
Expand All @@ -32,59 +32,59 @@
{ title: 'Completed', value: 4 }
]"
:label="t('term.state')"
:append-icon="rejudgeOptions.state === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.state = undefined"
:append-inner-icon="rejudgeOptions.state !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.state = undefined"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model="rejudgeOptions.status"
:label="t('term.status')"
:append-icon="rejudgeOptions.status === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.status = undefined"
:append-inner-icon="rejudgeOptions.status !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.status = undefined"
/>
</VCol>
<VCol cols="12">
<VTextField
v-model="rejudgeOptions.runnerId"
:rules="[isUUID]"
:label="t('term.runner-id')"
:append-icon="rejudgeOptions.runnerId === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.runnerId = undefined"
:append-inner-icon="rejudgeOptions.runnerId !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.runnerId = undefined"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model.number="rejudgeOptions.scoreL"
:rules="[(v) => v === undefined || v >= 0, (v) => v === undefined || v <= 100]"
:label="t('min-score')"
:append-icon="rejudgeOptions.scoreL === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.scoreL = undefined"
:append-inner-icon="rejudgeOptions.scoreL !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.scoreL = undefined"
/>
</VCol>
<VCol cols="6">
<VTextField
v-model.number="rejudgeOptions.scoreR"
:rules="[(v) => v === undefined || v >= 0, (v) => v === undefined || v <= 100]"
:label="t('max-score')"
:append-icon="rejudgeOptions.scoreR === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.scoreR = undefined"
:append-inner-icon="rejudgeOptions.scoreR !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.scoreR = undefined"
/>
</VCol>
<VCol cols="6">
<DateTimeInput
v-model="rejudgeOptions.submittedAtL"
:label="t('submitted-after')"
:append-icon="rejudgeOptions.submittedAtL === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.submittedAtL = undefined"
:append-inner-icon="rejudgeOptions.submittedAtL !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.submittedAtL = undefined"
/>
</VCol>
<VCol cols="6">
<DateTimeInput
v-model="rejudgeOptions.submittedAtR"
:label="t('submitted-before')"
:append-icon="rejudgeOptions.submittedAtR === undefined ? 'mdi-null' : 'mdi-delete'"
@click:append="rejudgeOptions.submittedAtR = undefined"
:append-inner-icon="rejudgeOptions.submittedAtR !== undefined && 'mdi-delete'"
@click:append-inner="rejudgeOptions.submittedAtR = undefined"
/>
</VCol>
</VRow>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
<template>
<VCard :title="t('term.rules')" variant="flat">
<RulesEditor :endpoint="`contest/${contestId}/admin/rule`" id="contest" />
</VCard>
<RulesEditor :endpoint="`contest/${contestId}/admin/rule`" id="contest" />
</template>

<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import RulesEditor from '@/components/common/RulesEditor.vue'
import type { IContestDTO } from '@/components/contest/types'
Expand All @@ -15,6 +11,4 @@ defineProps<{
contestId: string
contest: IContestDTO
}>()
const { t } = useI18n()
</script>
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,9 @@
<VIcon start> mdi-lock </VIcon>
{{ t('term.access') }}
</VTab>
<VTab prepend-icon="mdi-code-tags" :to="rel('rule')">
{{ t('term.rules') }}
</VTab>
</VTabs>
<VDivider vertical />
<RouterView class="flex-grow-1" :problem="problem" @updated="emit('updated')" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const submitAllTask = useAsyncTask(async () => {
return withMessage(t('msg.submit-all-success', { count: modifiedCount }))
})
const pull = ref(false)
const pull = ref(true)
const rejudgeAllTask = useAsyncTask(async () => {
const { modifiedCount } = await http
.post(`problem/${props.problemId}/admin/rejudge-all`, { json: { pull: pull.value } })
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<template>
<RulesEditor :endpoint="`problem/${problemId}/admin/rule`" id="problem" />
</template>

<script setup lang="ts">
import RulesEditor from '@/components/common/RulesEditor.vue'
import type { IProblemDTO } from '@/components/problem/types'
defineProps<{
orgId: string
problemId: string
problem: IProblemDTO
}>()
</script>
9 changes: 9 additions & 0 deletions apps/server/src/db/contest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
IContestProblemSettings,
IContestRanklistSettings,
IContestStage,
SContestParticipantRuleResult,
SContestSolutionRuleResult
} from '../schemas/contest.js'
import { capabilityMask } from '../utils/capability.js'
Expand Down Expand Up @@ -60,6 +61,12 @@ export interface IContestRanklist {
settings: IContestRanklistSettings
}

export interface IContestParticipantRuleCtx {
contest: IContest
currentStage: IContestStage
user: IUser
}

export interface IContestSolutionRuleCtx {
contest: IContest
currentStage: IContestStage
Expand All @@ -69,6 +76,7 @@ export interface IContestSolutionRuleCtx {
}

export const contestRuleSchemas = {
participant: SContestParticipantRuleResult,
solution: SContestSolutionRuleResult
}

Expand Down Expand Up @@ -96,6 +104,7 @@ export interface IContest
rules?: RulesFromSchemas<
typeof contestRuleSchemas,
{
participant: IContestParticipantRuleCtx
solution: IContestSolutionRuleCtx
}
>
Expand Down
28 changes: 26 additions & 2 deletions apps/server/src/db/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@ import { ProblemConfig } from '@aoi-js/common'
import { fastifyPlugin } from 'fastify-plugin'
import { BSON, Collection } from 'mongodb'

import { IProblemSettings } from '../schemas/problem.js'
import { IProblemSettings, SProblemSolutionRuleResult } from '../schemas/problem.js'
import { capabilityMask } from '../utils/capability.js'

import { IPrincipalControlable, IWithAccessLevel, IWithAttachment, IWithContent } from './common.js'
import {
IPrincipalControlable,
IWithAccessLevel,
IWithAttachment,
IWithContent,
RulesFromSchemas
} from './common.js'
import { ISolution } from './solution.js'

export const PROBLEM_CAPS = {
CAP_ACCESS: capabilityMask(0), // Can access(view) this problem
Expand All @@ -32,6 +39,16 @@ export interface IProblemData {
createdAt: number
}

export interface IProblemSolutionRuleCtx {
problem: IProblem
currentResult: IProblemStatus | null
solution: ISolution
}

export const problemRuleSchemas = {
solution: SProblemSolutionRuleResult
}

export interface IProblem
extends IPrincipalControlable,
IWithAttachment,
Expand All @@ -50,6 +67,13 @@ export interface IProblem
settings: IProblemSettings

createdAt: number

rules?: RulesFromSchemas<
typeof problemRuleSchemas,
{
solution: IProblemSolutionRuleCtx
}
>
}

declare module './index.js' {
Expand Down
39 changes: 33 additions & 6 deletions apps/server/src/routes/contest/scoped.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import { BSON } from 'mongodb'

import { CONTEST_CAPS, ORG_CAPS, evalTagRules, getCurrentContestStage } from '../../db/index.js'
import { SContestStage } from '../../schemas/contest.js'
import { T } from '../../schemas/index.js'
import { CAP_ALL, ensureCapability, hasCapability } from '../../utils/index.js'
import {
CONTEST_CAPS,
IContestParticipantRuleCtx,
ORG_CAPS,
evalTagRules,
getCurrentContestStage
} from '../../db/index.js'
import { SContestParticipantRuleResult, SContestStage, T } from '../../schemas/index.js'
import { CAP_ALL, createEvaluator, ensureCapability, hasCapability } from '../../utils/index.js'
import { manageContent } from '../common/content.js'
import { defineRoutes, loadCapability, loadUUID, paramSchemaMerger } from '../common/index.js'

Expand All @@ -17,6 +22,9 @@ import { contestSolutionRoutes } from './solution/index.js'

export const contestScopedRoutes = defineRoutes(async (s) => {
const { contests, contestParticipants, users } = s.db
const participantRuleEvaluator = createEvaluator(
SContestParticipantRuleResult
)<IContestParticipantRuleCtx>

s.addHook(
'onRoute',
Expand Down Expand Up @@ -114,20 +122,39 @@ export const contestScopedRoutes = defineRoutes(async (s) => {

const user = await users.findOne({ _id: req.user.userId })
if (!user) return rep.notFound()
let tags = await evalTagRules(ctx._contestStage, user)
if (ctx._contest.rules?.participant) {
const { allowRegister, tags: _tags } = participantRuleEvaluator(
{
contest: ctx._contest,
currentStage: ctx._contestStage,
user
},
ctx._contest.rules?.participant,
{}
)
if (typeof allowRegister === 'string') {
return rep.forbidden(allowRegister)
}
if (allowRegister === false) {
return rep.forbidden('Cannot register for contest')
}
tags ??= _tags
}
await contestParticipants.insertOne(
{
_id: new BSON.UUID(),
userId: req.user.userId,
contestId: ctx._contestId,
results: {},
tags: await evalTagRules(ctx._contestStage, user),
tags,
createdAt: req._now,
updatedAt: req._now
},
{ ignoreUndefined: true }
)

// TODO: add to the corresponding contest
// TODO: This operation is not atomic along with registration
await contests.updateOne({ _id: ctx._contestId }, { $inc: { participantCount: 1 } })

return {}
Expand Down
Loading

0 comments on commit d317390

Please sign in to comment.