Skip to content

Commit 7e2495a

Browse files
authored
Merge pull request #237 from buildingSMART/IVS-602_Stabilize_API_Format_Version_Serializers
IVS-602 - Stabilize API format version & serializers
2 parents 37136dc + 82b3c99 commit 7e2495a

File tree

12 files changed

+808
-184
lines changed

12 files changed

+808
-184
lines changed

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

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
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
89

910
class BaseSerializer(serializers.ModelSerializer):
1011

@@ -21,6 +22,9 @@ def get_field_names(self, declared_fields, info):
2122
expanded_fields = list(set(expanded_fields) - set(self.Meta.hide))
2223

2324
return expanded_fields
25+
26+
class Meta:
27+
abstract = True
2428

2529

2630
class ValidationRequestSerializer(BaseSerializer):
@@ -29,9 +33,53 @@ class Meta:
2933
model = ValidationRequest
3034
fields = '__all__'
3135
show = ["public_id", "model_public_id"]
32-
hide = ["id", "model", "deleted", "created_by", "updated_by"]
36+
hide = ["id", "model", "deleted", "created_by", "updated_by", "status_reason"]
3337
read_only_fields = ['size', 'created_by', 'updated_by']
3438

39+
def validate_file(self, value):
40+
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
50+
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+
3583

3684
class ValidationTaskSerializer(BaseSerializer):
3785

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
from django.urls import re_path
2+
3+
from .views import ValidationRequestListAPIView, ValidationRequestDetailAPIView
4+
from .views import ValidationTaskListAPIView, ValidationTaskDetailAPIView
5+
from .views import ValidationOutcomeListAPIView, ValidationOutcomeDetailAPIView
6+
from .views import ModelListAPIView, ModelDetailAPIView
7+
8+
9+
urlpatterns = [
10+
11+
# REST API
12+
# using re_path to make trailing slashes optional
13+
re_path(r'validationrequest/?$', ValidationRequestListAPIView.as_view()),
14+
re_path(r'validationrequest/(?P<id>[\w-]+)/?$', ValidationRequestDetailAPIView.as_view()),
15+
re_path(r'validationtask/?$', ValidationTaskListAPIView.as_view()),
16+
re_path(r'validationtask/(?P<id>[\w-]+)/?$', ValidationTaskDetailAPIView.as_view()),
17+
re_path(r'validationoutcome/?$', ValidationOutcomeListAPIView.as_view()),
18+
re_path(r'validationoutcome/(?P<id>[\w-]+)/?$', ValidationOutcomeDetailAPIView.as_view()),
19+
re_path(r'model/?$', ModelListAPIView.as_view()),
20+
re_path(r'model/(?P<id>[\w-]+)/?$', ModelDetailAPIView.as_view()),
21+
]

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

Lines changed: 34 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@
55

66
from django.db import transaction
77
from core.utils import get_client_ip_address
8-
from core.settings import MAX_FILES_PER_UPLOAD, MAX_FILE_SIZE_IN_MB
8+
from core.settings import MAX_FILES_PER_UPLOAD
99

10-
from rest_framework import status
10+
from rest_framework import status, serializers
1111
from rest_framework.generics import ListAPIView, ListCreateAPIView
1212
from rest_framework.parsers import FormParser, MultiPartParser
1313
from rest_framework.response import Response
@@ -20,13 +20,16 @@
2020
from drf_spectacular.utils import extend_schema
2121

2222
from apps.ifc_validation_models.models import set_user_context
23-
from apps.ifc_validation_models.models import ValidationRequest, ValidationTask, ValidationOutcome, Model
23+
from apps.ifc_validation_models.models import ValidationRequest
24+
from apps.ifc_validation_models.models import ValidationTask
25+
from apps.ifc_validation_models.models import ValidationOutcome
26+
from apps.ifc_validation_models.models import Model
2427

2528
from .serializers import ValidationRequestSerializer
2629
from .serializers import ValidationTaskSerializer
2730
from .serializers import ValidationOutcomeSerializer
2831
from .serializers import ModelSerializer
29-
from .tasks import ifc_file_validation_task
32+
from ...tasks import ifc_file_validation_task
3033

3134
logger = logging.getLogger(__name__)
3235

@@ -43,14 +46,14 @@ class ValidationRequestDetailAPIView(APIView):
4346
def get(self, request, id, *args, **kwargs):
4447

4548
"""
46-
Retrieves a single Validation Request by public_id.
49+
Retrieves a single Validation Request by (public) id.
4750
"""
4851

49-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
52+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
5053

5154
instance = ValidationRequest.objects.filter(created_by__id=request.user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first()
5255
if instance:
53-
serializer = ValidationRequestSerializer(instance)
56+
serializer = self.serializer_class(instance)
5457
return Response(serializer.data, status=status.HTTP_200_OK)
5558
else:
5659
data = {'message': f"Validation Request with public_id={id} does not exist for user with id={request.user.id}."}
@@ -63,7 +66,7 @@ def delete(self, request, id, *args, **kwargs):
6366
Deletes an IFC Validation Request instance by id.
6467
"""
6568

66-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
69+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
6770

6871
if request.user.is_authenticated:
6972
logger.info(f"Authenticated, user = {request.user.id}")
@@ -98,7 +101,7 @@ def get(self, request, *args, **kwargs):
98101
Returns a list of all Validation Requests.
99102
"""
100103

101-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
104+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
102105
return super().get(request, *args, **kwargs)
103106

104107
def get_queryset(self):
@@ -114,6 +117,10 @@ def get_queryset(self):
114117
# apply filter(s)
115118
pub_ids = [p.strip() for p in public_id.split(',') if p.strip()]
116119
priv_ids = [ValidationRequest.to_private_id(p) for p in pub_ids]
120+
121+
logger.info(f"pub_ids = {pub_ids}")
122+
logger.info(f"priv_ids = {priv_ids}")
123+
117124
qs = qs.filter(id__in=priv_ids)
118125

119126
return qs
@@ -125,7 +132,7 @@ def post(self, request, *args, **kwargs):
125132
Creates a new Validation Request instance.
126133
"""
127134

128-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
135+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
129136

130137
serializer = self.serializer_class(data=request.data)
131138
if serializer.is_valid():
@@ -144,10 +151,9 @@ def post(self, request, *args, **kwargs):
144151
if file_i is not None: files += file_i
145152
logger.info(f"Received {len(files)} file(s) - files: {files}")
146153

147-
# only accept one file (for now)
148-
if len(files) != 1:
149-
data = {'message': f"Only one file can be uploaded at a time."}
150-
return Response(data, status=status.HTTP_400_BAD_REQUEST)
154+
# only accept one file (for now) - note: can't be done easily in serializer,
155+
# as we need access to request.FILES and our model only accepts one file
156+
serializer.validate_files(files)
151157

152158
# retrieve file size and save
153159
uploaded_file = serializer.validated_data
@@ -158,23 +164,13 @@ def post(self, request, *args, **kwargs):
158164
file_name = uploaded_file['file_name']
159165
logger.info(f"file_length for uploaded file {file_name} = {file_length} ({file_length / (1024*1024)} MB)")
160166

161-
# check if file name ends with .ifc
162-
if not file_name.lower().endswith('.ifc'):
163-
data = {'file_name': "File name must end with '.ifc'."}
164-
return Response(data, status=status.HTTP_400_BAD_REQUEST)
165-
166-
# apply file size limit
167-
if file_length > MAX_FILE_SIZE_IN_MB * 1024 * 1024:
168-
data = {'message': f"File size exceeds allowed file size limit ({MAX_FILE_SIZE_IN_MB} MB)."}
169-
return Response(data, status=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE)
170-
171167
# can't use this, file hasn't been saved yet
172168
#file = os.path.join(MEDIA_ROOT, uploaded_file['file_name'])
173169
#uploaded_file['size'] = os.path.getsize(file)
174170
uploaded_file['size'] = file_length
175171
instance = serializer.save()
176172

177-
# # submit task for background execution
173+
# submit task for background execution
178174
def submit_task(instance):
179175
ifc_file_validation_task.delay(instance.id, instance.file_name)
180176
logger.info(f"Task 'ifc_file_validation_task' submitted for id:{instance.id} file_name: {instance.file_name})")
@@ -183,6 +179,10 @@ def submit_task(instance):
183179

184180
return Response(serializer.data, status=status.HTTP_201_CREATED)
185181

182+
except serializers.ValidationError as e:
183+
184+
return Response(e.detail, status=status.HTTP_400_BAD_REQUEST)
185+
186186
except Exception as e:
187187

188188
traceback.print_exc(file=sys.stdout)
@@ -202,10 +202,10 @@ class ValidationTaskDetailAPIView(APIView):
202202
def get(self, request, id, *args, **kwargs):
203203

204204
"""
205-
Retrieves a single Validation Task by public_id.
205+
Retrieves a single Validation Task by (public) id.
206206
"""
207207

208-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
208+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
209209

210210
instance = ValidationTask.objects.filter(request__created_by__id=request.user.id, request__deleted=False, id=ValidationTask.to_private_id(id)).first()
211211
if instance:
@@ -229,7 +229,7 @@ def get(self, request, *args, **kwargs):
229229
Returns a list of all Validation Tasks, optionally filtered by request_public_id.
230230
"""
231231

232-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' %(get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
232+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
233233
return super().get(request, *args, **kwargs)
234234

235235
def get_queryset(self):
@@ -261,10 +261,10 @@ class ValidationOutcomeDetailAPIView(APIView):
261261
def get(self, request, id, *args, **kwargs):
262262

263263
"""
264-
Retrieves a single Validation Outcome by public_id.
264+
Retrieves a single Validation Outcome by (public) id.
265265
"""
266266

267-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
267+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
268268

269269
instance = ValidationOutcome.objects.filter(validation_task__request__created_by__id=request.user.id, validation_task__request__deleted=False, id=ValidationOutcome.to_private_id(id)).first()
270270
if instance:
@@ -287,7 +287,7 @@ def get(self, request, *args, **kwargs):
287287
Returns a list of all Validation Outcomes, optionally filtered by request_public_id or validation_task_public_id.
288288
"""
289289

290-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
290+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
291291
return super().get(request, *args, **kwargs)
292292

293293
def get_queryset(self):
@@ -315,8 +315,6 @@ def priv_ids(param, prefix, to_priv):
315315
return qs
316316

317317

318-
319-
320318
class ModelDetailAPIView(APIView):
321319

322320
queryset = Model.objects.all()
@@ -328,10 +326,10 @@ class ModelDetailAPIView(APIView):
328326
def get(self, request, id, *args, **kwargs):
329327

330328
"""
331-
Retrieves a single Model by public_id.
329+
Retrieves a single Model by (public) id.
332330
"""
333331

334-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
332+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
335333

336334
instance = Model.objects.filter(request__created_by__id=request.user.id, request__deleted=False, id=Model.to_private_id(id)).first()
337335
if instance:
@@ -354,7 +352,7 @@ def get(self, request, *args, **kwargs):
354352
Returns a list of all Models, optionally filtered by request_public_id.
355353
"""
356354

357-
logger.info('API request - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
355+
logger.info('API request v%s - User IP: %s Request Method: %s Request URL: %s Content-Length: %s' % (self.request.version, get_client_ip_address(request), request.method, request.path, request.META.get('CONTENT_LENGTH')))
358356
return super().get(request, *args, **kwargs)
359357

360358
def get_queryset(self):
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
from django.urls import path
2+
from . import chart_views as charts
3+
4+
5+
urlpatterns = [
6+
7+
# Django Admin charts
8+
path("filter-options/", charts.get_filter_options),
9+
path("requests/<int:year>/", charts.get_requests_chart),
10+
path("duration-per-request/<int:year>/", charts.get_duration_per_request_chart),
11+
path("duration-per-task/<int:year>/", charts.get_duration_per_task_chart),
12+
path("uploads-per-2h/<int:year>/", charts.get_uploads_per_2h_chart),
13+
path("processing-status/<int:year>/", charts.get_processing_status_chart),
14+
path("avg-size/<int:year>/", charts.get_avg_size_chart),
15+
path("user-registrations/<int:year>/", charts.get_user_registrations_chart),
16+
path("usage-by-vendor/<int:year>/", charts.get_usage_by_vendor_chart),
17+
path("models-by-vendor/<int:year>/", charts.get_models_by_vendor_chart),
18+
path("top-tools/<int:year>/", charts.get_top_tools_chart),
19+
path("tools-count/<int:year>/", charts.get_tools_count_chart),
20+
path("totals/", charts.get_totals),
21+
path("queue-p95/<int:year>/", charts.get_queue_p95_chart),
22+
path("stuck-per-day/<int:year>/", charts.get_stuck_per_day_chart),
23+
path("uploads-per-weekday/<int:year>/", charts.get_uploads_per_weekday_chart),
24+
]

backend/apps/ifc_validation/chart_views.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -418,17 +418,17 @@ def get_duration_per_task_chart(request, year):
418418
seconds = row["avg_duration"].total_seconds() if row["avg_duration"] else 0
419419
task_data[task_type][period_label] += round(seconds, 2)
420420

421-
labels = list(task_data[next(iter(task_data))].keys()) # labels from any type
421+
labels = list(next(iter(task_data.values())).keys()) if task_data else []
422422

423423
datasets = [
424-
{
425-
"label": TASK_TYPES[t][0],
426-
"backgroundColor": TASK_TYPES[t][1],
427-
"borderColor": COLORS["primary"],
428-
"data": [task_data[t][lbl] for lbl in labels],
429-
}
430-
for t in task_data
431-
]
424+
{
425+
"label": TASK_TYPES[t][0],
426+
"backgroundColor": TASK_TYPES[t][1],
427+
"borderColor": COLORS["primary"],
428+
"data": [task_data[t][lbl] for lbl in labels],
429+
}
430+
for t in task_data
431+
]
432432

433433
return chart_response(
434434
title=f"Duration per Task in {year}",

backend/apps/ifc_validation/templates/admin/ifc_validation_models/app_index.html

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -237,7 +237,7 @@ <h3>${title}</h3>
237237
const windowVal = $window.val();
238238

239239
chartDefs.forEach(def => {
240-
let url = `/api/chart/${def.endpoint}/${year}/?period=${period}`;
240+
let url = `/api/charts/${def.endpoint}/${year}/?period=${period}`;
241241
if (windowVal) { // send only if entered N
242242
url += `&window=${windowVal}`;
243243
}
@@ -250,10 +250,10 @@ <h3>${title}</h3>
250250
// ------------------------------------------------------------------
251251
$(function () {
252252
// Populate year dropdown
253-
$.getJSON("/api/chart/filter-options/", ({ options }) => {
253+
$.getJSON("/api/charts/filter-options/", ({ options }) => {
254254
const $year = $("#year");
255255
options.forEach(opt => $year.append(new Option(opt, opt)));
256-
$.getJSON("/api/chart/totals/", data => {
256+
$.getJSON("/api/charts/totals/", data => {
257257
$("#totals").html(
258258
`<strong>${data.users}</strong> users&nbsp;&nbsp;|&nbsp;&nbsp;` +
259259
`<strong>${data.files}</strong> files processed&nbsp;&nbsp;|&nbsp;&nbsp;` +

0 commit comments

Comments
 (0)