Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions webapp/migrations/Version20250829092248.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php

declare(strict_types=1);

namespace DoctrineMigrations;

use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
use finfo;

/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20250829092248 extends AbstractMigration
{
public function getDescription(): string
{
return 'Add mime type to problem attachment';
}

public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE problem_attachment ADD mime_type VARCHAR(255) NOT NULL COMMENT \'Mime type of attachment\'');

// Load existing attachments
$attachments = $this->connection->fetchAllAssociative('SELECT attachmentid, content FROM problem_attachment INNER JOIN problem_attachment_content USING (attachmentid)');
$finfo = new finfo(FILEINFO_MIME_TYPE);
foreach ($attachments as $attachment) {
$mime = $finfo->buffer($attachment['content']);
$mime = explode(';', $mime)[0];
$this->addSql("UPDATE problem_attachment SET mime_type = :mime WHERE attachmentid = :attachmentid", ['mime' => $mime, 'attachmentid' => $attachment['attachmentid']]);
}
}

public function down(Schema $schema): void
{
// this down() migration is auto-generated, please modify it to your needs
$this->addSql('ALTER TABLE problem_attachment DROP mime_type');
}

public function isTransactional(): bool
{
return false;
}
}
1 change: 1 addition & 0 deletions webapp/src/Controller/API/AccessController.php
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,7 @@ public function getStatusAction(Request $request): Access
'time_limit',
'test_data_count',
'statement',
'attachments',
// DOMjudge specific properties:
'probid',
],
Expand Down
53 changes: 53 additions & 0 deletions webapp/src/Controller/API/ProblemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use App\Entity\Contest;
use App\Entity\ContestProblem;
use App\Entity\Problem;
use App\Entity\ProblemAttachment;
use App\Service\ConfigurationService;
use App\Service\DOMJudgeService;
use App\Service\EventLogService;
Expand Down Expand Up @@ -436,6 +437,58 @@ public function statementAction(Request $request, string $id): Response
return $contestProblem->getProblem()->getProblemStatementStreamedResponse();
}

/**
* Get an attachment for given problem for this contest.
* @throws NonUniqueResultException
*/
#[Rest\Get('/{id}/attachment/{filename}')]
#[OA\Response(
response: 200,
description: 'Returns the given problem attachment for this contest'
)]
#[OA\Parameter(ref: '#/components/parameters/id')]
#[OA\Parameter(
name: 'filename',
description: 'The filename of the attachment to get',
in: 'query',
required: true,
schema: new OA\Schema(type: 'string')
)]
public function attachmentAction(Request $request, string $id, string $filename): Response
{
$contestProblemData = $this
->getQueryBuilder($request)
->setParameter('id', $id)
->andWhere(sprintf('%s = :id', $this->getIdField()))
->getQuery()
->getOneOrNullResult();

if ($contestProblemData === null) {
throw new NotFoundHttpException(sprintf('Object with ID \'%s\' not found', $id));
}

/** @var ContestProblem $contestProblem */
$contestProblem = $contestProblemData[0];

/** @var ProblemAttachment|null $attachment */
$attachment = $this->em->createQueryBuilder()
->from(ProblemAttachment::class, 'a')
->join('a.content', 'c')
->select('a, c')
->andWhere('a.problem = :problem')
->andWhere('a.name = :filename')
->setParameter('problem', $contestProblem->getProblem())
->setParameter('filename', $filename)
->getQuery()
->getOneOrNullResult();

if ($attachment === null) {
throw new NotFoundHttpException(sprintf('Attachment with filename \'%s\' not found for problem with ID \'%s\'', $filename, $id));
}

return $attachment->getStreamedResponse();
}

protected function getQueryBuilder(Request $request): QueryBuilder
{
$contestId = $this->getContestId($request);
Expand Down
1 change: 1 addition & 0 deletions webapp/src/Controller/Jury/ProblemController.php
Original file line number Diff line number Diff line change
Expand Up @@ -474,6 +474,7 @@ public function viewAction(Request $request, SubmissionService $submissionServic
->setProblem($problem)
->setName($name)
->setType($type)
->setMimeType(mime_content_type($file->getRealPath()))
->setContent($attachmentContent);

$this->em->persist($attachment);
Expand Down
3 changes: 2 additions & 1 deletion webapp/src/DataFixtures/Test/AddProblemAttachmentFixture.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,8 @@ public function load(ObjectManager $manager): void
$problem = $manager->getRepository(Problem::class)->findOneBy(['externalid' => 'boolfind']);
$attachment = (new ProblemAttachment())
->setName('interactor')
->setType('py');
->setType('py')
->setMimeType('text/x-script.python');
$manager->persist($attachment);
$content = (new ProblemAttachmentContent())
->setContent($interactor)
Expand Down
28 changes: 28 additions & 0 deletions webapp/src/Entity/Problem.php
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,14 @@ class Problem extends BaseApiEntity implements
#[Serializer\Exclude]
private ?FileWithName $statementForApi = null;

/**
* @var FileWithName[]
*/
// This field gets filled by the contest problem visitor with an array of data transfer
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't understand this remark

// objects that represents the problem attachments.
#[Serializer\Exclude]
private array $attachmentsForApi = [];


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


/**
* @param list<FileWithName> $attachmentsForApi
*/
public function setAttachmentsForApi(array $attachmentsForApi = []): void
{
$this->attachmentsForApi = $attachmentsForApi;
}

/**
* @return FileWithName[]
*/
#[Serializer\VirtualProperty]
#[Serializer\SerializedName('attachments')]
#[Serializer\Type('array<App\DataTransferObject\FileWithName>')]
public function getAttachmentsForApi(): array
{
return $this->attachmentsForApi;
}

public function addLanguage(Language $language): Problem
{
$this->languages[] = $language;
Expand Down
15 changes: 15 additions & 0 deletions webapp/src/Entity/ProblemAttachment.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ class ProblemAttachment
#[ORM\Column(length: 4, options: ['comment' => 'File type of attachment'])]
private ?string $type = null;

#[ORM\Column(options: ['comment' => 'Mime type of attachment'])]
private ?string $mimeType = null;

#[ORM\ManyToOne(inversedBy: 'attachments')]
#[ORM\JoinColumn(name: 'probid', referencedColumnName: 'probid', onDelete: 'CASCADE')]
private ?Problem $problem = null;
Expand Down Expand Up @@ -86,6 +89,18 @@ public function setType(string $type): self
return $this;
}

public function getMimeType(): ?string
{
return $this->mimeType;
}

public function setMimeType(string $mimeType): self
{
$this->mimeType = $mimeType;

return $this;
}

public function getProblem(): ?Problem
{
return $this->problem;
Expand Down
19 changes: 19 additions & 0 deletions webapp/src/Serializer/ContestProblemVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -53,5 +53,24 @@ public function onPreSerialize(ObjectEvent $event): void
} else {
$contestProblem->getProblem()->setStatementForApi();
}

// Problem attachments
$attachments = [];
foreach ($contestProblem->getProblem()->getAttachments() as $attachment) {
$route = $this->dj->apiRelativeUrl(
'v4_app_api_problem_attachment',
[
'cid' => $contestProblem->getContest()->getExternalid(),
'id' => $contestProblem->getExternalId(),
'filename' => $attachment->getName(),
]
);
$attachments[] = new FileWithName(
$route,
$attachment->getMimeType(),
$attachment->getName()
);
}
$contestProblem->getProblem()->setAttachmentsForApi($attachments);
}
}
8 changes: 8 additions & 0 deletions webapp/src/Service/ImportProblemService.php
Original file line number Diff line number Diff line change
Expand Up @@ -534,13 +534,20 @@ public function importZippedProblem(
$type = 'txt';
}

$finfo = new \finfo();
$mime = $finfo->buffer($content);
$mime = explode(';', $mime)[0];

// Check if an attachment already exists, since then we overwrite it.
$attachment = $existingAttachments[$name] ?? null;

if ($attachment) {
$attachmentContent = $attachment->getContent();
if ($content !== $attachmentContent->getContent()) {
$attachmentContent->setContent($content);
$attachment
->setType($type)
->setMimeType($mime);
$messages['info'][] = sprintf("Updated attachment '%s'", $name);
$numAttachments++;
}
Expand All @@ -555,6 +562,7 @@ public function importZippedProblem(
->setProblem($problem)
->setName($name)
->setType($type)
->setMimeType($mime)
->setContent($attachmentContent);

$attachmentContent
Expand Down
4 changes: 3 additions & 1 deletion webapp/templates/jury/problem.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@
<thead class="thead-light">
<tr>
<th>Name</th>
<th>Mime type</th>
<th></th>
</tr>
</thead>
Expand All @@ -228,6 +229,7 @@
{% for attachment in problem.attachments %}
<tr>
<td>{{ attachment.name }}</td>
<td>{{ attachment.mimeType }}</td>
<td>
<a href="{{ path('jury_attachment_fetch', {'attachmentId': attachment.attachmentid}) }}"
class="btn btn-sm btn-primary">
Expand All @@ -247,7 +249,7 @@

{% if is_granted('ROLE_ADMIN') and not lockedProblem %}
<tr>
<td>
<td colspan="2">
{{ form_errors(problemAttachmentForm.content) }}
{{ form_widget(problemAttachmentForm.content) }}
</td>
Expand Down
16 changes: 16 additions & 0 deletions webapp/tests/Unit/Controller/API/ProblemControllerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Tests\Unit\Controller\API;

use App\DataFixtures\Test\AddProblemAttachmentFixture;
use App\DataFixtures\Test\DummyProblemFixture;
use App\Entity\Problem;

Expand Down Expand Up @@ -31,6 +32,13 @@ class ProblemControllerTest extends BaseTestCase
'filename' => 'C.pdf',
],
],
'attachments' => [
[
'href' => 'contests/demo/problems/boolfind/attachment/interactor',
'mime' => 'text/x-script.python',
'filename' => 'interactor',
],
],
],
'fltcmp' => [
"ordinal" => 1,
Expand All @@ -48,6 +56,7 @@ class ProblemControllerTest extends BaseTestCase
'filename' => 'B.pdf',
],
],
'attachments' => [],
],
'hello' => [
"ordinal" => 0,
Expand All @@ -65,11 +74,18 @@ class ProblemControllerTest extends BaseTestCase
'filename' => 'A.pdf',
],
],
'attachments' => [],
],
];

protected array $expectedAbsent = ['4242', 'nonexistent'];

protected function setUp(): void
{
parent::setUp();
$this->loadFixture(AddProblemAttachmentFixture::class);
}

public function testDeleteNotAllowed(): void
{
if ($this->apiUser === 'admin') {
Expand Down