Skip to content

Commit 3fbea22

Browse files
authored
Merge pull request #246 from buildingSMART/IVS-609-combine-pydantic-django-drf
IVS-609 - Pydantic for common validation scenarios
2 parents f86dbd7 + c86d20b commit 3fbea22

File tree

5 files changed

+144
-51
lines changed

5 files changed

+144
-51
lines changed
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from rest_framework import serializers
2+
from pydantic import ValidationError as PydanticValidationError
3+
4+
class PydanticValidatorMixin:
5+
"""
6+
Bridge for Pydantic & DRF. Set `Schema` on subclasses.
7+
8+
"""
9+
Schema = None
10+
11+
# allow subclasses to filter which keys from Pydantic we keep
12+
_pydantic_keep_keys: set[str] | None = None
13+
14+
def validate(self, attrs):
15+
if self.Schema is None:
16+
return attrs
17+
try:
18+
model = self.Schema.model_validate(attrs)
19+
except PydanticValidationError as exc:
20+
err_map = {}
21+
for err in exc.errors():
22+
loc = err.get("loc", ())
23+
field = "non_field_errors"
24+
if loc and loc[0] != "__root__":
25+
field = ".".join(map(str, loc))
26+
msg = err.get("msg", "")
27+
if msg.lower().startswith("value error, "):
28+
msg = msg[len("Value error, "):]
29+
err_map.setdefault(field, []).append(msg)
30+
raise serializers.ValidationError(err_map)
31+
32+
normalized = model.model_dump()
33+
34+
normalized.pop("files", None)
35+
36+
if self._pydantic_keep_keys is not None:
37+
normalized = {k: v for k, v in normalized.items() if k in self._pydantic_keep_keys}
38+
39+
return {**attrs, **normalized}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
from __future__ import annotations
2+
from typing import List, Optional
3+
from pydantic import BaseModel, field_validator
4+
from django.core.files.uploadedfile import UploadedFile
5+
from core.settings import MAX_FILE_SIZE_IN_MB
6+
7+
class _Base(BaseModel):
8+
model_config = {"arbitrary_types_allowed": True}
9+
10+
# note: by convention, this class is suffixed with "In" to indicate it is used for input validation
11+
class ValidationRequestIn(_Base):
12+
file: Optional[UploadedFile] = None
13+
files: Optional[List[UploadedFile]] = None
14+
file_name: Optional[str] = None
15+
size: Optional[int] = None
16+
17+
@field_validator("file")
18+
@classmethod
19+
def file_required_and_under_limit(cls, v: Optional[UploadedFile]):
20+
if v is None:
21+
raise ValueError("File is required.")
22+
max_bytes = MAX_FILE_SIZE_IN_MB * 1024 * 1024
23+
if v.size > max_bytes:
24+
raise ValueError(f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB).")
25+
return v
26+
27+
@field_validator("file_name")
28+
@classmethod
29+
def file_name_ifc(cls, v: Optional[str]):
30+
if not v:
31+
raise ValueError("File name is required.")
32+
if not v.lower().endswith(".ifc"):
33+
raise ValueError("File name must end with '.ifc'.")
34+
return v
35+
36+
@field_validator("size")
37+
@classmethod
38+
def size_positive_and_under_limit(cls, v: Optional[int]):
39+
if v is None:
40+
return v
41+
if v <= 0:
42+
raise ValueError("Size must be positive.")
43+
max_bytes = MAX_FILE_SIZE_IN_MB * 1024 * 1024
44+
if v > max_bytes:
45+
raise ValueError(f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB).")
46+
return v

backend/apps/ifc_validation/api/v1/serializers.py

Lines changed: 9 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
from apps.ifc_validation_models.models import ValidationOutcome
66
from apps.ifc_validation_models.models import Model
77

8-
from core.settings import MAX_FILE_SIZE_IN_MB
8+
from .pydantic_bridge import PydanticValidatorMixin
9+
from .schemas import ValidationRequestIn
910

1011
class BaseSerializer(serializers.ModelSerializer):
1112

@@ -27,7 +28,9 @@ class Meta:
2728
abstract = True
2829

2930

30-
class ValidationRequestSerializer(BaseSerializer):
31+
class ValidationRequestSerializer(PydanticValidatorMixin, BaseSerializer):
32+
33+
Schema = ValidationRequestIn
3134

3235
class Meta:
3336
model = ValidationRequest
@@ -36,70 +39,26 @@ class Meta:
3639
hide = ["id", "model", "deleted", "created_by", "updated_by", "status_reason"]
3740
read_only_fields = ['size', 'created', 'updated', 'created_by', 'updated_by', 'channel', 'completed', 'started', 'progress', 'status']
3841

39-
def validate_file(self, value):
4042

41-
# ensure file is not empty
42-
if not value:
43-
raise serializers.ValidationError("File is required.")
44-
45-
# ensure size is under MAX_FILE_SIZE_IN_MB
46-
if value.size > MAX_FILE_SIZE_IN_MB * 1024 * 1024:
47-
raise serializers.ValidationError(f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB).")
48-
49-
return value
43+
class ValidationTaskSerializer(PydanticValidatorMixin, BaseSerializer):
5044

51-
def validate_files(self, value):
52-
53-
# ensure exactly one file is uploaded
54-
if len(value) > 1:
55-
raise serializers.ValidationError({"file": "Only one file can be uploaded at a time."})
56-
57-
return value
58-
59-
def validate_file_name(self, value):
60-
61-
# ensure file name is not empty
62-
if not value:
63-
raise serializers.ValidationError("File name is required.")
64-
65-
# ensure file name ends with .ifc
66-
if not value.lower().endswith('.ifc'):
67-
raise serializers.ValidationError(f"File name must end with '.ifc'.")
68-
69-
return value
70-
71-
def validate_size(self, value):
72-
73-
# ensure size is positive
74-
if value <= 0:
75-
raise serializers.ValidationError("Size must be positive.")
76-
77-
# ensure size is under MAX_FILE_SIZE_IN_MB
78-
if value > MAX_FILE_SIZE_IN_MB * 1024 * 1024:
79-
raise serializers.ValidationError(f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB).")
80-
81-
return value
82-
83-
84-
class ValidationTaskSerializer(BaseSerializer):
85-
8645
class Meta:
8746
model = ValidationTask
8847
fields = '__all__'
8948
show = ["public_id", "request_public_id"]
9049
hide = ["id", "process_id", "process_cmd", "request"]
9150

9251

93-
class ValidationOutcomeSerializer(BaseSerializer):
94-
52+
class ValidationOutcomeSerializer(PydanticValidatorMixin, BaseSerializer):
53+
9554
class Meta:
9655
model = ValidationOutcome
9756
fields = '__all__'
9857
show = ["public_id", "instance_public_id", "validation_task_public_id"]
9958
hide = ["id", "instance", "validation_task"]
10059

10160

102-
class ModelSerializer(BaseSerializer):
61+
class ModelSerializer(PydanticValidatorMixin, BaseSerializer):
10362

10463
class Meta:
10564
model = Model
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
"""
2+
Initial tests moving away from Django towards FastAPI/Pydantic
3+
"""
4+
import pytest
5+
from pydantic import ValidationError, TypeAdapter
6+
from django.core.files.uploadedfile import SimpleUploadedFile # to be deleted after making schema DjangoDRF/FastAPI agnostic
7+
8+
from apps.ifc_validation.api.v1.schemas import ValidationRequestIn
9+
from core.settings import MAX_FILE_SIZE_IN_MB
10+
11+
TA = TypeAdapter(ValidationRequestIn)
12+
13+
def make_uploaded(name: str, size: int) -> SimpleUploadedFile:
14+
return SimpleUploadedFile(
15+
name=name,
16+
content=b"\x00" * size,
17+
content_type="application/octet-stream",
18+
)
19+
20+
def test_valid_file_passes():
21+
f = make_uploaded("ok.ifc", 100)
22+
m = TA.validate_python({"file": f, "file_name": "ok.ifc", "size": 100})
23+
assert m.file_name == "ok.ifc"
24+
assert m.file.size == 100
25+
26+
def test_missing_file_raises():
27+
with pytest.raises(ValidationError) as e:
28+
TA.validate_python({"file": None, "file_name": "ok.ifc", "size": 1})
29+
assert "File is required." in str(e.value)
30+
31+
def test_bad_extension_raises():
32+
f = make_uploaded("bad.txt", 100)
33+
with pytest.raises(ValidationError) as e:
34+
TA.validate_python({"file": f, "file_name": "bad.txt", "size": 100})
35+
assert "File name must end with '.ifc'." in str(e.value)
36+
37+
def test_negative_size_raises():
38+
f = make_uploaded("ok.ifc", 100)
39+
with pytest.raises(ValidationError) as e:
40+
TA.validate_python({"file": f, "file_name": "ok.ifc", "size": -5})
41+
assert "Size must be positive." in str(e.value)
42+
43+
def test_too_large_file_raises():
44+
too_big = (MAX_FILE_SIZE_IN_MB + 1) * 1024 * 1024
45+
f = make_uploaded("ok.ifc", too_big)
46+
with pytest.raises(ValidationError) as e:
47+
TA.validate_python({"file": f, "file_name": "ok.ifc", "size": too_big})
48+
assert f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB)." in str(e.value)

backend/apps/ifc_validation/api/v1/views.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,7 +179,8 @@ def post(self, request, *args, **kwargs):
179179

180180
# only accept one file (for now) - note: can't be done easily in serializer,
181181
# as we need access to request.FILES and our model only accepts one file
182-
serializer.validate_files(files)
182+
if len(files) > 1:
183+
raise serializers.ValidationError({'file': 'Only one file can be uploaded at a time.'})
183184

184185
# retrieve file size and save
185186
uploaded_file = serializer.validated_data

0 commit comments

Comments
 (0)