Skip to content

Commit 865449d

Browse files
committed
Log data imports
1 parent d198283 commit 865449d

File tree

9 files changed

+233
-10
lines changed

9 files changed

+233
-10
lines changed

jolpica/formula_one/importer/json_models.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -49,8 +49,7 @@ def to_timedelta(self) -> timedelta:
4949

5050
def mutate_timedelta_from_dict(value: Any) -> Any:
5151
if isinstance(value, dict) and value.get("_type") == "timedelta":
52-
del value["_type"]
53-
return TimedeltaModel(**value).to_timedelta()
52+
return TimedeltaModel(**{key: val for key, val in value.items() if key != "_type"}).to_timedelta()
5453
return value
5554

5655

jolpica_api/data_import/admin.py

+12
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
from django.contrib import admin
2+
3+
from .models import DataImportLog
4+
5+
6+
class DataImportLogAdmin(admin.ModelAdmin):
7+
def __init__(self, model, admin_site):
8+
self.list_display = [field.name for field in model._meta.fields if field.name != "updated_records"]
9+
super().__init__(model, admin_site)
10+
11+
12+
admin.site.register(DataImportLog, DataImportLogAdmin)

jolpica_api/data_import/apps.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@
33

44
class DataImportConfig(AppConfig):
55
default_auto_field = "django.db.models.BigAutoField"
6-
name = "data_import"
6+
name = "jolpica_api.data_import"
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# Generated by Django 5.1.6 on 2025-03-02 16:36
2+
3+
import django.db.models.deletion
4+
from django.conf import settings
5+
from django.db import migrations, models
6+
7+
8+
class Migration(migrations.Migration):
9+
10+
initial = True
11+
12+
dependencies = [
13+
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
14+
]
15+
16+
operations = [
17+
migrations.CreateModel(
18+
name="DataImportLog",
19+
fields=[
20+
("id", models.BigAutoField(primary_key=True, serialize=False)),
21+
("is_success", models.BooleanField(default=False)),
22+
("completed_at", models.DateTimeField(auto_now_add=True)),
23+
("total_records", models.PositiveIntegerField(null=True)),
24+
("updated_records", models.JSONField(null=True)),
25+
("error_type", models.CharField(max_length=255, null=True)),
26+
("errors", models.JSONField(null=True)),
27+
(
28+
"user",
29+
models.ForeignKey(
30+
null=True,
31+
on_delete=django.db.models.deletion.SET_NULL,
32+
to=settings.AUTH_USER_MODEL,
33+
),
34+
),
35+
],
36+
),
37+
]

jolpica_api/data_import/migrations/__init__.py

Whitespace-only changes.

jolpica_api/data_import/models.py

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
from __future__ import annotations
2+
3+
from django.contrib.auth.models import User
4+
from django.db import models
5+
6+
7+
class DataImportLog(models.Model):
8+
id = models.BigAutoField(primary_key=True)
9+
user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True)
10+
is_success = models.BooleanField(default=False)
11+
completed_at = models.DateTimeField(auto_now_add=True)
12+
total_records = models.PositiveIntegerField(null=True)
13+
updated_records = models.JSONField(null=True)
14+
error_type = models.CharField(max_length=255, null=True)
15+
errors = models.JSONField(null=True)

jolpica_api/data_import/tests/test_views.py

+125
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,11 @@
33

44
import pytest
55
from django.contrib.auth.models import User
6+
from rest_framework import status
67
from rest_framework.test import APIClient
78

89
import jolpica.formula_one.models as f1
10+
from jolpica_api.data_import.models import DataImportLog
911

1012

1113
@pytest.fixture(scope="function")
@@ -182,3 +184,126 @@ def test_data_import_2023_18_models_are_imported(client: APIClient):
182184
).count()
183185
> 45
184186
)
187+
188+
189+
@pytest.mark.django_db
190+
def test_successful_import(client):
191+
"""Test successful data import."""
192+
data = {
193+
"dry_run": False,
194+
"data": [
195+
{
196+
"object_type": "Driver",
197+
"foreign_keys": {},
198+
"objects": [{"reference": "max_verstappen", "forename": "Max"}],
199+
}
200+
],
201+
}
202+
response = client.put("/data/import/", data, format="json")
203+
204+
assert response.status_code == status.HTTP_200_OK
205+
assert DataImportLog.objects.count() == 1
206+
log = DataImportLog.objects.first()
207+
assert log.completed_at is not None
208+
assert log.error_type is None
209+
assert log.updated_records == {"Driver": [831]}
210+
assert log.is_success
211+
assert log.error_type is None
212+
assert log.errors is None
213+
214+
215+
@pytest.mark.django_db
216+
def test_validation_error_has_logs(client):
217+
"""Test import with deserialization errors."""
218+
data = {
219+
"dry_run": False,
220+
"data": [
221+
{
222+
"object_type": "Driver",
223+
"objects": [{"reference": "max_verstappen", "forename": "Max"}],
224+
}
225+
],
226+
}
227+
response = client.put("/data/import/", data, format="json")
228+
229+
assert response.status_code == status.HTTP_400_BAD_REQUEST
230+
assert DataImportLog.objects.count() == 1
231+
log = DataImportLog.objects.first()
232+
assert not log.is_success
233+
assert log.error_type == "VALIDATION"
234+
assert log.errors[0]["type"] == "missing"
235+
236+
237+
@pytest.mark.django_db
238+
def test_deserialisation_error_has_log(client):
239+
"""Test validation error during request data validation."""
240+
data = {
241+
"dry_run": False,
242+
"data": [
243+
{
244+
"object_type": "Round",
245+
"foreign_keys": {"year": 9999},
246+
"objects": [{"number": 5}],
247+
}
248+
],
249+
}
250+
response = client.put("/data/import/", data, format="json")
251+
252+
assert response.status_code == status.HTTP_400_BAD_REQUEST
253+
assert DataImportLog.objects.count() == 1
254+
log = DataImportLog.objects.first()
255+
assert not log.is_success
256+
assert log.error_type == "DESERIALISATION"
257+
assert response.json()["errors"][0] == {
258+
"index": 0,
259+
"message": ["DoesNotExist('Season matching query does not exist.')"],
260+
"type": "Round",
261+
}
262+
263+
264+
@pytest.mark.django_db
265+
def test_dry_run(client):
266+
"""Test dry run."""
267+
data = {
268+
"dry_run": True,
269+
"data": [
270+
{
271+
"object_type": "Driver",
272+
"foreign_keys": {},
273+
"objects": [{"reference": "max_verstappen", "forename": "Max"}],
274+
}
275+
],
276+
}
277+
response = client.put("/data/import/", data, format="json")
278+
279+
assert response.status_code == status.HTTP_200_OK
280+
assert DataImportLog.objects.count() == 0 # No log for dry run
281+
282+
283+
@pytest.mark.django_db
284+
def test_db_error(client):
285+
"""Test database error during import."""
286+
data = {
287+
"dry_run": False,
288+
"legacy_import": False,
289+
"data": [
290+
{
291+
"object_type": "Lap",
292+
"foreign_keys": {"year": 2023, "round": 18, "session": "R", "car_number": 1},
293+
"objects": [
294+
{"number": 1, "position": 1, "average_speed": 200.0, "is_entry_fastest_lap": True},
295+
{
296+
"number": 2,
297+
"position": 1,
298+
"average_speed": 201.0,
299+
},
300+
],
301+
},
302+
],
303+
}
304+
response = client.put("/data/import/", data, format="json")
305+
assert response.status_code == status.HTTP_400_BAD_REQUEST
306+
assert DataImportLog.objects.count() == 1
307+
log = DataImportLog.objects.first()
308+
assert not log.is_success
309+
assert log.error_type == "IMPORT"

jolpica_api/data_import/views.py

+41-7
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1-
from pydantic import BaseModel, ValidationError
2-
from rest_framework.permissions import IsAdminUser
1+
from collections import defaultdict
2+
3+
from pydantic import BaseModel, Field, ValidationError
4+
from rest_framework.permissions import IsAdminUser, IsAuthenticated
35
from rest_framework.request import Request
46
from rest_framework.response import Response
57
from rest_framework.views import APIView
68

9+
from jolpica.formula_one.importer.deserialisers import DeserialisationResult
710
from jolpica.formula_one.importer.importer import JSONModelImporter
11+
from jolpica.formula_one.importer.json_models import F1Import
12+
13+
from .models import DataImportLog
814

915

1016
class ImportDataRequestData(BaseModel):
@@ -15,25 +21,53 @@ class ImportDataRequestData(BaseModel):
1521
# Should never be used for data from 2025 onwards.
1622
legacy_import: bool = False
1723

18-
data: list
24+
data: list[F1Import] = Field(min_length=1)
1925

2026

2127
class ImportData(APIView):
22-
permission_classes = [IsAdminUser]
28+
permission_classes = [IsAuthenticated, IsAdminUser]
2329

2430
def put(self, request: Request) -> Response:
31+
if request.user.is_anonymous:
32+
return Response(status=401)
2533
try:
2634
request_data = ImportDataRequestData.model_validate(request.data)
2735
except ValidationError as ex:
28-
return Response({"errors": ex.errors(include_url=False, include_input=False)}, status=400)
36+
errors = ex.errors(include_url=False, include_input=False)
37+
DataImportLog(user=request.user, is_success=False, error_type="VALIDATION", errors=errors).save()
38+
return Response({"errors": errors}, status=400)
2939

3040
model_importer = JSONModelImporter(legacy_import=request_data.legacy_import)
31-
result = model_importer.deserialise_all(request_data.data)
41+
result = model_importer.deserialise_all(request.data["data"])
3242

3343
if not result.success:
44+
DataImportLog(
45+
user=request.user, is_success=False, error_type="DESERIALISATION", errors=result.errors
46+
).save()
3447
return Response({"errors": result.errors}, status=400)
3548

3649
if not request_data.dry_run and request.user.is_staff:
37-
model_importer.save_deserialisation_result_to_db(result)
50+
try:
51+
model_importer.save_deserialisation_result_to_db(result)
52+
except Exception as ex:
53+
DataImportLog(user=request.user, is_success=False, error_type="IMPORT", errors=[repr(ex)]).save()
54+
return Response({"errors": [{"type": "import_error", "message": repr(ex)}]}, status=400)
3855

56+
if not request_data.dry_run:
57+
save_successful_import_to_db(request, result)
3958
return Response({})
59+
60+
61+
def save_successful_import_to_db(request: Request, result: DeserialisationResult) -> None:
62+
if request.user.is_anonymous:
63+
raise ValueError()
64+
updated_record_count = 0
65+
updated_records = defaultdict(list)
66+
for model_import, instances in result.instances.items():
67+
instance_pks = [ins.pk for ins in instances]
68+
updated_record_count += len(instance_pks)
69+
updated_records[model_import.model_class.__name__].extend(instance_pks)
70+
71+
DataImportLog(
72+
user=request.user, is_success=True, total_records=updated_record_count, updated_records=updated_records
73+
).save()

jolpica_api/settings.py

+1
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
"jolpica.formula_one",
7474
"jolpica_api.authentication",
7575
"jolpica_api.ergastapi",
76+
"jolpica_api.data_import",
7677
]
7778
if DEPLOYMENT_ENV in ("LOCAL", "SANDBOX"):
7879
INSTALLED_APPS += ["django_dbml", "debug_toolbar"]

0 commit comments

Comments
 (0)