Skip to content

Commit 1c2abe3

Browse files
committed
Merge branch 'development' into docs/gh-pages
2 parents c8c7a44 + b0f545f commit 1c2abe3

13 files changed

Lines changed: 221 additions & 148 deletions

File tree

Makefile

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,18 @@ start-swarm-local:
4949
stop-swarm:
5050
docker stack rm validate
5151

52+
redeploy-swarm:
53+
$(MAKE) stop-swarm
54+
@echo "Waiting 15s for overlay network cleanup..."
55+
sleep 15
56+
$(MAKE) swarm-push ENV_FILE=$(ENV_FILE)
57+
$(MAKE) start-swarm-nodb ENV_FILE=$(ENV_FILE)
58+
59+
redeploy-frontend: rebuild-frontend
60+
docker tag buildingsmart/validationsvc-frontend $(REGISTRY)/validationsvc-frontend
61+
docker push $(REGISTRY)/validationsvc-frontend
62+
docker service update --force --image $(REGISTRY)/validationsvc-frontend validate_frontend
63+
5264
scale-workers:
5365
docker service scale validate_worker=$(WORKERS)
5466

@@ -121,7 +133,7 @@ rebuild: clean
121133
rebuild-frontend:
122134
docker stop frontend || true
123135
docker rmi --force $$(docker images -q 'buildingsmart/validationsvc-frontend:latest' | uniq) || true
124-
docker compose build \
136+
docker compose build frontend \
125137
--build-arg GIT_COMMIT_HASH="$$(git rev-parse --short HEAD)" \
126138
--build-arg VERSION="$$(cat .VERSION)"
127139

backend/Makefile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ install-macos: venv
3030
install-macos-m1: venv
3131
find . -name 'requirements.txt' -exec $(PIP) install -r {} \;
3232
$(PIP) install -r requirements.txt
33-
wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.5-1c5b825-macosm164.zip"
33+
wget -O /tmp/ifcopenshell_python.zip "https://s3.amazonaws.com/ifcopenshell-builds/ifcopenshell-python-311-v0.8.5-7bdc1b6-macosm164.zip"
3434
mkdir -p $(VIRTUAL_ENV)/lib/python3.11/site-packages
3535
unzip /tmp/ifcopenshell_python.zip -d $(VIRTUAL_ENV)/lib/python3.11/site-packages
3636
rm /tmp/ifcopenshell_python.zip

backend/apps/ifc_validation/admin.py

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import logging
2-
from datetime import timedelta
32

43
from django.urls import path
54
from django.contrib import admin
@@ -91,7 +90,12 @@ class ValidationRequestAdmin(BaseAdmin, NonAdminAddable):
9190
actions_on_top = True
9291

9392
def get_queryset(self, request):
94-
queryset = super().get_queryset(request)
93+
queryset = super().get_queryset(request).select_related(
94+
'model__produced_by',
95+
'model__produced_by__company',
96+
'created_by__useradditionalinfo',
97+
'updated_by__useradditionalinfo'
98+
)
9599
queryset = queryset.annotate(
96100
_duration=Case(
97101
When(completed__isnull=True, then=Now() - F('started')),
@@ -376,11 +380,13 @@ class ValidationTaskAdmin(BaseAdmin, NonAdminAddable):
376380

377381
list_display = ["id", "public_id", "request", "type", "status", "progress", "started", "ended", "duration_text", "created", "updated"]
378382
readonly_fields = ["id", "public_id", "request", "type", "process_id", "process_cmd", "started", "ended", "created", "updated"]
379-
date_hierarchy = "created"
380383

381384
list_filter = ["status", "type", "status", "started", "ended", ('created', AdvancedDateFilter)]
382385
search_fields = ('request__file_name', 'status', 'type')
383386

387+
paginator = utils.LargeTablePaginator
388+
show_full_result_count = False # do not use COUNT(*) twice
389+
384390
def get_queryset(self, request):
385391
queryset = super().get_queryset(request)
386392
queryset = queryset.annotate(
@@ -442,14 +448,20 @@ class ValidationOutcomeAdmin(BaseAdmin, NonAdminAddable):
442448

443449
list_display = ["id", "public_id", "model_text", "instance_id", "type_text", "feature", "feature_version", "outcome_code", "severity", "is_whitelisted", "expected", "observed", "created", "updated"]
444450
readonly_fields = ["id", "public_id", "created", "updated"]
445-
date_hierarchy = "created"
446-
447-
list_filter = ['validation_task__type', 'severity_in_db', 'validation_task__request__model', 'outcome_code', 'feature', ('created', AdvancedDateFilter)]
451+
452+
list_filter = ['validation_task__type', 'severity_in_db', 'outcome_code', ('created', AdvancedDateFilter)]
448453
search_fields = ('validation_task__request__file_name', 'feature', 'feature_version', 'outcome_code', 'severity_in_db', 'expected', 'observed')
449454

450455
paginator = utils.LargeTablePaginator
451456
show_full_result_count = False # do not use COUNT(*) twice
452457

458+
# optimize for list display and filters
459+
def get_queryset(self, request):
460+
return super().get_queryset(request).select_related(
461+
'validation_task__request__model',
462+
'validation_task'
463+
)
464+
453465
@admin.display(description="Model")
454466
def model_text(self, obj):
455467
return obj.validation_task.request.model
@@ -833,13 +845,13 @@ class VersionAdmin(BaseAdmin):
833845
search_fields = ("name", "released", "release_notes")
834846

835847

836-
class WhiteListQueryFragmentInline(admin.TabularInline):
848+
class WhiteListQueryFragmentInlineAdmin(admin.TabularInline):
837849
model = WhiteListQueryFragment
838850
extra = 1
839851

840-
@admin.register(WhiteListEntry)
852+
841853
class WhiteListEntryAdmin(BaseAdmin):
842-
inlines = [WhiteListQueryFragmentInline]
854+
inlines = [WhiteListQueryFragmentInlineAdmin]
843855

844856
readonly_fields = ["id", "created", "updated"]
845857
fieldsets = [
@@ -916,6 +928,7 @@ def get_row(field):
916928
)
917929
return TemplateResponse(request, "admin/whitelistentry_test_outcome.html", context)
918930

931+
919932
class WhiteListTestForm(forms.Form):
920933
whitelist_entry = forms.ModelChoiceField(
921934
queryset=WhiteListEntry.objects.all(),
@@ -937,6 +950,7 @@ class WhiteListTestForm(forms.Form):
937950
admin.site.register(Company, CompanyAdmin)
938951
admin.site.register(AuthoringTool, AuthoringToolAdmin)
939952
admin.site.register(Version, VersionAdmin)
953+
admin.site.register(WhiteListEntry, WhiteListEntryAdmin)
940954

941955
admin.site.unregister(User)
942956
admin.site.register(User, CustomUserAdmin)

backend/apps/ifc_validation/chart_views.py

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,15 @@
5454
}
5555

5656

57-
DEV_TEAM_EMAILS = {
58-
"hesselinkgeert@gmail.com",
59-
"geert.hess@gmail.com",
60-
"geertparallels@gmail.com",
61-
"t.krijnen@gmail.com",
62-
"rw-bsi@xeneo.biz",
63-
"raphael@xeneo.biz",
64-
"raphael.wouters@gmail.com",
65-
"evandro.alfieri@buildingsmart.org",
66-
"evandroalfieri@gmail.com",
67-
}
68-
6957
def _dev_team_ids() -> set[int]:
7058
"""
7159
Return a set of user IDs that should be classified as 'dev-team'.
7260
73-
Defined by DEV_TEAM_EMAILS. Keeps classification separate from is_vendor.
61+
Defined by User.is_staff. Manage membership via Django admin.
7462
"""
7563
return set(
7664
User.objects
77-
.filter(email__in=DEV_TEAM_EMAILS)
65+
.filter(is_staff=True)
7866
.values_list("id", flat=True)
7967
)
8068

backend/core/settings.py

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -246,10 +246,14 @@
246246
"USER": os.environ.get("POSTGRES_USER", "postgres"),
247247
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", "postgres"),
248248
"PORT": int(os.environ.get("POSTGRES_PORT", "5432")),
249-
"CONN_MAX_AGE": int(os.environ.get("POSTGRES_CONN_MAX_AGE", 600)),
249+
"CONN_MAX_AGE": 0, # must be 0 when using pool; pool handles lifecycle via max_lifetime
250250
"CONN_HEALTH_CHECKS": True,
251251
"OPTIONS": {
252-
"pool": False,
252+
"pool": {
253+
"min_size": 2,
254+
"max_size": 10,
255+
"max_lifetime": 600, # recycle connections before overlay network kills them (~13 min)
256+
},
253257
},
254258
},
255259
}
@@ -288,7 +292,7 @@
288292
TIME_ZONE = "UTC"
289293
USE_I18N = True
290294
USE_TZ = True
291-
295+
USE_THOUSAND_SEPARATOR = True
292296

293297
# Static files (CSS, JavaScript, Images)
294298
# https://docs.djangoproject.com/en/4.2/howto/static-files/

backend/core/utils.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -158,17 +158,31 @@ def count(self):
158158
# print('db pk = ', self.object_list.first()._meta.pk.name)
159159

160160
with transaction.atomic(), connection.cursor() as cursor:
161-
cursor.execute('SET LOCAL statement_timeout TO 25000;') # 25 seconds timeout
162161
try:
163-
# workaround for Postgres well-documented slow count(*) performance
164162
table = self.object_list.first()._meta.db_table
165163
id = self.object_list.first()._meta.pk.name
166-
cursor.execute(f'SELECT COUNT(distinct {id}) FROM {table}')
167-
row = cursor.fetchone()
168-
return row[0]
164+
count = 0
165+
166+
# workarounds for Postgres well-documented slow count(*) performance
167+
if connection.vendor == 'postgresql':
168+
cursor.execute(f'SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = \'{table}\'') # estimation
169+
row = cursor.fetchone()
170+
count = row[0]
171+
if count < 1*1000*1000:
172+
cursor.execute(f'SELECT COUNT(distinct {id}) FROM {table}') # exact per pk
173+
row = cursor.fetchone()
174+
count = row[0]
175+
176+
else:
177+
cursor.execute(f'SELECT COUNT(*) FROM {table}')
178+
row = cursor.fetchone()
179+
count = row[0]
180+
181+
return count
169182

170183
except OperationalError:
171-
return 9999999999 # naive guess
184+
185+
return 9999999999 # naive guess in case of timeout/error
172186

173187
except AttributeError:
174188
return 0

docker/frontend/nginx/default.conf.template

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,24 @@ server {
3636
try_files $uri @proxy_api;
3737
}
3838

39+
# Swagger/ReDoc — relaxed CSP for inline scripts, proxied directly
40+
location ~ ^/api/v1/(swagger-ui|redoc) {
41+
resolver 127.0.0.11 valid=30s;
42+
set $upstream http://backend:8000;
43+
add_header X-Content-Type-Options nosniff always;
44+
add_header X-Frame-Options DENY always;
45+
add_header Referrer-Policy 'same-origin' always;
46+
add_header Content-Security-Policy "script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; frame-ancestors 'none'" always;
47+
proxy_read_timeout 500s;
48+
proxy_connect_timeout 75s;
49+
proxy_pass $upstream;
50+
proxy_redirect off;
51+
proxy_set_header Host $http_host;
52+
proxy_set_header X-Real-IP $remote_addr;
53+
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
54+
proxy_set_header X-Forwarded-Proto $scheme;
55+
}
56+
3957
# API (Django APP)
4058
location /api {
4159
try_files $uri @proxy_api;
@@ -72,10 +90,13 @@ server {
7290

7391
# Django backend (API + BFF + Admin)
7492
location @proxy_api {
93+
resolver 127.0.0.11 valid=30s;
94+
set $upstream http://backend:8000;
95+
7596
proxy_read_timeout 500s;
7697
proxy_connect_timeout 75s;
7798

78-
proxy_pass http://backend:8000;
99+
proxy_pass $upstream;
79100
proxy_redirect off;
80101

81102
proxy_set_header Host $http_host;

docs/dev/api_quickstart.md

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ formats.
1313

1414
## Auth token
1515

16-
You will need an Authentication token before making calls to the API,
16+
You will need an Authentication token before making calls to the API,
1717
which can be obtained by emailing [validate@buildingsmart.org](mailto:validate@buildingsmart.org).
1818

1919
You can use this token either as a Bearer token or use it as the password in combination with your username/email for basic authentication.
@@ -26,13 +26,14 @@ You can use this token either as a Bearer token or use it as the password in com
2626
curl -X GET --location 'https://dev.validate.buildingsmart.org/api/v1/validationrequest' --header 'Authorization: Token <TOKEN>'
2727
```
2828

29-
-or-
29+
-or-
3030

3131
```shell
3232
curl -X GET --location 'https://dev.validate.buildingsmart.org/api/v1/validationrequest' --header 'Authorization: Basic <HASH>'
33+
curl -X GET --location 'https://dev.validate.buildingsmart.org/api/validationrequest/' --header 'Authorization: Basic <HASH>'
3334
```
3435

35-
where `<HASH>` is the Base64-encoded email and token as password, separated by a colon, eg. base64(johndoe@gmail.com:abcdefgh12345)
36+
where `<HASH>` is the Base64-encoded email and token as password, separated by a colon, eg. base64(johndoe@gmail.com:abcdefgh12345)
3637

3738
2. Submit a POST request to the `/validationrequest` endpoint to initiate a new Validation Request (requires a file name and the file contents):
3839

@@ -65,7 +66,7 @@ You can use this token either as a Bearer token or use it as the password in com
6566
```shell
6667
curl -X GET --location 'https://dev.validate.buildingsmart.org/api/v1/validationtask?request_public_id=r75257132,r383446691' --header 'Authorization: Token <TOKEN>'
6768
```
68-
69+
6970
6. Fetch all the outcomes of a single ValidationRequest via a GET request to the `/validationoutcome` endpoint
7071

7172
```shell

docs/ref/index.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,3 +35,8 @@ The responses to these inquiries are provided here for the benefit of the entire
3535
:heading-offset: 1
3636
:relative-images:
3737
```
38+
39+
```{include} ./normative-rules/BRP003.md
40+
:heading-offset: 1
41+
:relative-images:
42+
```

docs/ref/normative-rules/BRP003.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# BRP003
2+
3+
This rule confirms that geometric faces indicated as being planar
4+
do actually lie on the same plane.
5+
6+
## Approach
7+
8+
1. Derive the plane from the outer boundary; uses Newell’s method for the normal and compute d from the average of the input points.
9+
2. Validate outer and inner boundaries by projecting their points onto that plane and checking that each projection distance is within the representation context precision.
10+
11+
```{note}
12+
All calculations are performed in 128-bit floating point.
13+
```

0 commit comments

Comments
 (0)