Skip to content

Commit d44c9b3

Browse files
Introduce way to generated both honors and ranked results
1 parent 937e71a commit d44c9b3

File tree

12 files changed

+576
-200
lines changed

12 files changed

+576
-200
lines changed

webapp/src/Controller/Jury/ImportExportController.php

Lines changed: 84 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use App\Entity\TeamCategory;
1212
use App\Form\Type\ContestExportType;
1313
use App\Form\Type\ContestImportType;
14+
use App\Form\Type\ExportResultsType;
1415
use App\Form\Type\ICPCCmsType;
1516
use App\Form\Type\JsonImportType;
1617
use App\Form\Type\ProblemsImportType;
@@ -46,6 +47,7 @@
4647
use Symfony\Contracts\HttpClient\Exception\RedirectionExceptionInterface;
4748
use Symfony\Contracts\HttpClient\Exception\ServerExceptionInterface;
4849
use Symfony\Contracts\HttpClient\Exception\TransportExceptionInterface;
50+
use Twig\Environment;
4951

5052
#[Route(path: '/jury/import-export')]
5153
#[IsGranted('ROLE_JURY')]
@@ -63,6 +65,7 @@ public function __construct(
6365
KernelInterface $kernel,
6466
#[Autowire('%domjudge.version%')]
6567
protected readonly string $domjudgeVersion,
68+
protected readonly Environment $twig,
6669
) {
6770
parent::__construct($em, $eventLogService, $dj, $kernel);
6871
}
@@ -257,21 +260,73 @@ public function indexAction(Request $request): Response
257260
return $this->redirectToRoute('jury_import_export');
258261
}
259262

260-
/** @var TeamCategory[] $teamCategories */
261-
$teamCategories = $this->em->createQueryBuilder()
262-
->from(TeamCategory::class, 'c', 'c.categoryid')
263-
->select('c.sortorder, c.name')
264-
->where('c.visible = 1')
265-
->orderBy('c.sortorder')
266-
->getQuery()
267-
->getResult();
268-
$sortOrders = [];
269-
foreach ($teamCategories as $teamCategory) {
270-
$sortOrder = $teamCategory['sortorder'];
271-
if (!array_key_exists($sortOrder, $sortOrders)) {
272-
$sortOrders[$sortOrder] = [];
263+
$exportResultsForm = $this->createForm(ExportResultsType::class);
264+
265+
$exportResultsForm->handleRequest($request);
266+
267+
if ($exportResultsForm->isSubmitted() && $exportResultsForm->isValid()) {
268+
$contest = $this->dj->getCurrentContest();
269+
if ($contest === null) {
270+
throw new BadRequestHttpException('No current contest');
273271
}
274-
$sortOrders[$sortOrder][] = $teamCategory['name'];
272+
273+
$data = $exportResultsForm->getData();
274+
$format = $data['format'];
275+
$sortOrder = $data['sortorder'];
276+
$individuallyRanked = $data['individually_ranked'];
277+
$honors = $data['honors'];
278+
279+
$extension = match ($format) {
280+
'html_inline', 'html_download' => 'html',
281+
'tsv' => 'tsv',
282+
default => throw new BadRequestHttpException('Invalid format'),
283+
};
284+
$contentType = match ($format) {
285+
'html_inline' => 'text/html',
286+
'html_download' => 'text/html',
287+
'tsv' => 'text/csv',
288+
default => throw new BadRequestHttpException('Invalid format'),
289+
};
290+
$contentDisposition = match ($format) {
291+
'html_inline' => 'inline',
292+
'html_download', 'tsv' => 'attachment',
293+
default => throw new BadRequestHttpException('Invalid format'),
294+
};
295+
$filename = 'results.' . $extension;
296+
297+
$response = new StreamedResponse();
298+
$response->setCallback(function () use (
299+
$format,
300+
$sortOrder,
301+
$individuallyRanked,
302+
$honors
303+
) {
304+
if ($format === 'tsv') {
305+
$data = $this->importExportService->getResultsData(
306+
$sortOrder->sort_order,
307+
$individuallyRanked,
308+
$honors,
309+
);
310+
311+
echo "results\t1\n";
312+
foreach ($data as $row) {
313+
echo implode("\t", array_map(fn($field) => Utils::toTsvField((string)$field), $row->toArray())) . "\n";
314+
}
315+
} else {
316+
echo $this->getResultsHtml(
317+
$sortOrder->sort_order,
318+
$individuallyRanked,
319+
$honors,
320+
);
321+
}
322+
});
323+
$response->headers->set('Content-Type', $contentType);
324+
$response->headers->set('Content-Disposition', "$contentDisposition; filename=\"$filename\"");
325+
$response->headers->set('Content-Transfer-Encoding', 'binary');
326+
$response->headers->set('Connection', 'Keep-Alive');
327+
$response->headers->set('Accept-Ranges', 'bytes');
328+
329+
return $response;
275330
}
276331

277332
return $this->render('jury/import_export.html.twig', [
@@ -282,16 +337,13 @@ public function indexAction(Request $request): Response
282337
'contest_export_form' => $contestExportForm,
283338
'contest_import_form' => $contestImportForm,
284339
'problems_import_form' => $problemsImportForm,
285-
'sort_orders' => $sortOrders,
340+
'export_results_form' => $exportResultsForm,
286341
]);
287342
}
288343

289344
#[Route(path: '/export/{type<groups|teams|wf_results|full_results>}.tsv', name: 'jury_tsv_export')]
290-
public function exportTsvAction(
291-
string $type,
292-
#[MapQueryParameter(name: 'sort_order')]
293-
?int $sortOrder,
294-
): Response {
345+
public function exportTsvAction(string $type): Response
346+
{
295347
$data = [];
296348
$tsvType = $type;
297349
try {
@@ -302,14 +354,6 @@ public function exportTsvAction(
302354
case 'teams':
303355
$data = $this->importExportService->getTeamData();
304356
break;
305-
case 'wf_results':
306-
$data = $this->importExportService->getResultsData($sortOrder);
307-
$tsvType = 'results';
308-
break;
309-
case 'full_results':
310-
$data = $this->importExportService->getResultsData($sortOrder, full: true);
311-
$tsvType = 'results';
312-
break;
313357
}
314358
} catch (BadRequestHttpException $e) {
315359
$this->addFlash('danger', $e->getMessage());
@@ -322,9 +366,6 @@ public function exportTsvAction(
322366
echo sprintf("%s\t%s\n", $tsvType, $version);
323367
foreach ($data as $row) {
324368
// Utils::toTsvFields handles escaping of reserved characters.
325-
if ($row instanceof ResultRow) {
326-
$row = $row->toArray();
327-
}
328369
echo implode("\t", array_map(fn($field) => Utils::toTsvField((string)$field), $row)) . "\n";
329370
}
330371
});
@@ -335,29 +376,22 @@ public function exportTsvAction(
335376
return $response;
336377
}
337378

338-
#[Route(path: '/export/{type<wf_results|full_results|clarifications>}.html', name: 'jury_html_export')]
339-
public function exportHtmlAction(Request $request, string $type): Response
379+
#[Route(path: '/export/clarifications.html', name: 'jury_html_export_clarifications')]
380+
public function exportClarificationsHtmlAction(): Response
340381
{
341382
try {
342-
switch ($type) {
343-
case 'wf_results':
344-
return $this->getResultsHtml($request);
345-
case 'full_results':
346-
return $this->getResultsHtml($request, full: true);
347-
case 'clarifications':
348-
return $this->getClarificationsHtml();
349-
default:
350-
$this->addFlash('danger', "Unknown export type '" . $type . "' requested.");
351-
return $this->redirectToRoute('jury_import_export');
352-
}
383+
return $this->getClarificationsHtml();
353384
} catch (BadRequestHttpException $e) {
354385
$this->addFlash('danger', $e->getMessage());
355386
return $this->redirectToRoute('jury_import_export');
356387
}
357388
}
358389

359-
protected function getResultsHtml(Request $request, bool $full = false): Response
360-
{
390+
protected function getResultsHtml(
391+
int $sortOrder,
392+
bool $individuallyRanked,
393+
bool $honors
394+
): string {
361395
/** @var TeamCategory[] $categories */
362396
$categories = $this->em->createQueryBuilder()
363397
->from(TeamCategory::class, 'c', 'c.categoryid')
@@ -392,9 +426,7 @@ protected function getResultsHtml(Request $request, bool $full = false): Respons
392426
$regionWinners = [];
393427
$rankPerTeam = [];
394428

395-
$sortOrder = $request->query->getInt('sort_order');
396-
397-
foreach ($this->importExportService->getResultsData($sortOrder, full: $full) as $row) {
429+
foreach ($this->importExportService->getResultsData($sortOrder, $individuallyRanked, $honors) as $row) {
398430
$team = $teamNames[$row->teamId];
399431
$rankPerTeam[$row->teamId] = $row->rank;
400432

@@ -421,7 +453,7 @@ protected function getResultsHtml(Request $request, bool $full = false): Respons
421453
}
422454
if ($row['rank'] === null) {
423455
$honorable[] = $row['team'];
424-
} elseif (in_array($row['award'], ['Highest Honors', 'High Honors', 'Honors'], true)) {
456+
} elseif (in_array($row['award'], ['Ranked', 'Highest Honors', 'High Honors', 'Honors'], true)) {
425457
$ranked[$row['award']][] = $row;
426458
} else {
427459
$awarded[] = $row;
@@ -432,7 +464,7 @@ protected function getResultsHtml(Request $request, bool $full = false): Respons
432464

433465
$collator = new Collator('en_US');
434466
$collator->sort($honorable);
435-
foreach ($ranked as $award => &$rankedTeams) {
467+
foreach ($ranked as &$rankedTeams) {
436468
usort($rankedTeams, function (array $a, array $b) use ($collator): int {
437469
if ($a['rank'] !== $b['rank']) {
438470
return $a['rank'] <=> $b['rank'];
@@ -494,16 +526,10 @@ protected function getResultsHtml(Request $request, bool $full = false): Respons
494526
'firstToSolve' => $firstToSolve,
495527
'domjudgeVersion' => $this->domjudgeVersion,
496528
'title' => sprintf('Results for %s', $contest->getName()),
497-
'download' => $request->query->getBoolean('download'),
498529
'sortOrder' => $sortOrder,
499530
];
500-
$response = $this->render('jury/export/results.html.twig', $data);
501531

502-
if ($request->query->getBoolean('download')) {
503-
$response->headers->set('Content-disposition', 'attachment; filename=results.html');
504-
}
505-
506-
return $response;
532+
return $this->twig->render('jury/export/results.html.twig', $data);
507533
}
508534

509535
protected function getClarificationsHtml(): Response
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace App\Form\Type;
4+
5+
use App\Entity\TeamCategory;
6+
use Doctrine\ORM\EntityManagerInterface;
7+
use stdClass;
8+
use Symfony\Component\Form\AbstractType;
9+
use Symfony\Component\Form\Extension\Core\Type\ChoiceType;
10+
use Symfony\Component\Form\Extension\Core\Type\SubmitType;
11+
use Symfony\Component\Form\FormBuilderInterface;
12+
13+
class ExportResultsType extends AbstractType
14+
{
15+
public function __construct(protected readonly EntityManagerInterface $em) {}
16+
17+
public function buildForm(FormBuilderInterface $builder, array $options): void
18+
{
19+
/** @var TeamCategory[] $teamCategories */
20+
$teamCategories = $this->em->createQueryBuilder()
21+
->from(TeamCategory::class, 'c', 'c.categoryid')
22+
->select('c.sortorder, c.name')
23+
->where('c.visible = 1')
24+
->orderBy('c.sortorder')
25+
->getQuery()
26+
->getResult();
27+
$sortOrders = [];
28+
foreach ($teamCategories as $teamCategory) {
29+
$sortOrder = $teamCategory['sortorder'];
30+
if (!array_key_exists($sortOrder, $sortOrders)) {
31+
$sortOrders[$sortOrder] = new stdClass();
32+
$sortOrders[$sortOrder]->sort_order = $sortOrder;
33+
$sortOrders[$sortOrder]->categories = [];
34+
}
35+
$sortOrders[$sortOrder]->categories[] = $teamCategory['name'];
36+
}
37+
38+
$builder->add('sortorder', ChoiceType::class, [
39+
'choices' => $sortOrders,
40+
'group_by' => null,
41+
'choice_label' => fn(stdClass $sortOrder) => sprintf(
42+
'%d with %d categor%s',
43+
$sortOrder->sort_order,
44+
count($sortOrder->categories),
45+
count($sortOrder->categories) === 1 ? 'y' : 'ies',
46+
),
47+
'choice_value' => 'sort_order',
48+
'choice_attr' => fn(stdClass $sortOrder) => [
49+
'data-categories' => json_encode($sortOrder->categories),
50+
],
51+
'label' => 'Sort order',
52+
'help' => '[will be replaced by categories]',
53+
]);
54+
$builder->add('individually_ranked', ChoiceType::class, [
55+
'choices' => [
56+
'Yes' => true,
57+
'No' => false,
58+
],
59+
'label' => 'Individually ranked?',
60+
]);
61+
$builder->add('honors', ChoiceType::class, [
62+
'choices' => [
63+
'Yes' => true,
64+
'No' => false,
65+
],
66+
'label' => 'Honors?',
67+
]);
68+
$builder->add('format', ChoiceType::class, [
69+
'choices' => [
70+
'HTML (display inline)' => 'html_inline',
71+
'HTML (download)' => 'html_download',
72+
'TSV' => 'tsv',
73+
],
74+
'label' => 'Format',
75+
]);
76+
$builder->add('export', SubmitType::class, ['icon' => 'fa-download']);
77+
}
78+
}

webapp/src/Service/ImportExportService.php

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -457,8 +457,11 @@ public function getTeamData(): array
457457
/**
458458
* @return ResultRow[]
459459
*/
460-
public function getResultsData(int $sortOrder, bool $full = false): array
461-
{
460+
public function getResultsData(
461+
int $sortOrder,
462+
bool $individuallyRanked = false,
463+
bool $honors = true,
464+
): array {
462465
$contest = $this->dj->getCurrentContest();
463466
if ($contest === null) {
464467
throw new BadRequestHttpException('No current contest');
@@ -530,18 +533,22 @@ public function getResultsData(int $sortOrder, bool $full = false): array
530533
$lowestMedalPoints = $teamScore->numPoints;
531534
} elseif ($numPoints >= $median) {
532535
// Teams with equally solved number of problems get the same rank unless $full is true.
533-
if (!$full) {
536+
if (!$individuallyRanked) {
534537
if (!isset($ranks[$numPoints])) {
535538
$ranks[$numPoints] = $rank;
536539
}
537540
$rank = $ranks[$numPoints];
538541
}
539-
if ($numPoints === $lowestMedalPoints) {
540-
$awardString = 'Highest Honors';
541-
} elseif ($numPoints === $lowestMedalPoints - 1) {
542-
$awardString = 'High Honors';
542+
if ($honors) {
543+
if ($numPoints === $lowestMedalPoints) {
544+
$awardString = 'Highest Honors';
545+
} elseif ($numPoints === $lowestMedalPoints - 1) {
546+
$awardString = 'High Honors';
547+
} else {
548+
$awardString = 'Honors';
549+
}
543550
} else {
544-
$awardString = 'Honors';
551+
$awardString = 'Ranked';
545552
}
546553
} else {
547554
$awardString = 'Honorable';

webapp/templates/jury/clarifications.html.twig

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@
2323
{%- else %}
2424

2525
<div class="float-end">
26-
<a href="{{ path('jury_html_export', {'type': 'clarifications'}) }}" target="_blank" class="btn btn-secondary btn-sm">
26+
<a href="{{ path('jury_html_export_clarifications') }}" target="_blank" class="btn btn-secondary btn-sm">
2727
<i class="fas fa-print"></i> Print clarifications
2828
</a>
2929
<a href="{{ path('jury_clarification_new') }}" class="btn btn-primary btn-sm">

webapp/templates/jury/export/results.html.twig

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,15 @@
2727
</tbody>
2828
</table>
2929

30-
{% for award in ['Highest Honors', 'High Honors', 'Honors'] %}
30+
{% for award in ['Ranked', 'Highest Honors', 'High Honors', 'Honors'] %}
3131
{% if ranked[award] is defined %}
32-
<h2>{{ award }}</h2>
32+
<h2>
33+
{% if award == 'Ranked' %}
34+
Other ranked teams
35+
{% else %}
36+
{{ award }}
37+
{% endif %}
38+
</h2>
3339
<table class="table">
3440
<thead>
3541
<tr>

0 commit comments

Comments
 (0)