Skip to content

Commit c265b3c

Browse files
committed
feat: allow multi-language tearoff generation, store tearoffs on server
1 parent 058d0cc commit c265b3c

File tree

9 files changed

+226
-63
lines changed

9 files changed

+226
-63
lines changed

bullet/bullet_admin/forms/competition.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from competitions.models import Competition
22
from django import forms
3+
from django.core.validators import FileExtensionValidator
34
from users.models import User
45

56

@@ -38,3 +39,14 @@ class Meta:
3839

3940
def __init__(self, user: User, **kwargs):
4041
super().__init__(**kwargs)
42+
43+
44+
class TearoffUploadForm(forms.Form):
45+
problems = forms.FileField(
46+
label="Tearoff file",
47+
help_text="A ZIP file containing one PDF for every language, "
48+
"or a single PDF file. The file names should be the 2-letter language code.",
49+
validators=[
50+
FileExtensionValidator(["pdf", "zip"]),
51+
],
52+
)

bullet/bullet_admin/forms/documents.py

Lines changed: 8 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
from competitions.models import Competition, Venue
22
from django import forms
3+
from django.conf import settings
34
from django.core.exceptions import ValidationError
45
from documents.models import CertificateTemplate, TexTemplate
5-
from pikepdf import Pdf
66
from users.models import User
77

88

@@ -59,12 +59,6 @@ def __init__(self, competition: Competition, user: User, **kwargs):
5959

6060

6161
class TearoffForm(forms.Form):
62-
problems = forms.FileField(
63-
label="Problem file",
64-
help_text=(
65-
"A PDF file containing one problem per page (including last empty page)."
66-
),
67-
)
6862
first_problem = forms.IntegerField(
6963
label="First problem",
7064
initial=1,
@@ -75,6 +69,9 @@ class TearoffForm(forms.Form):
7569
initial=0,
7670
min_value=0,
7771
)
72+
backup_team_language = forms.ChoiceField(
73+
label="Backup team language",
74+
)
7875
ordering = forms.ChoiceField(
7976
label="Problem ordering",
8077
choices=[("align", "Aligned"), ("seq", "Sequential")],
@@ -83,27 +80,11 @@ class TearoffForm(forms.Form):
8380
label="Include QR code", initial=True, required=False
8481
)
8582

86-
def __init__(self, *, problems, first_problem, **kwargs):
83+
def __init__(self, *, problems, first_problem, venue, **kwargs):
8784
super().__init__(**kwargs)
8885

8986
self.fields["first_problem"].initial = first_problem
87+
self.fields["backup_team_language"].choices = list(
88+
filter(lambda lang: lang[0] in venue.accepted_languages, settings.LANGUAGES)
89+
)
9090
self._problem_count = problems
91-
92-
def clean_problems(self):
93-
pdf = None
94-
try:
95-
pdf = Pdf.open(self.cleaned_data["problems"])
96-
except Exception:
97-
if pdf:
98-
pdf.close()
99-
raise ValidationError("The uploaded file is not a valid PDF.")
100-
101-
if len(pdf.pages) != self._problem_count + 1:
102-
pdf.close()
103-
raise ValidationError(
104-
f"The uploaded file does not have the correct number of pages. "
105-
f"Expected {self._problem_count} + 1."
106-
)
107-
108-
pdf.close()
109-
return self.cleaned_data["problems"]

bullet/bullet_admin/templates/bullet_admin/competition/form.html

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@
1818
{% if not object.results_public %}
1919
{% url "badmin:competition_finalize" as finalize_url %}
2020
{% #abtn label="Finalize results" icon="mdi:check" url=finalize_url %}
21+
{% url "badmin:competition_upload_tearoffs" as upload_url %}
22+
{% #abtn label="Upload tearoffs" icon="mdi:upload" url=upload_url %}
2123
{% endif %}
2224
</div>
2325
</form>
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{% extends "bullet_admin/generic/form.html" %}
2+
{% block before_form %}
3+
<p class="mb-4">Current uploaded tearoff files: {{ available_langs|join:", " }}</p>
4+
{% endblock before_form %}

bullet/bullet_admin/urls/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@
5555
competition.CompetitionFinalizeView.as_view(),
5656
name="competition_finalize",
5757
),
58+
path(
59+
"competitions/tearoff_upload/",
60+
competition.CompetitionTearoffUploadView.as_view(),
61+
name="competition_upload_tearoffs",
62+
),
5863
path(
5964
"competitions/automove/",
6065
competition.CompetitionAutomoveView.as_view(),

bullet/bullet_admin/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from competitions.models import Venue
1313

1414

15-
def get_active_competition(request: HttpRequest):
15+
def get_active_competition(request: HttpRequest) -> Competition:
1616
if not hasattr(request, "_badmin_competition"):
1717
session_key = f"badmin_{request.BRANCH.identifier}_competition"
1818
if session_key not in request.session:

bullet/bullet_admin/views/competition.py

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
1+
import os.path
2+
import zipfile
3+
from operator import itemgetter
4+
15
from competitions.models import Competition
6+
from django.conf import settings
27
from django.contrib import messages
8+
from django.core.files.storage import default_storage
39
from django.forms import Form
410
from django.shortcuts import redirect
511
from django.urls import reverse
@@ -9,7 +15,7 @@
915
from users.logic import move_all_eligible_teams
1016

1117
from bullet_admin.access import BranchAdminAccess, UnlockedCompetitionMixin
12-
from bullet_admin.forms.competition import CompetitionForm
18+
from bullet_admin.forms.competition import CompetitionForm, TearoffUploadForm
1319
from bullet_admin.utils import get_active_competition
1420
from bullet_admin.views import GenericForm
1521

@@ -81,3 +87,62 @@ def form_valid(self, form):
8187
self.request, "The teams will be moved to the competition shortly."
8288
)
8389
return redirect("badmin:home")
90+
91+
92+
class CompetitionTearoffUploadView(
93+
UnlockedCompetitionMixin, BranchAdminAccess, GenericForm, FormView
94+
):
95+
form_class = TearoffUploadForm
96+
form_title = "Tearoff upload"
97+
form_multipart = True
98+
template_name = "bullet_admin/competition/tearoff.html"
99+
100+
def get_upload_folder(self):
101+
competition = get_active_competition(self.request)
102+
return str(competition.secret_dir / "tearoffs")
103+
104+
def get_context_data(self, **kwargs):
105+
ctx = super().get_context_data(**kwargs)
106+
path = self.get_upload_folder()
107+
if not default_storage.exists(path):
108+
ctx["available_langs"] = []
109+
else:
110+
ctx["available_langs"] = default_storage.listdir(path)[1]
111+
return ctx
112+
113+
def is_valid_name(self, name):
114+
return name in map(itemgetter(0), settings.LANGUAGES)
115+
116+
def upload_zip(self, problems):
117+
target_dir = default_storage.path(self.get_upload_folder())
118+
119+
with zipfile.ZipFile(problems) as zipf:
120+
for file in zipf.namelist():
121+
name, extension = os.path.splitext(file)
122+
if extension != ".pdf":
123+
continue
124+
125+
if not self.is_valid_name(name):
126+
continue
127+
128+
zipf.extract(file, target_dir)
129+
130+
def upload_pdf(self, problems):
131+
name, extension = os.path.splitext(problems.name)
132+
if not self.is_valid_name(name):
133+
return
134+
path = os.path.join(self.get_upload_folder(), problems.name)
135+
if default_storage.exists(path):
136+
default_storage.delete(path)
137+
default_storage.save(path, problems)
138+
139+
def form_valid(self, form):
140+
problems = form.cleaned_data["problems"]
141+
142+
if problems.name.endswith(".zip"):
143+
self.upload_zip(problems)
144+
else:
145+
self.upload_pdf(problems)
146+
147+
messages.success(self.request, "Tearoffs uploaded successfully.")
148+
return redirect("badmin:competition_upload_tearoffs")

bullet/bullet_admin/views/venues.py

Lines changed: 37 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1+
from pathlib import Path
2+
13
from competitions.models import Venue
24
from django.contrib import messages
5+
from django.core.files.storage import default_storage
36
from django.forms import Form
4-
from django.http import FileResponse, HttpResponseRedirect
7+
from django.http import FileResponse, HttpResponse, HttpResponseRedirect
58
from django.shortcuts import get_object_or_404, redirect
69
from django.urls import reverse, reverse_lazy
710
from django.utils.functional import cached_property
@@ -14,7 +17,7 @@
1417
)
1518
from documents.generators.certificate import certificates_for_venue
1619
from documents.generators.team_list import team_list
17-
from documents.generators.tearoff import TearoffGenerator
20+
from documents.generators.tearoff import TearoffGenerator, TearoffRequirementMissing
1821
from documents.models import TexJob
1922
from problems.logic.results import save_country_ranks, save_venue_ranks
2023
from problems.models import CategoryProblem, Problem
@@ -207,25 +210,48 @@ def get_form_kwargs(self):
207210
.first()
208211
)
209212
kw["problems"] = problem_count
213+
kw["venue"] = self.venue
210214
kw["first_problem"] = first_problem.number if first_problem else 1
211215
return kw
212216

213217
def form_valid(self, form):
214-
t = TearoffGenerator(form.cleaned_data["problems"])
218+
competition = self.venue.category.competition
219+
220+
try:
221+
tearoff_dir = Path(
222+
default_storage.path(competition.secret_dir / "tearoffs")
223+
)
224+
t = TearoffGenerator(tearoff_dir, form.cleaned_data["backup_team_language"])
225+
except TearoffRequirementMissing as e:
226+
return HttpResponse(str(e), status=500)
227+
215228
teams = list(
216229
Team.objects.competing()
217230
.filter(venue=self.venue, number__isnull=False)
218231
.all()
219232
)
233+
220234
for i in range(form.cleaned_data["backup_teams"]):
221-
teams.append(Team(venue=self.venue, name="???", number=999 - i))
222-
data = t.generate_pdf(
223-
teams,
224-
form.cleaned_data["first_problem"],
225-
form.cleaned_data["ordering"],
226-
form.cleaned_data["include_qr_codes"],
227-
)
228-
return FileResponse(data, filename="tearoffs.pdf")
235+
teams.append(
236+
Team(
237+
venue=self.venue,
238+
language=form.cleaned_data["backup_team_language"],
239+
name="???",
240+
number=999 - i,
241+
)
242+
)
243+
244+
try:
245+
data = t.generate_pdf(
246+
teams,
247+
form.cleaned_data["first_problem"],
248+
form._problem_count,
249+
form.cleaned_data["ordering"],
250+
form.cleaned_data["include_qr_codes"],
251+
)
252+
return FileResponse(data, filename="tearoffs.pdf")
253+
except TearoffRequirementMissing as e:
254+
return HttpResponse(str(e), status=500)
229255

230256

231257
class FinishReviewView(VenueMixin, RedirectBackMixin, GenericForm, FormView):

0 commit comments

Comments
 (0)