Skip to content

Commit 487b253

Browse files
Add option to upload contest text/statement.
Fixes #2069.
1 parent ac40ef6 commit 487b253

23 files changed

+636
-50
lines changed

misc-tools/import-contest.in

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,24 @@ def import_contest_banner(cid: str):
114114
else:
115115
print('Skipping contest banner import.')
116116

117+
def import_contest_text(cid: str):
118+
"""Import the contest text"""
119+
120+
files = ['contest.pdf', 'contest-web.pdf', 'contest.html', 'contest.txt']
121+
122+
text_file = None
123+
for file in files:
124+
if os.path.isfile(file):
125+
text_file = file
126+
break
127+
128+
if text_file:
129+
if dj_utils.confirm(f'Import {text_file} for contest?', False):
130+
dj_utils.upload_file(f'contests/{cid}/text', 'text', text_file)
131+
print('Contest text imported.')
132+
else:
133+
print('Skipping contest text import.')
134+
117135
if len(sys.argv) == 1:
118136
dj_utils.domjudge_webapp_folder_or_api_url = webappdir
119137
elif len(sys.argv) == 2:
@@ -154,6 +172,7 @@ else:
154172
if cid is not None:
155173
print(f' -> cid={cid}')
156174
import_contest_banner(cid)
175+
import_contest_text(cid)
157176

158177
# Problem import is also special: we need to upload each individual problem and detect what they are
159178
if os.path.exists('problems.yaml') or os.path.exists('problems.json') or os.path.exists('problemset.yaml'):
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace DoctrineMigrations;
6+
7+
use Doctrine\DBAL\Schema\Schema;
8+
use Doctrine\Migrations\AbstractMigration;
9+
10+
/**
11+
* Auto-generated Migration: Please modify to your needs!
12+
*/
13+
final class Version20240322100827 extends AbstractMigration
14+
{
15+
public function getDescription(): string
16+
{
17+
return 'Add contest text table and type to contests.';
18+
}
19+
20+
public function up(Schema $schema): void
21+
{
22+
// this up() migration is auto-generated, please modify it to your needs
23+
$this->addSql('CREATE TABLE contest_text_content (cid INT UNSIGNED NOT NULL COMMENT \'Contest ID\', content LONGBLOB NOT NULL COMMENT \'Text content(DC2Type:blobtext)\', PRIMARY KEY(cid)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_unicode_ci` ENGINE = InnoDB COMMENT = \'Stores contents of contest texts\' ');
24+
$this->addSql('ALTER TABLE contest_text_content ADD CONSTRAINT FK_6680FE6A4B30D9C4 FOREIGN KEY (cid) REFERENCES contest (cid) ON DELETE CASCADE');
25+
$this->addSql('ALTER TABLE contest ADD contest_text_type VARCHAR(4) DEFAULT NULL COMMENT \'File type of contest text\'');
26+
}
27+
28+
public function down(Schema $schema): void
29+
{
30+
// this down() migration is auto-generated, please modify it to your needs
31+
$this->addSql('ALTER TABLE contest_text_content DROP FOREIGN KEY FK_6680FE6A4B30D9C4');
32+
$this->addSql('DROP TABLE contest_text_content');
33+
$this->addSql('ALTER TABLE contest DROP contest_text_type');
34+
}
35+
36+
public function isTransactional(): bool
37+
{
38+
return false;
39+
}
40+
}

webapp/src/Controller/API/ContestController.php

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,121 @@ public function setBannerAction(Request $request, string $cid, ValidatorInterfac
263263
return new Response('', Response::HTTP_NO_CONTENT);
264264
}
265265

266+
/**
267+
* Delete the text for the given contest.
268+
*/
269+
#[IsGranted('ROLE_ADMIN')]
270+
#[Rest\Delete('/{cid}/text', name: 'delete_contest_text')]
271+
#[OA\Response(response: 204, description: 'Deleting text succeeded')]
272+
#[OA\Parameter(ref: '#/components/parameters/cid')]
273+
public function deleteTextAction(Request $request, string $cid): Response
274+
{
275+
$contest = $this->getContestAndCheckIfLocked($request, $cid);
276+
$contest->setClearContestText(true);
277+
$contest->processContestText();
278+
$this->em->flush();
279+
280+
$this->eventLogService->log('contests', $contest->getCid(), EventLogService::ACTION_UPDATE,
281+
$contest->getCid());
282+
283+
return new Response('', Response::HTTP_NO_CONTENT);
284+
}
285+
286+
/**
287+
* Set the text for the given contest.
288+
*/
289+
#[IsGranted('ROLE_ADMIN')]
290+
#[Rest\Post("/{cid}/text", name: 'post_contest_text')]
291+
#[Rest\Put("/{cid}/text", name: 'put_contest_text')]
292+
#[OA\RequestBody(
293+
required: true,
294+
content: new OA\MediaType(
295+
mediaType: 'multipart/form-data',
296+
schema: new OA\Schema(
297+
required: ['text'],
298+
properties: [
299+
new OA\Property(
300+
property: 'text',
301+
description: 'The text to use, as either text/html, text/plain or application/pdf.',
302+
type: 'string',
303+
format: 'binary'
304+
),
305+
]
306+
)
307+
)
308+
)]
309+
#[OA\Response(response: 204, description: 'Setting text succeeded')]
310+
#[OA\Parameter(ref: '#/components/parameters/cid')]
311+
public function setTextAction(Request $request, string $cid, ValidatorInterface $validator): Response
312+
{
313+
$contest = $this->getContestAndCheckIfLocked($request, $cid);
314+
315+
/** @var UploadedFile|null $text */
316+
$text = $request->files->get('text');
317+
if (!$text) {
318+
return new JsonResponse(['title' => 'Validation failed', 'errors' => ['Please supply a text']], Response::HTTP_BAD_REQUEST);
319+
}
320+
if (!in_array($text->getMimeType(), ['text/html', 'text/plain', 'application/pdf'])) {
321+
return new JsonResponse(['title' => 'Validation failed', 'errors' => ['Invalid text type']], Response::HTTP_BAD_REQUEST);
322+
}
323+
324+
$contest->setContestTextFile($text);
325+
326+
if ($errorResponse = $this->responseForErrors($validator->validate($contest), true)) {
327+
return $errorResponse;
328+
}
329+
330+
$contest->processContestText();
331+
$this->em->flush();
332+
333+
$this->eventLogService->log('contests', $contest->getCid(), EventLogService::ACTION_UPDATE,
334+
$contest->getCid());
335+
336+
return new Response('', Response::HTTP_NO_CONTENT);
337+
}
338+
339+
/**
340+
* Get the text for the given contest.
341+
*/
342+
#[Rest\Get('/{cid}/text', name: 'contest_text')]
343+
#[OA\Response(
344+
response: 200,
345+
description: 'Returns the given contest text in PDF, HTML or TXT format',
346+
content: [
347+
new OA\MediaType(mediaType: 'application/pdf'),
348+
new OA\MediaType(mediaType: 'text/plain'),
349+
new OA\MediaType(mediaType: 'text/html'),
350+
]
351+
)]
352+
#[OA\Parameter(ref: '#/components/parameters/cid')]
353+
public function textAction(Request $request, string $cid): Response
354+
{
355+
/** @var Contest|null $contest */
356+
$contest = $this->getQueryBuilder($request)
357+
->andWhere(sprintf('%s = :id', $this->getIdField()))
358+
->setParameter('id', $cid)
359+
->getQuery()
360+
->getOneOrNullResult();
361+
362+
$hasAccess = $this->dj->checkrole('jury') ||
363+
$this->dj->checkrole('api_reader') ||
364+
$contest->getFreezeData()->started();
365+
366+
if (!$hasAccess) {
367+
throw new AccessDeniedHttpException();
368+
}
369+
370+
if ($contest === null) {
371+
throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $cid));
372+
}
373+
374+
if (!$contest->getContestTextType()) {
375+
throw new NotFoundHttpException(sprintf('Contest with ID \'%s\' has no text', $cid));
376+
}
377+
378+
return $contest->getContestTextStreamedResponse();
379+
}
380+
266381
/**
267382
* Change the start time or unfreeze (thaw) time of the given contest.
268383
* @throws NonUniqueResultException

webapp/src/Controller/BaseController.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ protected function saveEntity(
101101
): void {
102102
$auditLogType = Utils::tableForEntity($entity);
103103

104+
// Call the prePersist lifecycle callbacks.
105+
// This used to work in preUpdate, but Doctrine has deprecated that feature.
106+
// See https://www.doctrine-project.org/projects/doctrine-orm/en/3.1/reference/events.html#events-overview.
107+
$metadata = $entityManager->getClassMetadata($entity::class);
108+
foreach ($metadata->lifecycleCallbacks['prePersist'] ?? [] as $prePersistMethod) {
109+
$entity->$prePersistMethod();
110+
}
111+
104112
$entityManager->persist($entity);
105113
$entityManager->flush();
106114

webapp/src/Controller/Jury/ContestController.php

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
use Symfony\Component\HttpFoundation\Request;
3838
use Symfony\Component\HttpFoundation\RequestStack;
3939
use Symfony\Component\HttpFoundation\Response;
40+
use Symfony\Component\HttpFoundation\StreamedResponse;
4041
use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException;
4142
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
4243
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -169,6 +170,7 @@ public function indexAction(Request $request): Response
169170
return $this->redirectToRoute('jury_contests');
170171
}
171172

173+
/** @var Contest[] $contests */
172174
$contests = $em->createQueryBuilder()
173175
->select('c')
174176
->from(Contest::class, 'c')
@@ -244,6 +246,18 @@ public function indexAction(Request $request): Response
244246
}
245247
}
246248

249+
// Create action links
250+
if ($contest->getContestTextType()) {
251+
$contestactions[] = [
252+
'icon' => 'file-' . $contest->getContestTextType(),
253+
'title' => 'view contest description',
254+
'link' => $this->generateUrl('jury_contest_text', [
255+
'cid' => $contest->getCid(),
256+
])
257+
];
258+
} else {
259+
$contestactions[] = [];
260+
}
247261
if ($this->isGranted('ROLE_ADMIN') && !$contest->isLocked()) {
248262
$contestactions[] = [
249263
'icon' => 'edit',
@@ -987,4 +1001,15 @@ public function publicScoreboardDataZipAction(
9871001
}
9881002
return $this->dj->getScoreboardZip($request, $requestStack, $contest, $scoreboardService, $type === 'unfrozen');
9891003
}
1004+
1005+
#[Route(path: '/{cid<\d+>}/text', name: 'jury_contest_text')]
1006+
public function viewTextAction(int $cid): StreamedResponse
1007+
{
1008+
$contest = $this->em->getRepository(Contest::class)->find($cid);
1009+
if (!$contest) {
1010+
throw new NotFoundHttpException(sprintf('Contest with ID %s not found', $cid));
1011+
}
1012+
1013+
return $contest->getContestTextStreamedResponse();
1014+
}
9901015
}

webapp/src/Controller/Jury/ProblemController.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,7 @@ public function indexAction(): Response
118118
if ($p->getProblemtextType()) {
119119
$problemactions[] = [
120120
'icon' => 'file-' . $p->getProblemtextType(),
121-
'title' => 'view problem description',
121+
'title' => 'view all problem statements of the contest',
122122
'link' => $this->generateUrl('jury_problem_text', [
123123
'probId' => $p->getProbid(),
124124
])

webapp/src/Controller/PublicController.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,16 @@ public function problemTextAction(int $probId): StreamedResponse
187187
});
188188
}
189189

190+
#[Route(path: '/contest-text', name: 'public_contest_text')]
191+
public function contestTextAction(): StreamedResponse
192+
{
193+
$contest = $this->dj->getCurrentContest(onlyPublic: true);
194+
if (!$contest->getFreezeData()->started()) {
195+
throw new NotFoundHttpException('Contest text not found or not available');
196+
}
197+
return $contest->getContestTextStreamedResponse();
198+
}
199+
190200
/**
191201
* @throws NonUniqueResultException
192202
*/

webapp/src/Controller/Team/MiscController.php

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
use App\Controller\BaseController;
66
use App\DataTransferObject\SubmissionRestriction;
77
use App\Entity\Clarification;
8+
use App\Entity\Contest;
9+
use App\Entity\ContestProblem;
810
use App\Entity\Language;
911
use App\Form\Type\PrintType;
1012
use App\Service\ConfigurationService;
@@ -16,6 +18,9 @@
1618
use Doctrine\ORM\NonUniqueResultException;
1719
use Doctrine\ORM\NoResultException;
1820
use Symfony\Component\ExpressionLanguage\Expression;
21+
use Symfony\Component\HttpFoundation\StreamedResponse;
22+
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
23+
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
1924
use Symfony\Component\PropertyAccess\PropertyAccess;
2025
use Symfony\Component\Security\Http\Attribute\IsGranted;
2126
use Symfony\Component\HttpFoundation\File\UploadedFile;
@@ -211,4 +216,15 @@ public function docsAction(): Response
211216
{
212217
return $this->render('team/docs.html.twig');
213218
}
219+
220+
#[Route(path: '/contest-text', name: 'team_contest_text')]
221+
public function contestTextAction(): StreamedResponse
222+
{
223+
$user = $this->dj->getUser();
224+
$contest = $this->dj->getCurrentContest($user->getTeam()->getTeamid());
225+
if (!$contest->getFreezeData()->started()) {
226+
throw new NotFoundHttpException('Contest text not found or not available');
227+
}
228+
return $contest->getContestTextStreamedResponse();
229+
}
214230
}

0 commit comments

Comments
 (0)