Skip to content

Commit 4606726

Browse files
committed
add admin UI to import schools
1 parent b175509 commit 4606726

File tree

6 files changed

+270
-9
lines changed

6 files changed

+270
-9
lines changed

bullet/bullet_admin/forms/education.py

Lines changed: 136 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,144 @@
1+
import csv
2+
from dataclasses import dataclass
3+
from functools import partial
4+
from io import TextIOWrapper
5+
16
from django import forms
2-
from education.models import School
7+
from django.core.validators import FileExtensionValidator
8+
from django.db import transaction
9+
from django_countries.fields import CountryField
10+
from education.models import School, SchoolType
11+
from education.tasks import send_schools_to_search
312

413
from bullet_admin.forms.utils import get_country_choices
514

615

16+
class SchoolCSVImportForm(forms.Form):
17+
csv_file = forms.FileField(
18+
label="CSV file",
19+
validators=[FileExtensionValidator(["csv"])],
20+
help_text="CSV format: identifier, name, address, types (comma-separated)",
21+
)
22+
country = CountryField().formfield()
23+
preview = forms.BooleanField(
24+
label="Preview only (don't import)",
25+
required=False,
26+
initial=True,
27+
help_text="Check this to preview the import without making changes",
28+
)
29+
30+
@dataclass
31+
class SchoolData:
32+
identifier: str
33+
name: str
34+
address: str
35+
types: list[str]
36+
37+
def __init__(self, competition, user, **kwargs):
38+
super().__init__(**kwargs)
39+
self.fields["country"].choices = get_country_choices(competition, user)
40+
self.competition = competition
41+
self.user = user
42+
43+
def parse_csv(self) -> list[SchoolData]:
44+
csv_file = self.cleaned_data["csv_file"]
45+
46+
schools = []
47+
with TextIOWrapper(csv_file.file, encoding="utf-8") as decoded_file:
48+
csv_file.file.seek(0)
49+
50+
reader = csv.DictReader(decoded_file)
51+
if not reader.fieldnames:
52+
raise ValueError("CSV file has no header row")
53+
54+
required_columns = ["identifier", "name", "address", "types"]
55+
missing_columns = []
56+
for col in required_columns:
57+
if col not in reader.fieldnames:
58+
missing_columns.append(col)
59+
if missing_columns:
60+
raise ValueError(
61+
f"CSV file is missing required columns: {', '.join(missing_columns)}."
62+
)
63+
64+
for row in reader:
65+
identifier = row.get("identifier", "").strip()
66+
name = row.get("name", "").strip()
67+
address = row.get("address", "").strip()
68+
types_str = row.get("types", "").strip()
69+
types = [t.strip() for t in types_str.split(",") if t.strip()]
70+
71+
schools.append(
72+
SchoolCSVImportForm.SchoolData(identifier, name, address, types)
73+
)
74+
75+
if not schools:
76+
raise ValueError("No valid school data found in CSV file")
77+
return schools
78+
79+
@transaction.atomic
80+
def import_schools(self):
81+
schools_data = self.parse_csv()
82+
country = self.cleaned_data["country"]
83+
84+
created_count = 0
85+
updated_count = 0
86+
errors = []
87+
88+
school_types_cache = {}
89+
used_types = set()
90+
for data in schools_data:
91+
used_types.update(data.types)
92+
93+
for type_name in used_types:
94+
school_type = SchoolType.objects.filter(identifier=type_name).first()
95+
if not school_type:
96+
continue
97+
school_types_cache[type_name] = school_type
98+
99+
school_ids = []
100+
for data in schools_data:
101+
school = None
102+
if data.identifier:
103+
school = School.objects.filter(
104+
importer_identifier=data.identifier,
105+
country=country,
106+
).first()
107+
108+
if school:
109+
if school.importer_ignored:
110+
errors.append(f"School {school.importer_identifier} is ignored")
111+
continue
112+
updated_count += 1
113+
else:
114+
school = School(
115+
importer_identifier=data.identifier,
116+
country=country,
117+
)
118+
created_count += 1
119+
120+
school.name = data.name
121+
school.address = data.address
122+
school.save(send_to_search=False)
123+
124+
types = []
125+
for type_name in data.types:
126+
if type_name in school_types_cache:
127+
types.append(school_types_cache[type_name])
128+
school.types.set(types)
129+
130+
school_ids.append(school.id)
131+
132+
transaction.on_commit(partial(send_schools_to_search, school_ids=school_ids))
133+
134+
return {
135+
"created": created_count,
136+
"updated": updated_count,
137+
"errors": errors,
138+
"total": len(schools_data),
139+
}
140+
141+
7142
class SchoolForm(forms.ModelForm):
8143
class Meta:
9144
model = School
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
{% extends "bullet_admin/generic/form.html" %}
2+
{% load badmin %}
3+
4+
{% block after_form %}
5+
{% if preview_data is not None %}
6+
<div class="mt-8">
7+
<h3 class="text-lg font-semibold mb-4">Preview</h3>
8+
<div class="bg-blue-50 border border-blue-200 rounded-md p-4 mb-4">
9+
<p class="text-sm text-blue-800">
10+
This is a preview. No schools have been imported yet.
11+
Uncheck "Preview only" and submit again to perform the actual import.
12+
</p>
13+
</div>
14+
15+
<div class="overflow-x-auto">
16+
<table class="min-w-full divide-y divide-gray-200">
17+
<thead class="bg-gray-50">
18+
<tr>
19+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Identifier</th>
20+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Name</th>
21+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Address</th>
22+
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Types</th>
23+
</tr>
24+
</thead>
25+
<tbody class="bg-white divide-y divide-gray-200">
26+
{% for school in preview_data %}
27+
<tr class="{% cycle "bg-white" "bg-gray-50" %}">
28+
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900">{{ school.identifier }}</td>
29+
<td class="px-6 py-4 text-sm text-gray-900">{{ school.name }}</td>
30+
<td class="px-6 py-4 text-sm text-gray-900">{{ school.address }}</td>
31+
<td class="px-6 py-4 text-sm text-gray-900">
32+
{% for type in school.types %}
33+
<span class="inline-block bg-gray-100 rounded-full px-2 py-1 text-xs font-medium text-gray-800 mr-1 mb-1">{{ type }}</span>
34+
{% endfor %}
35+
</td>
36+
</tr>
37+
{% endfor %}
38+
</tbody>
39+
</table>
40+
</div>
41+
</div>
42+
{% endif %}
43+
{% endblock after_form %}

bullet/bullet_admin/urls/__init__.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -266,6 +266,11 @@
266266
education.SchoolCreateView.as_view(),
267267
name="school_create",
268268
),
269+
path(
270+
"education/schools/import/",
271+
education.SchoolCSVImportView.as_view(),
272+
name="school_csv_import",
273+
),
269274
path("gallery/albums/", album.AlbumListView.as_view(), name="album_list"),
270275
path("gallery/albums/new/", album.AlbumCreateView.as_view(), name="album_create"),
271276
path(

bullet/bullet_admin/views/education.py

Lines changed: 67 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
from django.contrib import messages
22
from django.http import HttpResponseRedirect
33
from django.urls import reverse, reverse_lazy
4-
from django.views.generic import CreateView, ListView, UpdateView
4+
from django.views.generic import CreateView, FormView, ListView, UpdateView
55
from education.models import School
66

77
from bullet import search
88
from bullet_admin.access import PermissionCheckMixin, is_country_admin
9-
from bullet_admin.forms.education import SchoolForm
9+
from bullet_admin.forms.education import SchoolCSVImportForm, SchoolForm
1010
from bullet_admin.mixins import MixinProtocol, RedirectBackMixin
1111
from bullet_admin.utils import get_active_competition, get_allowed_countries
1212
from bullet_admin.views import GenericForm
@@ -26,7 +26,10 @@ def get_queryset(self):
2626
class SchoolListView(PermissionCheckMixin, SchoolQuerySetMixin, GenericList, ListView):
2727
required_permissions = [is_country_admin]
2828

29-
list_links = [NewLink("school", reverse_lazy("badmin:school_create"))]
29+
list_links = [
30+
NewLink("school", reverse_lazy("badmin:school_create")),
31+
Link("green", "mdi:upload", "Import", reverse_lazy("badmin:school_csv_import")),
32+
]
3033
table_fields = ["name", "address", "country"]
3134
table_field_templates = {
3235
"name": "bullet_admin/education/field__school_name.html",
@@ -78,7 +81,6 @@ class SchoolUpdateView(
7881
form_class = SchoolForm
7982
template_name = "bullet_admin/education/school_form.html"
8083
form_title = "Edit school"
81-
require_unlocked_competition = False
8284
default_success_url = reverse_lazy("badmin:school_list")
8385

8486
def get_form_kwargs(self):
@@ -106,7 +108,6 @@ class SchoolCreateView(
106108
CreateView,
107109
):
108110
required_permissions = [is_country_admin]
109-
require_unlocked_competition = False
110111
form_class = SchoolForm
111112
form_title = "New school"
112113
default_success_url = reverse_lazy("badmin:school_list")
@@ -126,3 +127,64 @@ def form_valid(self, form):
126127

127128
messages.success(self.request, "School saved.")
128129
return HttpResponseRedirect(self.get_success_url())
130+
131+
132+
class SchoolCSVImportView(
133+
PermissionCheckMixin,
134+
RedirectBackMixin,
135+
GenericForm,
136+
FormView,
137+
):
138+
required_permissions = [is_country_admin]
139+
form_class = SchoolCSVImportForm
140+
form_title = "Import schools"
141+
form_multipart = True
142+
template_name = "bullet_admin/education/school_csv_import.html"
143+
default_success_url = reverse_lazy("badmin:school_list")
144+
145+
def get_form_kwargs(self):
146+
kw = super().get_form_kwargs()
147+
kw["competition"] = get_active_competition(self.request)
148+
kw["user"] = self.request.user
149+
return kw
150+
151+
def form_valid(self, form):
152+
if form.cleaned_data["preview"]:
153+
try:
154+
schools_data = form.parse_csv()
155+
return self.render_to_response(
156+
self.get_context_data(
157+
form=form,
158+
preview_data=schools_data[:10],
159+
)
160+
)
161+
except Exception as e:
162+
messages.error(self.request, str(e))
163+
return self.form_invalid(form)
164+
else:
165+
try:
166+
result = form.import_schools()
167+
168+
if result["errors"]:
169+
messages.warning(
170+
self.request,
171+
f"Import completed with {len(result['errors'])} errors. "
172+
f"Created: {result['created']}, Updated: {result['updated']}",
173+
)
174+
for error in result["errors"][:5]: # Show first 5 errors
175+
messages.error(self.request, error)
176+
if len(result["errors"]) > 5:
177+
messages.error(
178+
self.request,
179+
f"... and {len(result['errors']) - 5} more errors",
180+
)
181+
else:
182+
messages.success(
183+
self.request,
184+
f"Successfully imported {result['created']} new schools and updated {result['updated']} existing schools.",
185+
)
186+
187+
return HttpResponseRedirect(self.get_success_url())
188+
except Exception as e:
189+
messages.error(self.request, str(e))
190+
return self.form_invalid(form)

bullet/education/models.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66

77
class SchoolType(models.Model):
8+
id: int
89
name = models.CharField(max_length=64)
910
note = models.CharField(
1011
max_length=32, help_text="shown in admin interface", blank=True
@@ -48,6 +49,7 @@ def __str__(self):
4849

4950

5051
class School(models.Model):
52+
id: int
5153
name = models.CharField(max_length=256)
5254
types = models.ManyToManyField(SchoolType)
5355
address = models.CharField(max_length=256, blank=True, null=True)
@@ -74,13 +76,17 @@ class Meta:
7476
def __str__(self):
7577
return f"{self.name}, {self.address}"
7678

77-
def save(self, send_to_search=True, **kwargs):
78-
x = super().save(**kwargs)
79-
if send_to_search and search.enabled and not self.is_legacy:
79+
def send_to_search(self):
80+
if search.enabled and not self.is_legacy:
8081
search.client.index("schools").add_documents(
8182
[self.for_search()],
8283
"id",
8384
)
85+
86+
def save(self, send_to_search=True, **kwargs):
87+
x = super().save(**kwargs)
88+
if send_to_search:
89+
self.send_to_search()
8490
return x
8591

8692
def for_search(self):

bullet/education/tasks.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from django_rq import job
2+
3+
from education.models import School
4+
5+
6+
@job
7+
def send_schools_to_search(school_ids: list[int]):
8+
schools = School.objects.filter(id__in=school_ids)
9+
for school in schools:
10+
school.send_to_search()

0 commit comments

Comments
 (0)