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 %} +
+ {% csrf_token %} +

+ Are you sure? +

+

+ Are you sure you want to permanently delete the selected {{ entity_name }} and related objects? +
+ This action is IRREVERSIBLE! +

+ + {% for val_request in val_requests %} +

+ {{ val_request }} +

+ + {% endfor %} + + +    + {% translate "No, take me back" %} +
+{% 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//', ValidationRequestDetailAPIView.as_view()), + path('validationrequest//', ValidationRequestDetailAPIView.as_view()), path('validationtask/', ValidationTaskListAPIView.as_view()), - path('validationtask//', ValidationTaskDetailAPIView.as_view()), + path('validationtask//', ValidationTaskDetailAPIView.as_view()), path('validationoutcome/', ValidationOutcomeListAPIView.as_view()), - path('validationoutcome//', ValidationOutcomeDetailAPIView.as_view()), + path('validationoutcome//', ValidationOutcomeDetailAPIView.as_view()), ] diff --git a/backend/apps/ifc_validation/views.py b/backend/apps/ifc_validation/views.py index d845f66..b4ca4fb 100644 --- a/backend/apps/ifc_validation/views.py +++ b/backend/apps/ifc_validation/views.py @@ -4,6 +4,7 @@ from django.db import transaction from core.utils import get_client_ip_address +from core.settings import MAX_FILES_PER_UPLOAD from rest_framework import status from rest_framework.parsers import FormParser, MultiPartParser @@ -42,7 +43,7 @@ def get(self, request, id, *args, **kwargs): 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'))) - instance = ValidationRequest.objects.filter(created_by__id=request.user.id, id=id).first() + instance = ValidationRequest.objects.filter(created_by__id=request.user.id, deleted=False, id=id).first() if instance: serializer = ValidationRequestSerializer(instance) @@ -60,7 +61,7 @@ def delete(self, request, id, *args, **kwargs): 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'))) - instance = ValidationRequest.objects.filter(created_by__id=request.user.id).filter(id=id).first() + instance = ValidationRequest.objects.filter(created_by__id=request.user.id, deleted=False).filter(id=id).first() if instance: instance.delete() data = {'message': f"Validation Request with id='{id}' was deleted successfully."} @@ -87,7 +88,7 @@ def get(self, request, *args, **kwargs): 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'))) - all_user_instances = ValidationRequest.objects.filter(created_by__id=request.user.id) + all_user_instances = ValidationRequest.objects.filter(created_by__id=request.user.id, deleted=False) serializer = self.serializer_class(all_user_instances, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -165,7 +166,7 @@ def get(self, request, id, *args, **kwargs): 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'))) - instance = ValidationTask.objects.filter(request__created_by__id=request.user.id, id=id).first() + instance = ValidationTask.objects.filter(request__created_by__id=request.user.id, request__deleted=False, id=id).first() if instance: serializer = ValidationTaskSerializer(instance) return Response(serializer.data, status=status.HTTP_200_OK) @@ -190,7 +191,7 @@ def get(self, request, *args, **kwargs): 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'))) - all_user_instances = ValidationTask.objects.filter(request__created_by__id=request.user.id) + all_user_instances = ValidationTask.objects.filter(request__created_by__id=request.user.id, request__deleted=False) serializer = self.serializer_class(all_user_instances, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -211,7 +212,7 @@ def get(self, request, id, *args, **kwargs): 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'))) - instance = ValidationOutcome.objects.filter(validation_task__request__created_by__id=request.user.id, id=id).first() + instance = ValidationOutcome.objects.filter(validation_task__request__created_by__id=request.user.id, validation_task__request__deleted=False, id=id).first() if instance: serializer = ValidationOutcomeSerializer(instance) return Response(serializer.data, status=status.HTTP_200_OK) @@ -236,7 +237,7 @@ def get(self, request, *args, **kwargs): 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'))) - all_user_instances = ValidationOutcome.objects.filter(validation_task__request__created_by__id=request.user.id) + all_user_instances = ValidationOutcome.objects.filter(validation_task__request__created_by__id=request.user.id, validation_task__request__deleted=False) serializer = self.serializer_class(all_user_instances, many=True) return Response(serializer.data, status=status.HTTP_200_OK) diff --git a/backend/apps/ifc_validation_bff/urls.py b/backend/apps/ifc_validation_bff/urls.py index 94f9635..06e92af 100644 --- a/backend/apps/ifc_validation_bff/urls.py +++ b/backend/apps/ifc_validation_bff/urls.py @@ -1,18 +1,18 @@ from django.urls import path from .views_legacy import me, models_paginated, download, upload, delete, revalidate -from .views_legacy import report2, report_error +from .views_legacy import report, report_error urlpatterns = [ # 'Flask'-way of doing things; backend for legacy API (< 0.6) path('api/me', me), path('api/models_paginated//', models_paginated), - path('api/download/', download), + path('api/download/', download), path('api/', upload), path('api/delete/', delete), path('api/revalidate/', revalidate), - path('api/report2/', report2), + path('api/report/', report), path('api/report_error///', report_error), # vs diff --git a/backend/apps/ifc_validation_bff/views_legacy.py b/backend/apps/ifc_validation_bff/views_legacy.py index 91686d0..58834aa 100644 --- a/backend/apps/ifc_validation_bff/views_legacy.py +++ b/backend/apps/ifc_validation_bff/views_legacy.py @@ -6,16 +6,14 @@ import logging import itertools import functools +import typing from django.db import transaction -from django.core.files.storage import default_storage -from django.core.files.base import ContentFile from django.http import JsonResponse, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseRedirect, HttpResponseNotFound from django.contrib.auth.models import User -from django.contrib.auth.decorators import login_required -from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.csrf import ensure_csrf_cookie, requires_csrf_token -from apps.ifc_validation_models.models import set_user_context +from apps.ifc_validation_models.models import IdObfuscator, ValidationOutcome, set_user_context from apps.ifc_validation_models.models import ValidationRequest from apps.ifc_validation_models.models import ValidationTask from apps.ifc_validation_models.models import Model @@ -152,36 +150,6 @@ def get_feature_description(feature_code, feature_version): return None -#@login_required - doesn't work as OAuth is not integrated with Django -def me(request): - - # return user or redirect response - user = get_current_user(request) - if user: - json = { - "user_data": - { - 'sub': user.username, - 'email': user.email, - 'family_name': user.last_name, - 'given_name': user.first_name, - 'name': ' '.join([user.first_name, user.last_name]).strip(), - 'is_active': user.is_active - }, - "sandbox_info": - { - "pr_title": None, - "commit_id": None - }, - "redirect": None if user.is_active else '/waiting_zone' - } - return JsonResponse(json) - - else: - - return create_redirect_response(login=True) - - def status_combine(*args): statuses = "-pvnwi" return statuses[max(map(statuses.index, args))] @@ -189,15 +157,13 @@ def status_combine(*args): def format_request(request): return { - # "instances": [], - # "tasks": [], - "id": request.id, - "code": request.id, # TODO - not sure why another longer surrogate key was created? + "id": request.public_id, + "code": request.public_id, "filename": request.file_name, "file_date": None if request.model is None or request.model.date is None else datetime.strftime(request.model.date, '%Y-%m-%d %H:%M:%S'), # TODO - formatting is actually a UI concern... - "user_id": request.created_by.id, + "user_id": IdObfuscator.to_public_id(request.created_by.id, override_cls=User), "progress": -2 if request.status == ValidationRequest.Status.FAILED else (-1 if request.status == ValidationRequest.Status.PENDING else request.progress), - "date": datetime.strftime(request.created if request.updated is None else request.updated, '%Y-%m-%d %H:%M:%S'), # TODO - formatting is actually a UI concern... + "date": datetime.strftime(request.created, '%Y-%m-%d %H:%M:%S'), # TODO - formatting is actually a UI concern... "license": '-' if (request.model is None or request.model.license is None) else request.model.license, "number_of_elements": None if (request.model is None or request.model.number_of_elements is None) else request.model.number_of_elements, "number_of_geometries": None if (request.model is None or request.model.number_of_geometries is None) else request.model.number_of_geometries, @@ -224,6 +190,37 @@ def format_request(request): } +#@login_required - doesn't work as OAuth is not integrated with Django +@ensure_csrf_cookie +def me(request): + + # return user or redirect response + user = get_current_user(request) + if user: + json = { + "user_data": + { + 'sub': user.username, + 'email': user.email, + 'family_name': user.last_name, + 'given_name': user.first_name, + 'name': ' '.join([user.first_name, user.last_name]).strip(), + 'is_active': user.is_active + }, + "sandbox_info": + { + "pr_title": None, + "commit_id": None + }, + "redirect": None if user.is_active else '/waiting_zone' + } + return JsonResponse(json) + + else: + + return create_redirect_response(login=True) + + def models_paginated(request, start: int, end: int): # fetch current user @@ -232,8 +229,8 @@ def models_paginated(request, start: int, end: int): return create_redirect_response(login=True) # return model(s) as projection of Validation Request + Model attributes - requests = ValidationRequest.objects.filter(created_by__id=user.id).order_by('progress', '-updated')[start:end] - total_count = ValidationRequest.objects.filter(created_by__id=user.id).count() + requests = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False).order_by('-created')[start:end] + total_count = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False).count() models = list(map(format_request, requests)) response_data = {} @@ -243,6 +240,7 @@ def models_paginated(request, start: int, end: int): return JsonResponse(response_data) +@requires_csrf_token def download(request, id: int): # fetch current user @@ -250,8 +248,8 @@ def download(request, id: int): if not user: return create_redirect_response(login=True) - logger.debug(f"Locating file for id='{id}'") - request = ValidationRequest.objects.filter(created_by__id=user.id, id=id).first() + logger.debug(f"Locating file for pub='{id}' pk='{ValidationRequest.to_private_id(id)}'") + request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() if request: file_path = os.path.join(os.path.abspath(MEDIA_ROOT), request.file.name) logger.debug(f"File to be downloaded is located at '{file_path}'") @@ -266,7 +264,7 @@ def download(request, id: int): return HttpResponseNotFound() -@csrf_exempt +@requires_csrf_token def upload(request): if request.method == "POST" and request.FILES: @@ -313,41 +311,41 @@ def upload(request): return HttpResponseBadRequest() -# TODO currently a POST, should be a delete... -@csrf_exempt +@requires_csrf_token def delete(request, ids: str): # fetch current user user = get_current_user(request) if not user: return create_redirect_response(login=True) + + set_user_context(user) - with transaction.atomic(): + if request.method == "DELETE" and len(ids.split(',')) > 0: - for id in ids.split(','): + with transaction.atomic(): - logger.info(f"Locating file for id='{id}' and user.id='{user.id}'") - request = ValidationRequest.objects.filter(created_by__id=user.id, id=id).first() + for id in ids.split(','): - file_name = request.file_name - file_absolute = os.path.join(MEDIA_ROOT, request.file.name) + logger.info(f"Locating file for pub='{id}' pk='{ValidationRequest.to_private_id(id)}' and user.id='{user.id}'") + request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() - if os.path.exists(file_absolute): - os.remove(file_absolute) - logger.info(f"File '{file_name}' was deleted (physical file '{file_absolute}')") + request.delete() + logger.info(f"Validation Request with id='{id}' and related entities were marked as deleted.") - request.delete() - logger.info(f"Validation Request with id='{id}' and related entities were deleted.") + # legacy API returns this object + return JsonResponse({ - # legacy API returns this object - return JsonResponse({ + 'status': 'success', + 'id': ids, + }) - 'status': 'success', - 'id': ids, - }) + else: + logger.error(f'Received invalid request: {request}') + return HttpResponseBadRequest() -@csrf_exempt +@requires_csrf_token def revalidate(request, ids: str): # fetch current user @@ -362,13 +360,13 @@ def revalidate(request, ids: str): def on_commit(ids): for id in ids.split(','): - request = ValidationRequest.objects.filter(created_by__id=user.id, id=id).first() + request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() ifc_file_validation_task.delay(request.id, request.file_name) logger.info(f"Task 'ifc_file_validation_task' re-submitted for Validation Request - id: {request.id} file_name: {request.file_name}") for id in ids.split(','): - request = ValidationRequest.objects.filter(created_by__id=user.id, id=id).first() + request = ValidationRequest.objects.filter(created_by__id=user.id, id=ValidationRequest.to_private_id(id)).first() request.mark_as_pending(reason='Resubmitted for processing via React UI') if request.model: request.model.reset_status() @@ -381,7 +379,7 @@ def on_commit(ids): }) -def report2(request, id: str): +def report(request, id: str): report_type = request.GET.get('type') @@ -390,10 +388,10 @@ def report2(request, id: str): if not user: return create_redirect_response(login=True) - # redirect if report is not for current user - request = ValidationRequest.objects.filter(created_by__id=user.id, id=id).first() + # return 404-NotFound if report is not for current user or if it is deleted + request = ValidationRequest.objects.filter(created_by__id=user.id, deleted=False, id=ValidationRequest.to_private_id(id)).first() if not request: - return create_redirect_response(dashboard=True) + return HttpResponseNotFound() # return file metrics as projection of Validation Request + Model attributes instances = {} @@ -412,12 +410,12 @@ def report2(request, id: str): # TODO - should we not do this in the model? match = re.search('^On line ([0-9])+ column ([0-9])+(.)*', outcome.observed) mapped = { - "id": outcome.id, + "id": outcome.public_id, "lineno": match.groups()[0] if match and len(match.groups()) > 0 else None, "column": match.groups()[1] if match and len(match.groups()) > 1 else None, "severity": outcome.severity, "msg": outcome.observed, - "task_id": outcome.validation_task_id + "task_id": outcome.validation_task_public_id } syntax_results.append(mapped) @@ -433,23 +431,23 @@ def report2(request, id: str): if task.outcomes: for outcome in task.outcomes.iterator(): mapped = { - "id": outcome.id, + "id": outcome.public_id, "attribute": json.loads(outcome.feature)['attribute'] if outcome.feature else None, # eg. 'IfcSpatialStructureElement.WR41', "constraint_type": json.loads(outcome.feature)['type'] if outcome.feature else None, # 'uncategorized', 'schema', 'global_rule', 'simpletype_rule', 'entity_rule' - "instance_id": outcome.instance_id, + "instance_id": outcome.instance_public_id, "severity": outcome.severity, "msg": outcome.observed, - "task_id": outcome.validation_task_id + "task_id": outcome.validation_task_public_id } schema_results.append(mapped) inst = outcome.instance - if inst and inst.id not in instances: + if inst and inst.public_id not in instances: instance = { "guid": f'#{inst.stepfile_id}', "type": inst.ifc_type } - instances[inst.id] = instance + instances[inst.public_id] = instance logger.info('Fetching and mapping schema done.') @@ -468,33 +466,35 @@ def report2(request, id: str): logger.info('Fetching and mapping {label} Gherkin results...') + print(*(request.id for t in types)) + tasks = [ValidationTask.objects.filter(request_id=request.id, type=t).last() for t in types] - all_outcomes = itertools.chain.from_iterable(t.outcomes.iterator() for t in tasks) + all_outcomes : typing.Sequence[ValidationOutcome] = itertools.chain.from_iterable(t.outcomes.iterator() for t in tasks) for outcome in all_outcomes: mapped = { - "id": outcome.id, + "id": outcome.public_id, "feature": outcome.feature, "feature_version": outcome.feature_version, "feature_url": get_feature_url(outcome.feature[0:6], outcome.feature_version), "feature_text": get_feature_description(outcome.feature[0:6], outcome.feature_version), "step": outcome.get_severity_display(), # TODO "severity": outcome.severity, - "instance_id": outcome.instance_id, + "instance_id": outcome.instance_public_id, "expected": outcome.expected, "observed": outcome.observed, "message": str(outcome) if outcome.expected and outcome.observed else None, - "task_id": outcome.validation_task_id, + "task_id": outcome.validation_task_public_id, "msg": outcome.observed, } grouped_gherkin_outcomes[label].append(mapped) inst = outcome.instance - if inst and inst.id not in instances: + if inst and inst.public_id not in instances: instance = { "guid": f'#{inst.stepfile_id}', "type": inst.ifc_type } - instances[inst.id] = instance + instances[inst.public_id] = instance logger.info(f'Mapped {label} Gherkin results.') diff --git a/backend/apps/ifc_validation_models b/backend/apps/ifc_validation_models index a50760e..4916f9e 160000 --- a/backend/apps/ifc_validation_models +++ b/backend/apps/ifc_validation_models @@ -1 +1 @@ -Subproject commit a50760e0e3b418407abcdfa00c8710feccc1da1d +Subproject commit 4916f9e42139e95060f30f984dc2eb43e774d912 diff --git a/backend/core/email_tasks.py b/backend/core/email_tasks.py index 914e3d5..b0f2e34 100644 --- a/backend/core/email_tasks.py +++ b/backend/core/email_tasks.py @@ -12,12 +12,13 @@ @shared_task @log_execution -def send_user_registered_admin_email_task(user_id, user_email): +def send_user_registered_admin_email_task(user_id, user_email, is_active = True): # load and merge email template merge_data = { 'USER_ID': user_id, 'USER_EMAIL': user_email, + 'IS_ACTIVE': is_active, 'PUBLIC_URL': PUBLIC_URL, 'ACTIVATE_URL': PUBLIC_URL + '/admin/auth/user' } diff --git a/backend/core/settings.py b/backend/core/settings.py index 71dcd9d..c80df33 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -25,12 +25,14 @@ # SECURITY WARNING: keep the secret key used in production secret! SECRET_KEY = os.environ.get( - "SECRET_KEY", "django-insecure-um7-^+&jbk_=80*xcc9uf4nh$4koida7)ja&6!vb*$8@n288jk" + "DJANGO_SECRET_KEY", "django-insecure-um7-^+&jbk_=80*xcc9uf4nh$4koida7)ja&6!vb*$8@n288jk" ) # SECURITY WARNING: don't run with debug turned on in production! DEBUG = os.environ.get("DEBUG", False) -DEVELOPMENT = os.environ.get('ENV', 'PROD').upper() in ('DEV', 'DEVELOPMENT') +DEVELOPMENT = os.environ.get('ENV', 'PROD').upper() in ('DEV', 'DEVELOP', 'DEVELOPMENT') +STAGING = os.environ.get('ENV', 'PROD').upper() in ('STAGE', 'STAGING', 'QA') +PRODUCTION = os.environ.get('ENV', 'PROD').upper() in ('PROD', 'PRODUCTION', 'PRD') PUBLIC_URL = os.getenv('PUBLIC_URL').strip('/') if os.getenv('PUBLIC_URL') is not None else None ALLOWED_HOSTS = ["127.0.0.1", "0.0.0.0", "localhost", "backend"] @@ -47,18 +49,20 @@ "django.contrib.messages", "django.contrib.staticfiles", - "corsheaders", # CORS - "rest_framework", # DRF + "corsheaders", # CORS + "rest_framework", # DRF "rest_framework.authtoken", - "drf_spectacular", # OpenAPI/Swagger - "drf_spectacular_sidecar", # required for Django collectstatic discovery + "drf_spectacular", # OpenAPI/Swagger + "drf_spectacular_sidecar", # required for Django collectstatic discovery + + "django_celery_results", # Celery result backend + "django_celery_beat", # Celery scheduled tasks - "django_celery_results", # Celery result backend - "django_celery_beat", # Celery scheduled tasks + "apps.ifc_validation", # IfcValidation Service + "apps.ifc_validation_models", # IfcValidation Data Model + "apps.ifc_validation_bff", # IfcValidation ReactUI BFF - "apps.ifc_validation", # IfcValidation Service - "apps.ifc_validation_models", # IfcValidation Data Model - "apps.ifc_validation_bff", # IfcValidation ReactUI BFF + "django_cleanup.apps.CleanupConfig" # to automatically remove unlinked files ] if DEVELOPMENT: @@ -85,7 +89,12 @@ SESSION_ENGINE = 'django.contrib.sessions.backends.db' -CORS_ALLOW_ALL_ORIGINS = True +CORS_ALLOW_CREDENTIALS = True +CORS_ALLOW_ALL_ORIGINS = False +CORS_ALLOWED_ORIGINS = [] +if os.environ.get("DJANGO_TRUSTED_ORIGINS") is not None: + CORS_ALLOWED_ORIGINS += os.environ.get("DJANGO_TRUSTED_ORIGINS").split(" ") + CORS_ALLOW_METHODS = [ 'DELETE', 'GET', @@ -102,17 +111,16 @@ 'dnt', 'origin', 'user-agent', - 'x-csrftoken', 'x-requested-with', - + 'x-csrf-token', 'cache-control' # extra header ] -CSRF_TRUSTED_ORIGINS = [ - 'https://dev.validate.buildingsmart.org' -] -if os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS") is not None: - CSRF_TRUSTED_ORIGINS += os.environ.get("DJANGO_CSRF_TRUSTED_ORIGINS").split(" ") +CSRF_COOKIE_NAME = 'csrftoken' +CSRF_HEADER_NAME = 'HTTP_X_CSRF_TOKEN' +CSRF_TRUSTED_ORIGINS = [] +if os.environ.get("DJANGO_TRUSTED_ORIGINS") is not None: + CSRF_TRUSTED_ORIGINS += os.environ.get("DJANGO_TRUSTED_ORIGINS").split(" ") REST_FRAMEWORK = { 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', diff --git a/backend/core/templates/user_registered_admin_email.html b/backend/core/templates/user_registered_admin_email.html index a0e0d90..d558ee9 100644 --- a/backend/core/templates/user_registered_admin_email.html +++ b/backend/core/templates/user_registered_admin_email.html @@ -4,7 +4,7 @@
- User {{USER_EMAIL}} registered for the Validation Service @ {{PUBLIC_URL}} and can be activated here. + User {{USER_EMAIL}} registered for the Validation Service @ {{PUBLIC_URL}}{% if not IS_ACTIVE %} and can be activated here{% endif %}.
diff --git a/backend/core/views_auth.py b/backend/core/views_auth.py index 77f3fe1..608692b 100644 --- a/backend/core/views_auth.py +++ b/backend/core/views_auth.py @@ -11,7 +11,7 @@ from authlib.integrations.django_client import OAuth from .email_tasks import send_user_registered_admin_email_task -from .settings import LOGIN_CALLBACK_URL, POST_LOGIN_REDIRECT_URL, LOGOUT_URL, LOGIN_URL +from .settings import LOGIN_CALLBACK_URL, POST_LOGIN_REDIRECT_URL, LOGOUT_URL, LOGIN_URL, PRODUCTION oauth = OAuth() oauth.register(name="b2c") @@ -51,14 +51,14 @@ def callback(request): username = username, password = username, email = userinfo['email'], - is_active = False, + is_active = True if PRODUCTION else False, is_superuser = False, is_staff = False, first_name = userinfo['given_name'], last_name = userinfo['family_name'] ) - transaction.on_commit(lambda: send_user_registered_admin_email_task.delay(user.id, user.email)) + transaction.on_commit(lambda: send_user_registered_admin_email_task.delay(user.id, user.email, user.is_active)) logger.info(f"Created user with username = '{username}' via OAuth, user.id = {user.id}") else: if user.is_active: @@ -78,4 +78,4 @@ def logout(request): req = PreparedRequest() req.prepare_url(end_session_endpoint, params) redirect_url = req.url - return redirect(redirect_url) \ No newline at end of file + return redirect(redirect_url) diff --git a/backend/requirements.txt b/backend/requirements.txt index 81de9c8..fd9ffbf 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -3,6 +3,7 @@ django djangorestframework django-filter django-cors-headers +django-cleanup drf-spectacular drf-spectacular[sidecar] diff --git a/docker-compose.full.yml b/docker-compose.full.yml index 86408b3..fee043e 100644 --- a/docker-compose.full.yml +++ b/docker-compose.full.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # Frontend - React + nginx @@ -41,7 +39,9 @@ services: CELERY_RESULT_BACKEND: "django-db" CELERY_RESULT_BACKEND_DB: "db+postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_NAME}" DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} + DJANGO_TRUSTED_ORIGINS: ${DJANGO_TRUSTED_ORIGINS} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} @@ -76,6 +76,7 @@ services: CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT} TASK_TIMEOUT_LIMIT: ${TASK_TIMEOUT_LIMIT} DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} diff --git a/docker-compose.infra_only.yml b/docker-compose.infra_only.yml index 9eaffd4..75e5530 100644 --- a/docker-compose.infra_only.yml +++ b/docker-compose.infra_only.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # Redis diff --git a/docker-compose.load_balanced.yml b/docker-compose.load_balanced.yml index 5fcaf53..7cf4aa5 100644 --- a/docker-compose.load_balanced.yml +++ b/docker-compose.load_balanced.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # Frontend - React + nginx @@ -41,7 +39,9 @@ services: CELERY_RESULT_BACKEND: "django-db" CELERY_RESULT_BACKEND_DB: "db+postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_NAME}" DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} + DJANGO_TRUSTED_ORIGINS: ${DJANGO_TRUSTED_ORIGINS} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} @@ -80,6 +80,7 @@ services: CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT} TASK_TIMEOUT_LIMIT: ${TASK_TIMEOUT_LIMIT} DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} diff --git a/docker-compose.yml b/docker-compose.yml index dd55ba0..7e75d62 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: # Frontend - React + nginx @@ -42,7 +40,9 @@ services: CELERY_RESULT_BACKEND: "django-db" CELERY_RESULT_BACKEND_DB: "db+postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@db/${POSTGRES_NAME}" DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS} + DJANGO_TRUSTED_ORIGINS: ${DJANGO_TRUSTED_ORIGINS} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} @@ -74,6 +74,7 @@ services: CELERY_TASK_TIME_LIMIT: ${CELERY_TASK_TIME_LIMIT} TASK_TIMEOUT_LIMIT: ${TASK_TIMEOUT_LIMIT} DJANGO_DB: ${DJANGO_DB} + DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY} POSTGRES_HOST: db POSTGRES_PORT: ${POSTGRES_PORT} POSTGRES_NAME: ${POSTGRES_NAME} diff --git a/frontend/public/index.html b/frontend/public/index.html index 1d8d1a1..0e8d270 100644 --- a/frontend/public/index.html +++ b/frontend/public/index.html @@ -3,8 +3,8 @@ - - + +