Skip to content

Commit 37a5235

Browse files
Add attachments to problem endpoint in API
1 parent d6dd5b3 commit 37a5235

File tree

11 files changed

+192
-2
lines changed

11 files changed

+192
-2
lines changed
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
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+
use finfo;
10+
11+
/**
12+
* Auto-generated Migration: Please modify to your needs!
13+
*/
14+
final class Version20250829092248 extends AbstractMigration
15+
{
16+
public function getDescription(): string
17+
{
18+
return 'Add mime type to problem attachment';
19+
}
20+
21+
public function up(Schema $schema): void
22+
{
23+
// this up() migration is auto-generated, please modify it to your needs
24+
$this->addSql('ALTER TABLE problem_attachment ADD mime_type VARCHAR(255) NOT NULL COMMENT \'Mime type of attachment\'');
25+
26+
// Load existing attachments
27+
$attachments = $this->connection->fetchAllAssociative('SELECT attachmentid, content FROM problem_attachment INNER JOIN problem_attachment_content USING (attachmentid)');
28+
$finfo = new finfo(FILEINFO_MIME_TYPE);
29+
foreach ($attachments as $attachment) {
30+
$mime = $finfo->buffer($attachment['content']);
31+
$mime = explode(';', $mime)[0];
32+
$this->addSql("UPDATE problem_attachment SET mime_type = :mime WHERE attachmentid = :attachmentid", ['mime' => $mime, 'attachmentid' => $attachment['attachmentid']]);
33+
}
34+
}
35+
36+
public function down(Schema $schema): void
37+
{
38+
// this down() migration is auto-generated, please modify it to your needs
39+
$this->addSql('ALTER TABLE problem_attachment DROP mime_type');
40+
}
41+
42+
public function isTransactional(): bool
43+
{
44+
return false;
45+
}
46+
}

webapp/src/Controller/API/AccessController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ public function getStatusAction(Request $request): Access
163163
'time_limit',
164164
'test_data_count',
165165
'statement',
166+
'attachments',
166167
// DOMjudge specific properties:
167168
'probid',
168169
],

webapp/src/Controller/API/ProblemController.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
use App\Entity\Contest;
99
use App\Entity\ContestProblem;
1010
use App\Entity\Problem;
11+
use App\Entity\ProblemAttachment;
1112
use App\Service\ConfigurationService;
1213
use App\Service\DOMJudgeService;
1314
use App\Service\EventLogService;
@@ -436,6 +437,58 @@ public function statementAction(Request $request, string $id): Response
436437
return $contestProblem->getProblem()->getProblemStatementStreamedResponse();
437438
}
438439

440+
/**
441+
* Get an attachment for given problem for this contest.
442+
* @throws NonUniqueResultException
443+
*/
444+
#[Rest\Get('/{id}/attachment/{filename}')]
445+
#[OA\Response(
446+
response: 200,
447+
description: 'Returns the given problem attachment for this contest'
448+
)]
449+
#[OA\Parameter(ref: '#/components/parameters/id')]
450+
#[OA\Parameter(
451+
name: 'filename',
452+
description: 'The filename of the attachment to get',
453+
in: 'query',
454+
required: true,
455+
schema: new OA\Schema(type: 'string')
456+
)]
457+
public function attachmentAction(Request $request, string $id, string $filename): Response
458+
{
459+
$contestProblemData = $this
460+
->getQueryBuilder($request)
461+
->setParameter('id', $id)
462+
->andWhere(sprintf('%s = :id', $this->getIdField()))
463+
->getQuery()
464+
->getOneOrNullResult();
465+
466+
if ($contestProblemData === null) {
467+
throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $id));
468+
}
469+
470+
/** @var ContestProblem $contestProblem */
471+
$contestProblem = $contestProblemData[0];
472+
473+
/** @var ProblemAttachment|null $attachment */
474+
$attachment = $this->em->createQueryBuilder()
475+
->from(ProblemAttachment::class, 'a')
476+
->join('a.content', 'c')
477+
->select('a, c')
478+
->andWhere('a.problem = :problem')
479+
->andWhere('a.name = :filename')
480+
->setParameter('problem', $contestProblem->getProblem())
481+
->setParameter('filename', $filename)
482+
->getQuery()
483+
->getOneOrNullResult();
484+
485+
if ($attachment === null) {
486+
throw new NotFoundHttpException(sprintf('Attachment with filename \'%s\' not found for problem with ID \'%s\'', $filename, $id));
487+
}
488+
489+
return $attachment->getStreamedResponse();
490+
}
491+
439492
protected function getQueryBuilder(Request $request): QueryBuilder
440493
{
441494
$contestId = $this->getContestId($request);

webapp/src/Controller/Jury/ProblemController.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -474,6 +474,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic
474474
->setProblem($problem)
475475
->setName($name)
476476
->setType($type)
477+
->setMimeType(mime_content_type($file->getRealPath()))
477478
->setContent($attachmentContent);
478479

479480
$this->em->persist($attachment);

webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,8 @@ public function load(ObjectManager $manager): void
3939
$problem = $manager->getRepository(Problem::class)->findOneBy(['externalid' => 'boolfind']);
4040
$attachment = (new ProblemAttachment())
4141
->setName('interactor')
42-
->setType('py');
42+
->setType('py')
43+
->setMimeType('text/x-script.python');
4344
$manager->persist($attachment);
4445
$content = (new ProblemAttachmentContent())
4546
->setContent($interactor)

webapp/src/Entity/Problem.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,14 @@ class Problem extends BaseApiEntity implements
200200
#[Serializer\Exclude]
201201
private ?FileWithName $statementForApi = null;
202202

203+
/**
204+
* @var FileWithName[]
205+
*/
206+
// This field gets filled by the contest problem visitor with an array of data transfer
207+
// objects that represents the problem attachments.
208+
#[Serializer\Exclude]
209+
private array $attachmentsForApi = [];
210+
203211

204212
/**
205213
* @var Collection<int, Language>
@@ -635,6 +643,26 @@ public function getStatementForApi(): array
635643
return array_filter([$this->statementForApi]);
636644
}
637645

646+
647+
/**
648+
* @param list<FileWithName> $attachmentsForApi
649+
*/
650+
public function setAttachmentsForApi(array $attachmentsForApi = []): void
651+
{
652+
$this->attachmentsForApi = $attachmentsForApi;
653+
}
654+
655+
/**
656+
* @return FileWithName[]
657+
*/
658+
#[Serializer\VirtualProperty]
659+
#[Serializer\SerializedName('attachments')]
660+
#[Serializer\Type('array<App\DataTransferObject\FileWithName>')]
661+
public function getAttachmentsForApi(): array
662+
{
663+
return $this->attachmentsForApi;
664+
}
665+
638666
public function addLanguage(Language $language): Problem
639667
{
640668
$this->languages[] = $language;

webapp/src/Entity/ProblemAttachment.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,9 @@ class ProblemAttachment
2828
#[ORM\Column(length: 4, options: ['comment' => 'File type of attachment'])]
2929
private ?string $type = null;
3030

31+
#[ORM\Column(options: ['comment' => 'Mime type of attachment'])]
32+
private ?string $mimeType = null;
33+
3134
#[ORM\ManyToOne(inversedBy: 'attachments')]
3235
#[ORM\JoinColumn(name: 'probid', referencedColumnName: 'probid', onDelete: 'CASCADE')]
3336
private ?Problem $problem = null;
@@ -86,6 +89,18 @@ public function setType(string $type): self
8689
return $this;
8790
}
8891

92+
public function getMimeType(): ?string
93+
{
94+
return $this->mimeType;
95+
}
96+
97+
public function setMimeType(string $mimeType): self
98+
{
99+
$this->mimeType = $mimeType;
100+
101+
return $this;
102+
}
103+
89104
public function getProblem(): ?Problem
90105
{
91106
return $this->problem;

webapp/src/Serializer/ContestProblemVisitor.php

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,5 +53,24 @@ public function onPreSerialize(ObjectEvent $event): void
5353
} else {
5454
$contestProblem->getProblem()->setStatementForApi();
5555
}
56+
57+
// Problem attachments
58+
$attachments = [];
59+
foreach ($contestProblem->getProblem()->getAttachments() as $attachment) {
60+
$route = $this->dj->apiRelativeUrl(
61+
'v4_app_api_problem_attachment',
62+
[
63+
'cid' => $contestProblem->getContest()->getExternalid(),
64+
'id' => $contestProblem->getExternalId(),
65+
'filename' => $attachment->getName(),
66+
]
67+
);
68+
$attachments[] = new FileWithName(
69+
$route,
70+
$attachment->getMimeType(),
71+
$attachment->getName()
72+
);
73+
}
74+
$contestProblem->getProblem()->setAttachmentsForApi($attachments);
5675
}
5776
}

webapp/src/Service/ImportProblemService.php

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -534,13 +534,20 @@ public function importZippedProblem(
534534
$type = 'txt';
535535
}
536536

537+
$finfo = new \finfo();
538+
$mime = $finfo->buffer($content);
539+
$mime = explode(';', $mime)[0];
540+
537541
// Check if an attachment already exists, since then we overwrite it.
538542
$attachment = $existingAttachments[$name] ?? null;
539543

540544
if ($attachment) {
541545
$attachmentContent = $attachment->getContent();
542546
if ($content !== $attachmentContent->getContent()) {
543547
$attachmentContent->setContent($content);
548+
$attachment
549+
->setType($type)
550+
->setMimeType($mime);
544551
$messages['info'][] = sprintf("Updated attachment '%s'", $name);
545552
$numAttachments++;
546553
}
@@ -555,6 +562,7 @@ public function importZippedProblem(
555562
->setProblem($problem)
556563
->setName($name)
557564
->setType($type)
565+
->setMimeType($mime)
558566
->setContent($attachmentContent);
559567

560568
$attachmentContent

webapp/templates/jury/problem.html.twig

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@
214214
<thead class="thead-light">
215215
<tr>
216216
<th>Name</th>
217+
<th>Mime type</th>
217218
<th></th>
218219
</tr>
219220
</thead>
@@ -228,6 +229,7 @@
228229
{% for attachment in problem.attachments %}
229230
<tr>
230231
<td>{{ attachment.name }}</td>
232+
<td>{{ attachment.mimeType }}</td>
231233
<td>
232234
<a href="{{ path('jury_attachment_fetch', {'attachmentId': attachment.attachmentid}) }}"
233235
class="btn btn-sm btn-primary">
@@ -247,7 +249,7 @@
247249

248250
{% if is_granted('ROLE_ADMIN') and not lockedProblem %}
249251
<tr>
250-
<td>
252+
<td colspan="2">
251253
{{ form_errors(problemAttachmentForm.content) }}
252254
{{ form_widget(problemAttachmentForm.content) }}
253255
</td>

0 commit comments

Comments
 (0)