diff --git a/.env b/.env index 894285a..bacde9f 100644 --- a/.env +++ b/.env @@ -7,8 +7,9 @@ PUBLIC_URL = https://dev.validate.buildingsmart.org # Django MEDIA_ROOT = /files_storage DJANGO_DB = postgresql +DJANGO_SECRET_KEY = django-insecure-um7-^+&jbk_=80*xcc9uf4nh$4koida7)ja&6!vb*$8@n288jk DJANGO_ALLOWED_HOSTS = dev.validate.buildingsmart.org -DJANGO_CSRF_TRUSTED_ORIGINS = https://dev.validate.buildingsmart.org https://authentication.buildingsmart.org +DJANGO_TRUSTED_ORIGINS = https://dev.validate.buildingsmart.org https://authentication.buildingsmart.org DJANGO_LOG_LEVEL = INFO # DB diff --git a/README.md b/README.md index aa13828..e984186 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,9 @@ *(Work In Progress - dev-v0.6-alpha)* +# Software Infrastructure + +![image](https://github.com/buildingSMART/validate/assets/155643707/5286c847-cf2a-478a-8940-fcdbd6fffeea) + # Application Structure @@ -58,14 +62,13 @@ or docker compose up ``` -3. This pulls Docker-hub images, builds and spins up **six** different services: +3. This pulls Docker-hub images, builds and spins up **five** different services: ``` db - PostgreSQL database redis - Redis instance backend - Django Admin + API's worker - Celery worker -flower - Celery flower dashboard frontend - React UI ``` @@ -89,7 +92,6 @@ exit - Django Admin UI: http://localhost/admin (or http://localhost:8000/admin) - default user/password: root/root - Django API - Swagger: http://localhost/api/swagger-ui - Django API - Redoc: http://localhost/api/redoc -- Celery Flower UI: http://localhost:5555 6. Optionally, use a tool like curl or Postman to invoke API requests directly @@ -157,4 +159,4 @@ DJANGO_SUPERUSER_USERNAME=SYSTEM DJANGO_SUPERUSER_PASSWORD=system DJANGO_SUPERUS - Django API - Redoc: http://localhost:8000/api/redoc - Celery Flower UI: http://localhost:5555 -9. Optionally, use a tool like curl or Postman to invoke API requests directly \ No newline at end of file +9. Optionally, use a tool like curl or Postman to invoke API requests directly diff --git a/backend/.env b/backend/.env index d4a3e71..2855afb 100644 --- a/backend/.env +++ b/backend/.env @@ -16,7 +16,7 @@ PUBLIC_URL = http://localhost:3000 MEDIA_ROOT = .dev/files_storage DJANGO_DB = postgresql TEST_DJANGO_DB = sqlite -DJANGO_CSRF_TRUSTED_ORIGINS = http://localhost:3000 http://localhost +DJANGO_TRUSTED_ORIGINS = http://localhost:3000 http://localhost http://localhost:8000 DJANGO_LOG_FOLDER = .dev/logging DJANGO_LOG_LEVEL = INFO diff --git a/backend/apps/ifc_validation/admin.py b/backend/apps/ifc_validation/admin.py index 7e394fb..41bc122 100644 --- a/backend/apps/ifc_validation/admin.py +++ b/backend/apps/ifc_validation/admin.py @@ -2,8 +2,12 @@ from datetime import timedelta from django.contrib import admin +from django.contrib import messages from django.contrib.auth import get_permission_codename from django.contrib.auth.models import User +from django.http import HttpResponseRedirect +from django.shortcuts import render +from django.utils.translation import ngettext from core import utils from apps.ifc_validation_models.models import ValidationRequest, ValidationTask, ValidationOutcome @@ -27,22 +31,30 @@ def save_model(self, request, obj, form, change): super().save_model(request, obj, form, change) -class ValidationRequestAdmin(BaseAdmin): +class NonAdminAddable(admin.ModelAdmin): + + def has_add_permission(self, request): + + # disable add via Admin ('+ Add' button) + return False + + +class ValidationRequestAdmin(BaseAdmin, NonAdminAddable): fieldsets = [ - ('General Information', {"classes": ("wide"), "fields": ["id", "file_name", "file", "file_size_text"]}), + ('General Information', {"classes": ("wide"), "fields": ["id", "public_id", "file_name", "file", "file_size_text", "deleted"]}), ('Status Information', {"classes": ("wide"), "fields": ["status", "status_reason", "progress"]}), ('Auditing Information', {"classes": ("wide"), "fields": [("created", "created_by"), ("updated", "updated_by")]}) ] - list_display = ["id", "file_name", "file_size_text", "status", "progress", "duration_text", "created", "created_by", "updated", "updated_by"] - readonly_fields = ["id", "file_name", "file", "file_size_text", "duration", "duration_text", "created", "created_by", "updated", "updated_by"] + list_display = ["id", "public_id", "file_name", "file_size_text", "status", "progress", "duration_text", "created", "created_by", "updated", "updated_by", "is_deleted"] + readonly_fields = ["id", "public_id", "deleted", "file_name", "file", "file_size_text", "duration", "duration_text", "created", "created_by", "updated", "updated_by"] date_hierarchy = "created" - list_filter = ["status", "created_by", "created", "updated"] + list_filter = ["status", "deleted", "created_by", "created", "updated"] search_fields = ('file_name', 'status', 'created_by__username', 'updated_by__username') - actions = ["mark_as_failed_action", "restart_processing_action"] + actions = ["soft_delete_action", "soft_restore_action", "mark_as_failed_action", "restart_processing_action", "hard_delete_action"] actions_on_top = True @admin.display(description="Duration (sec)") @@ -56,11 +68,89 @@ def duration_text(self, obj): else: return None + @admin.display(description="Deleted ?") + def is_deleted(self, obj): + + return ("Yes" if obj.deleted else "No") + @admin.display(description="File Size") def file_size_text(self, obj): return utils.format_human_readable_file_size(obj.size) + @admin.action( + description="Permanently delete selected Validation Requests", + permissions=["hard_delete"] + ) + def hard_delete_action(self, request, queryset): + + if 'apply' in request.POST: + + for obj in queryset: + obj.hard_delete() + + self.message_user( + request, + ngettext( + "%d Validation Request was successfully deleted.", + "%d Validation Requests were successfully deleted.", + len(queryset), + ) + % len(queryset), + messages.SUCCESS, + ) + return HttpResponseRedirect(request.get_full_path()) + + return render(request, 'admin/hard_delete_intermediate.html', context={'val_requests': queryset, 'entity_name': 'Validation Request(s)'}) + + @admin.action( + description="Soft-delete selected Validation Requests", + permissions=["soft_delete"] + ) + def soft_delete_action(self, request, queryset): + # TODO: move to middleware component? + if request.user.is_authenticated: + logger.info(f"Authenticated, user.id = {request.user.id}") + set_user_context(request.user) + + for obj in queryset: + obj.soft_delete() + + self.message_user( + request, + ngettext( + "%d Validation Request was successfully marked as deleted.", + "%d Validation Requests were successfully marked as deleted.", + len(queryset), + ) + % len(queryset), + messages.SUCCESS, + ) + + @admin.action( + description="Soft-restore selected Validation Requests", + permissions=["soft_restore"] + ) + def soft_restore_action(self, request, queryset): + # TODO: move to middleware component? + if request.user.is_authenticated: + logger.info(f"Authenticated, user.id = {request.user.id}") + set_user_context(request.user) + + for obj in queryset: + obj.undo_delete() + + self.message_user( + request, + ngettext( + "%d Validation Request was successfully marked as restored.", + "%d Validation Requests were successfully marked as restored.", + len(queryset), + ) + % len(queryset), + messages.SUCCESS, + ) + @admin.action( description="Mark selected Validation Requests as Failed", permissions=["change_status"] @@ -70,6 +160,7 @@ def mark_as_failed_action(self, request, queryset): if request.user.is_authenticated: logger.info(f"Authenticated, user.id = {request.user.id}") set_user_context(request.user) + queryset.update(status=ValidationRequest.Status.FAILED) @admin.action( @@ -90,26 +181,47 @@ def restart_processing_action(self, request, queryset): ifc_file_validation_task.delay(obj.id, obj.file_name) logger.info(f"Task 'ifc_file_validation_task' re-submitted for id:{obj.id} file_name: {obj.file_name}") + def get_actions(self, request): + + actions = super().get_actions(request) + + # remove default 'delete' action from list + if 'delete_selected' in actions: + del actions['delete_selected'] + + return actions + def has_change_status_permission(self, request): - """ - Does the user have the 'change status' permission? - """ opts = self.opts codename = get_permission_codename("change_status", opts) return request.user.has_perm("%s.%s" % (opts.app_label, codename)) + + def has_hard_delete_permission(self, request): + + opts = self.opts + codename = get_permission_codename("delete", opts) + return request.user.has_perm("%s.%s" % (opts.app_label, codename)) + def has_soft_delete_permission(self, request): -class ValidationTaskAdmin(BaseAdmin): + return self.has_hard_delete_permission(request) + + def has_soft_restore_permission(self, request): + + return self.has_soft_delete_permission(request) + + +class ValidationTaskAdmin(BaseAdmin, NonAdminAddable): fieldsets = [ - ('General Information', {"classes": ("wide"), "fields": ["id", "request", "type", "process_id", "process_cmd"]}), + ('General Information', {"classes": ("wide"), "fields": ["id", "public_id", "request", "type", "process_id", "process_cmd"]}), ('Status Information', {"classes": ("wide"), "fields": ["status", "status_reason", "progress", "started", "ended", "duration"]}), ('Auditing Information', {"classes": ("wide"), "fields": ["created", "updated"]}) ] - list_display = ["id", "request", "type", "status", "progress", "started", "ended", "duration_text", "created", "updated"] - readonly_fields = ["id", "request", "type", "process_id", "process_cmd", "started", "ended", "duration", "created", "updated"] + list_display = ["id", "public_id", "request", "type", "status", "progress", "started", "ended", "duration_text", "created", "updated"] + readonly_fields = ["id", "public_id", "request", "type", "process_id", "process_cmd", "started", "ended", "duration", "created", "updated"] date_hierarchy = "created" list_filter = ["status", "type", "status", "started", "ended", "created", "updated"] @@ -130,10 +242,10 @@ def duration_text(self, obj): return None -class ValidationOutcomeAdmin(BaseAdmin): +class ValidationOutcomeAdmin(BaseAdmin, NonAdminAddable): - list_display = ["id", "file_name_text", "type_text", "instance_id", "feature", "feature_version", "outcome_code", "severity", "expected", "observed", "created", "updated"] - readonly_fields = ["id", "created", "updated"] + list_display = ["id", "public_id", "file_name_text", "type_text", "instance_id", "feature", "feature_version", "outcome_code", "severity", "expected", "observed", "created", "updated"] + readonly_fields = ["id", "public_id", "created", "updated"] list_filter = ['validation_task__type', 'severity', 'outcome_code'] search_fields = ('validation_task__request__file_name', 'feature', 'feature_version', 'outcome_code', 'severity', 'expected', 'observed') @@ -147,10 +259,10 @@ def type_text(self, obj): return obj.validation_task.type -class ModelAdmin(BaseAdmin): +class ModelAdmin(BaseAdmin, NonAdminAddable): - list_display = ["id", "file_name", "size_text", "date", "schema", "mvd", "nbr_of_elements", "nbr_of_geometries", "nbr_of_properties", "produced_by", "created", "updated"] - readonly_fields = ["id", "file", "file_name", "size", "size_text", "date", "schema", "mvd", "number_of_elements", "number_of_geometries", "number_of_properties", "produced_by", "created", "updated"] + list_display = ["id", "public_id", "file_name", "size_text", "date", "schema", "mvd", "nbr_of_elements", "nbr_of_geometries", "nbr_of_properties", "produced_by", "created", "updated"] + readonly_fields = ["id", "public_id", "file", "file_name", "size", "size_text", "date", "schema", "mvd", "number_of_elements", "number_of_geometries", "number_of_properties", "produced_by", "created", "updated"] search_fields = ('file_name', 'schema', 'mvd', 'produced_by__name', 'produced_by__version') @@ -175,9 +287,9 @@ def size_text(self, obj): return utils.format_human_readable_file_size(obj.size) -class ModelInstanceAdmin(BaseAdmin): +class ModelInstanceAdmin(BaseAdmin, NonAdminAddable): - list_display = ["id", "stepfile_id", "model", "ifc_type", "created", "updated"] + list_display = ["id", "public_id", "stepfile_id", "model", "ifc_type", "created", "updated"] search_fields = ('stepfile_id', 'model__file_name', 'ifc_type') diff --git a/backend/apps/ifc_validation/checks/ifc_gherkin_rules b/backend/apps/ifc_validation/checks/ifc_gherkin_rules index 561cf2c..0efe589 160000 --- a/backend/apps/ifc_validation/checks/ifc_gherkin_rules +++ b/backend/apps/ifc_validation/checks/ifc_gherkin_rules @@ -1 +1 @@ -Subproject commit 561cf2c027ae4bf6a28d69ff85c6d1cab7beedff +Subproject commit 0efe589c7f1dba7a1095a4c61aee0ac934a0b16d diff --git a/backend/apps/ifc_validation/email_tasks.py b/backend/apps/ifc_validation/email_tasks.py index e109565..128292a 100644 --- a/backend/apps/ifc_validation/email_tasks.py +++ b/backend/apps/ifc_validation/email_tasks.py @@ -145,7 +145,7 @@ def send_completion_email_task(id, file_name): # load and merge email template merge_data = { 'FILE_NAME': file_name, - 'ID': id, + 'ID': request.public_id, 'STATUS_SYNTAX': ("p" if (request.model is None or request.model.status_syntax is None) else request.model.status_syntax) in ['v', 'w', 'i'], "STATUS_SCHEMA": status_combine( "p" if (request.model is None or request.model.status_schema is None) else request.model.status_schema, diff --git a/backend/apps/ifc_validation/fixtures/invalid_xss_file_missing_apostrophe.ifc b/backend/apps/ifc_validation/fixtures/invalid_xss_file_missing_apostrophe.ifc new file mode 100644 index 0000000..5dc6537 --- /dev/null +++ b/backend/apps/ifc_validation/fixtures/invalid_xss_file_missing_apostrophe.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition []'),'2;1'); +FILE_NAME('.ifc','2023-12-16T18:20:00',(''),(''),'-0.7.0','-0.7.0',''); +FILE_SCHEMA((IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; \ No newline at end of file diff --git a/backend/apps/ifc_validation/fixtures/malicious_file.ifc b/backend/apps/ifc_validation/fixtures/malicious_file.ifc new file mode 100644 index 0000000..b403b64 --- /dev/null +++ b/backend/apps/ifc_validation/fixtures/malicious_file.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition []'),'2;1'); +FILE_NAME('.ifc','2023-12-16T18:20:00',(''),(''),'-0.7.0','-0.7.0',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($,$,'',$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; \ No newline at end of file diff --git a/backend/apps/ifc_validation/fixtures/malicious_file2.ifc b/backend/apps/ifc_validation/fixtures/malicious_file2.ifc new file mode 100644 index 0000000..bb1d484 --- /dev/null +++ b/backend/apps/ifc_validation/fixtures/malicious_file2.ifc @@ -0,0 +1,10 @@ +ISO-10303-21; +HEADER; +FILE_DESCRIPTION(('ViewDefinition []'),'2;1'); +FILE_NAME('test.ifc','2023-12-16T18:20:00',(''),(''),'-0.1.0','Test-0.1.0',''); +FILE_SCHEMA(('IFC4')); +ENDSEC; +DATA; +#1=IFCPERSON($, '','',$,$,$,$,$); +ENDSEC; +END-ISO-10303-21; \ No newline at end of file diff --git a/backend/apps/ifc_validation/serializers.py b/backend/apps/ifc_validation/serializers.py index addb257..19c135c 100644 --- a/backend/apps/ifc_validation/serializers.py +++ b/backend/apps/ifc_validation/serializers.py @@ -1,25 +1,49 @@ from rest_framework import serializers -from apps.ifc_validation_models.models import ValidationRequest, ValidationTask, ValidationOutcome +from apps.ifc_validation_models.models import ValidationRequest +from apps.ifc_validation_models.models import ValidationTask +from apps.ifc_validation_models.models import ValidationOutcome -class ValidationRequestSerializer(serializers.ModelSerializer): +class BaseSerializer(serializers.HyperlinkedModelSerializer): + + def get_field_names(self, declared_fields, info): + + # Django does not support both 'fields' and 'exclude' + + expanded_fields = super(BaseSerializer, self).get_field_names(declared_fields, info) + + if getattr(self.Meta, 'show', None): + expanded_fields = expanded_fields + self.Meta.show + + if getattr(self.Meta, 'hide', None): + expanded_fields = list(set(expanded_fields) - set(self.Meta.hide)) + + return expanded_fields + + +class ValidationRequestSerializer(BaseSerializer): class Meta: model = ValidationRequest - read_only_fields = ("id", "size", "created", "created_by", "updated", "updated_by") - fields = ("id", "file_name", "file", "size", "status", "status_reason", "progress", "created", "created_by", "updated", "updated_by") + fields = '__all__' + show = ["public_id", "model_public_id"] + hide = ["id", "model"] -class ValidationTaskSerializer(serializers.ModelSerializer): +class ValidationTaskSerializer(BaseSerializer): class Meta: model = ValidationTask fields = '__all__' + show = ["public_id", "request_public_id"] + hide = ["id", "process_id", "process_cmd", "request"] -class ValidationOutcomeSerializer(serializers.ModelSerializer): +class ValidationOutcomeSerializer(BaseSerializer): class Meta: model = ValidationOutcome fields = '__all__' + show = ["public_id", "instance_public_id", "validation_task_public_id"] + hide = ["id", "instance", "validation_task"] diff --git a/backend/apps/ifc_validation/tasks.py b/backend/apps/ifc_validation/tasks.py index 7210ed0..e30e8d8 100644 --- a/backend/apps/ifc_validation/tasks.py +++ b/backend/apps/ifc_validation/tasks.py @@ -46,7 +46,7 @@ def get_absolute_file_path(file_name): logger.debug(f"get_absolute_file_path(): file_name={file_name} returned '{ifc_fn}'") return ifc_fn - + @shared_task(bind=True) @log_execution @@ -59,7 +59,7 @@ def error_handler(self, *args, **kwargs): @log_execution def chord_error_handler(self, request, exc, traceback, *args, **kwargs): - on_workflow_failed.apply_async(request, exc, traceback, *args, **kwargs) + on_workflow_failed.apply_async([request, exc, traceback]) @shared_task(bind=True) @@ -110,7 +110,7 @@ def on_workflow_failed(self, *args, **kwargs): reason = f"Processing failed: args={args} kwargs={kwargs}" request = ValidationRequest.objects.get(pk=id) request.mark_as_failed(reason) - + # queue sending email send_failure_email_task.delay(id=id, file_name=request.file_name) @@ -156,7 +156,6 @@ def ifc_file_validation_task(self, id, file_name, *args, **kwargs): workflow_started = on_workflow_started.s(id, file_name) workflow_completed = on_workflow_completed.s(id, file_name) - workflow_failed = on_workflow_failed.s(id, file_name) serial_tasks = chain( syntax_validation_subtask.s(id, file_name), @@ -186,19 +185,29 @@ def ifc_file_validation_task(self, id, file_name, *args, **kwargs): workflow.set(link_error=[error_task]) workflow.apply_async() + @shared_task(bind=True) @log_execution @requires_django_user_context def instance_completion_subtask(self, prev_result, id, file_name, *args, **kwargs): - request = ValidationRequest.objects.get(pk=id) - file_path = get_absolute_file_path(request.file.name) - ifc_file = ifcopenshell.open(file_path) + prev_result_succeeded = prev_result is not None and prev_result[0]['is_valid'] is True + if prev_result_succeeded: + + request = ValidationRequest.objects.get(pk=id) + file_path = get_absolute_file_path(request.file.name) + + ifc_file = ifcopenshell.open(file_path) - with transaction.atomic(): - for inst in request.model.instances.iterator(): - inst.ifc_type = ifc_file[inst.stepfile_id].is_a() - inst.save() + with transaction.atomic(): + for inst in request.model.instances.iterator(): + inst.ifc_type = ifc_file[inst.stepfile_id].is_a() + inst.save() + + else: + reason = f'Skipped as prev_result = {prev_result}.' + #task.mark_as_skipped(reason) + return {'is_valid': None, 'reason': reason} @shared_task(bind=True) @@ -330,19 +339,24 @@ def parse_info_subtask(self, prev_result, id, file_name, *args, **kwargs): model.date = None try: ifc_file_time_stamp = f'{ifc_file.header.file_name.time_stamp}' - logger.debug(f'Timestamp within file = {ifc_file_time_stamp}') - date = datetime.datetime.strptime(ifc_file_time_stamp, "%Y-%m-%dT%H:%M:%S") - date_with_tz = datetime.datetime( - date.year, - date.month, - date.day, - date.hour, - date.minute, - date.second, - tzinfo=datetime.timezone.utc) - model.date = date_with_tz - except ValueError: - model.date = datetime.datetime.fromisoformat(ifc_file_time_stamp) + except RuntimeError: + ifc_file_time_stamp = None + model.date = None + if ifc_file_time_stamp: + try: + logger.debug(f'Timestamp within file = {ifc_file_time_stamp}') + date = datetime.datetime.strptime(ifc_file_time_stamp, "%Y-%m-%dT%H:%M:%S") + date_with_tz = datetime.datetime( + date.year, + date.month, + date.day, + date.hour, + date.minute, + date.second, + tzinfo=datetime.timezone.utc) + model.date = date_with_tz + except ValueError: + model.date = datetime.datetime.fromisoformat(ifc_file_time_stamp) logger.debug(f'Detected date = {model.date}') # MVD @@ -353,8 +367,14 @@ def parse_info_subtask(self, prev_result, id, file_name, *args, **kwargs): logger.debug(f'Detected MVD = {model.mvd}') # authoring app - app = ifc_file.by_type("IfcApplication")[0].ApplicationFullName if len(ifc_file.by_type("IfcApplication")) > 0 else None - version = ifc_file.by_type("IfcApplication")[0].Version if len(ifc_file.by_type("IfcApplication")) > 0 else None + try: + app = ifc_file.by_type("IfcApplication")[0].ApplicationFullName if len(ifc_file.by_type("IfcApplication")) > 0 else None + except RuntimeError: + app = None + try: + version = ifc_file.by_type("IfcApplication")[0].Version if len(ifc_file.by_type("IfcApplication")) > 0 else None + except RuntimeError: + version = None name = None if (app is None and version is None) else (None if version is None else app + ' ' + version) logger.debug(f'Detected Authoring Tool in file = {name}') @@ -501,7 +521,8 @@ def schema_validation_subtask(self, prev_result, id, file_name, *args, **kwargs) # add task task = ValidationTask.objects.create(request=request, type=ValidationTask.Type.SCHEMA) - prev_result_succeeded = prev_result is not None and prev_result['is_valid'] is True + # TODO: revisit schema validation task, perhaps change order of flow? + prev_result_succeeded = prev_result is not None and (prev_result['is_valid'] is True or 'Unsupported schema' in prev_result['reason']) if prev_result_succeeded: task.mark_as_initiated() diff --git a/backend/apps/ifc_validation/templates/admin/hard_delete_intermediate.html b/backend/apps/ifc_validation/templates/admin/hard_delete_intermediate.html new file mode 100644 index 0000000..a6f1ead --- /dev/null +++ b/backend/apps/ifc_validation/templates/admin/hard_delete_intermediate.html @@ -0,0 +1,33 @@ +{% extends "admin/base_site.html" %} +{% load i18n admin_urls static %} + +{% block extrahead %} + {{ block.super }} + {{ media }} + +{% endblock %} + +{% block content %} +
+{% endblock %} \ No newline at end of file diff --git a/backend/apps/ifc_validation/urls.py b/backend/apps/ifc_validation/urls.py index 3279597..ac58297 100644 --- a/backend/apps/ifc_validation/urls.py +++ b/backend/apps/ifc_validation/urls.py @@ -6,9 +6,9 @@ urlpatterns = [ path('validationrequest/', ValidationRequestListAPIView.as_view()), - path('validationrequest/