This is an automated email from the ASF dual-hosted git repository. yasith pushed a commit to branch py310 in repository https://gitbox.apache.org/repos/asf/airavata-portals.git
commit 4bed03124f8e99ce5e7ba85fc03da3e32a03f75c Author: yasithdev <[email protected]> AuthorDate: Fri Jul 18 14:13:40 2025 -0500 partial migration fixes --- .../experiment_util/intermediate_output.py | 19 ++++-- .../airavata_django_portal_sdk/user_storage/api.py | 2 +- .../backends/django_filesystem_provider.py | 17 +++-- .../user_storage/backends/mft_provider.py | 10 +++ .../admin/management/commands/archive_user_data.py | 2 +- .../django_airavata/apps/api/views.py | 13 ++-- .../django_airavata/apps/auth/models.py | 75 +++++++++++++--------- .../django_airavata/apps/auth/views.py | 10 +-- .../django_airavata/apps/dataparsers/apps.py | 21 ++++-- .../django_airavata/apps/groups/apps.py | 21 ++++-- .../django_airavata/apps/workspace/apps.py | 21 ++++-- .../base/management/commands/set_wagtail_site.py | 2 +- .../django_airavata/wagtailapps/base/models.py | 6 +- airavata-django-portal/pyproject.toml | 1 + airavata-django-portal/pyrightconfig.json | 32 ++++----- 15 files changed, 159 insertions(+), 93 deletions(-) diff --git a/airavata-django-portal/airavata_django_portal_sdk/experiment_util/intermediate_output.py b/airavata-django-portal/airavata_django_portal_sdk/experiment_util/intermediate_output.py index dec812f4f..e6a1bfd55 100644 --- a/airavata-django-portal/airavata_django_portal_sdk/experiment_util/intermediate_output.py +++ b/airavata-django-portal/airavata_django_portal_sdk/experiment_util/intermediate_output.py @@ -31,9 +31,9 @@ def can_fetch_intermediate_output(request, experiment: ExperimentModel, output_n jobs: list[JobModel] = [] process: ProcessModel task: TaskModel - for process in experiment.processes: - for task in process.tasks: - for job in task.jobs: + for process in experiment.processes or []: + for task in process.tasks or []: + for job in task.jobs or []: jobs.append(job) def latest_status_is_active(job: JobModel) -> bool: @@ -47,6 +47,8 @@ def can_fetch_intermediate_output(request, experiment: ExperimentModel, output_n try: # Return True if process status is in a terminal state process_status = get_intermediate_output_process_status(request, experiment, output_name) + if process_status is None: + return True return process_status.state in [ProcessState.CANCELED, ProcessState.COMPLETED, ProcessState.FAILED] except Exception: # Return True since error here likely means that there is no currently running process @@ -68,15 +70,18 @@ def get_intermediate_output_data_products(request, experiment: ExperimentModel, most_recent_completed_process_output = None for process in output_fetching_processes: # Skip over any processes that aren't completed + if process.processStatuses is None: + continue + assert process.processStatuses is not None if (len(process.processStatuses) == 0 or process.processStatuses[-1].state != ProcessState.COMPLETED): continue - for process_output in process.processOutputs: + for process_output in process.processOutputs or []: if process_output.name == output_name: most_recent_completed_process_output = process_output break if most_recent_completed_process_output is not None: break - if most_recent_completed_process_output is not None: + if most_recent_completed_process_output is not None and most_recent_completed_process_output.value: data_product_uris = [] if most_recent_completed_process_output.value.startswith('airavata-dp://'): data_product_uris = most_recent_completed_process_output.value.split(',') @@ -89,9 +94,9 @@ def get_intermediate_output_data_products(request, experiment: ExperimentModel, def _get_output_fetching_processes(experiment: ExperimentModel) -> list[ProcessModel]: "sort the processes (most recent first) and filter to just the output fetching ones" - processes: list[ProcessModel] = sorted(experiment.processes, key=lambda p: p.creationTime, reverse=True) if experiment.processes else [] + processes: list[ProcessModel] = sorted(experiment.processes or [], key=lambda p: p.creationTime or 0, reverse=True) output_fetching_processes: list[ProcessModel] = [] for process in processes: - if any(map(lambda t: t.taskType == TaskTypes.OUTPUT_FETCHING, process.tasks)): + if any(map(lambda t: t.taskType == TaskTypes.OUTPUT_FETCHING, process.tasks or [])): output_fetching_processes.append(process) return output_fetching_processes diff --git a/airavata-django-portal/airavata_django_portal_sdk/user_storage/api.py b/airavata-django-portal/airavata_django_portal_sdk/user_storage/api.py index 0f8937592..9f4f619b4 100644 --- a/airavata-django-portal/airavata_django_portal_sdk/user_storage/api.py +++ b/airavata-django-portal/airavata_django_portal_sdk/user_storage/api.py @@ -449,7 +449,7 @@ def get_data_product_metadata(request, data_product=None, data_product_uri=None) params={'product-uri': data_product.productUri}) data = resp.json() file = { - "name": os.path.basename(path), + "name": os.path.basename(path) if path else "", # FIXME: since this isn't the true relative path, going to leave out for now # "path": path, "resource_path": path, diff --git a/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/django_filesystem_provider.py b/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/django_filesystem_provider.py index cbd586942..feb80f62e 100644 --- a/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/django_filesystem_provider.py +++ b/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/django_filesystem_provider.py @@ -139,10 +139,10 @@ class DjangoFileSystemProvider(UserStorageProvider): @property def datastore(self): - directory = os.path.join(self.directory, self.username) - owner_username = self.context.get('owner_username') + directory = os.path.join(self.directory, self.username) if self.directory and self.username else "" + owner_username = self.context.get('owner_username') if self.context else None # When the current user isn't the owner, set the directory based on the owner's username - if owner_username: + if owner_username and self.directory: directory = os.path.join(self.directory, owner_username) return _Datastore(directory=directory) @@ -247,6 +247,8 @@ class _Datastore: """Return an experiment directory (full path) for the given experiment.""" user_experiment_data_storage = self.storage if path is None: + assert project_name is not None + assert experiment_name is not None proj_dir_name = user_experiment_data_storage.get_valid_name( project_name) # AIRAVATA-3245 Make project directory with correct permissions @@ -282,13 +284,10 @@ class _Datastore: def _makedirs(self, dir_path): user_experiment_data_storage = self.storage full_path = user_experiment_data_storage.path(dir_path) - os.makedirs( - full_path, - mode=user_experiment_data_storage.directory_permissions_mode) + mode = getattr(user_experiment_data_storage, 'directory_permissions_mode', 0o755) + os.makedirs(full_path, mode=mode) # os.makedirs mode isn't always respected so need to chmod to be sure - os.chmod( - full_path, - mode=user_experiment_data_storage.directory_permissions_mode) + os.chmod(full_path, mode=mode) def list_user_dir(self, file_path): logger.debug(f"file_path={file_path}") diff --git a/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/mft_provider.py b/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/mft_provider.py index 4f10ae44b..970e05910 100644 --- a/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/mft_provider.py +++ b/airavata-django-portal/airavata_django_portal_sdk/user_storage/backends/mft_provider.py @@ -24,6 +24,8 @@ class MFTUserStorageProvider(UserStorageProvider, ProvidesDownloadUrl): self.base_resource_path = base_resource_path def exists(self, resource_path): + if not self.mft_api_endpoint: + raise ValueError("MFT API endpoint not configured") with grpc.insecure_channel(self.mft_api_endpoint) as channel: child_path = self._get_child_path(resource_path) # TODO: is this still needed? @@ -60,6 +62,8 @@ class MFTUserStorageProvider(UserStorageProvider, ProvidesDownloadUrl): return child_path in map(lambda f: f.friendlyName, list(response.directories) + list(response.files)) def get_metadata(self, resource_path): + if not self.mft_api_endpoint: + raise ValueError("MFT API endpoint not configured") with grpc.insecure_channel(self.mft_api_endpoint) as channel: child_path = self._get_child_path(resource_path) stub = MFTApi_pb2_grpc.MFTApiServiceStub(channel) @@ -137,6 +141,8 @@ class MFTUserStorageProvider(UserStorageProvider, ProvidesDownloadUrl): return directories_data, files_data def is_file(self, resource_path): + if not self.mft_api_endpoint: + raise ValueError("MFT API endpoint not configured") with grpc.insecure_channel(self.mft_api_endpoint) as channel: child_path = self._get_child_path(resource_path) stub = MFTApi_pb2_grpc.MFTApiServiceStub(channel) @@ -162,6 +168,8 @@ class MFTUserStorageProvider(UserStorageProvider, ProvidesDownloadUrl): return False def is_dir(self, resource_path): + if not self.mft_api_endpoint: + raise ValueError("MFT API endpoint not configured") with grpc.insecure_channel(self.mft_api_endpoint) as channel: child_path = self._get_child_path(resource_path) stub = MFTApi_pb2_grpc.MFTApiServiceStub(channel) @@ -187,6 +195,8 @@ class MFTUserStorageProvider(UserStorageProvider, ProvidesDownloadUrl): return False def get_download_url(self, resource_path): + if not self.mft_api_endpoint: + raise ValueError("MFT API endpoint not configured") with grpc.insecure_channel(self.mft_api_endpoint) as channel: child_path = self._get_child_path(resource_path) stub = MFTApi_pb2_grpc.MFTApiServiceStub(channel) diff --git a/airavata-django-portal/django_airavata/apps/admin/management/commands/archive_user_data.py b/airavata-django-portal/django_airavata/apps/admin/management/commands/archive_user_data.py index 20b4ab5b3..922215dcb 100644 --- a/airavata-django-portal/django_airavata/apps/admin/management/commands/archive_user_data.py +++ b/airavata-django-portal/django_airavata/apps/admin/management/commands/archive_user_data.py @@ -92,7 +92,7 @@ class Command(BaseCommand): # and create database records for the archive try: # If any error occurs in this block, the transaction will be rolled back - with transaction.atomic(): + with transaction.atomic() as _: # type: ignore user_data_archive = models.UserDataArchive( archive_name=archive_tarball_filename, archive_path=os.fspath(archive_directory / archive_tarball_filename), diff --git a/airavata-django-portal/django_airavata/apps/api/views.py b/airavata-django-portal/django_airavata/apps/api/views.py index 75ebfd431..105fa3a96 100644 --- a/airavata-django-portal/django_airavata/apps/api/views.py +++ b/airavata-django-portal/django_airavata/apps/api/views.py @@ -1887,10 +1887,15 @@ def link_output_view(request): def _generate_output_view_data(request): params = request.GET.copy() - provider_id = params.pop('provider-id')[0] - experiment_id = params.pop('experiment-id')[0] - experiment_output_name = params.pop('experiment-output-name')[0] - test_mode = ('test-mode' in params and params.pop('test-mode')[0] == "true") + provider_id = request.GET.get('provider-id', '') + experiment_id = request.GET.get('experiment-id', '') + experiment_output_name = request.GET.get('experiment-output-name', '') + test_mode = request.GET.get('test-mode', 'false') == "true" + # Remove these from params since we've already extracted them + params.pop('provider-id', None) + params.pop('experiment-id', None) + params.pop('experiment-output-name', None) + params.pop('test-mode', None) return output_views.generate_data(request, provider_id, experiment_output_name, diff --git a/airavata-django-portal/django_airavata/apps/auth/models.py b/airavata-django-portal/django_airavata/apps/auth/models.py index dcdd6f3bf..e057963b8 100644 --- a/airavata-django-portal/django_airavata/apps/auth/models.py +++ b/airavata-django-portal/django_airavata/apps/auth/models.py @@ -118,7 +118,7 @@ class UserProfile(models.Model): fields = ExtendedUserProfileField.objects.filter(deleted=False) for field in fields: try: - value = self.extended_profile_values.filter(ext_user_profile_field=field).get() + value = ExtendedUserProfileValue.objects.get(ext_user_profile_field=field) if not value.valid: return False except ExtendedUserProfileValue.DoesNotExist: @@ -178,7 +178,7 @@ class ExtendedUserProfileField(models.Model): required = models.BooleanField(default=True) def __str__(self) -> str: - return f"{self.name} ({self.id})" + return f"{self.name} ({self.pk})" @property def field_type(self): @@ -220,7 +220,7 @@ class ExtendedUserProfileFieldChoice(models.Model): abstract = True def __str__(self) -> str: - return f"{self.display_text} ({self.id})" + return f"{self.display_text} ({self.pk})" class ExtendedUserProfileSingleChoiceFieldChoice(ExtendedUserProfileFieldChoice): @@ -269,7 +269,6 @@ class ExtendedUserProfileValue(models.Model): created_date = models.DateTimeField(auto_now_add=True) updated_date = models.DateTimeField(auto_now=True) - @property def value_type(self): if hasattr(self, 'text'): @@ -286,31 +285,38 @@ class ExtendedUserProfileValue(models.Model): @property def value_display(self): if self.value_type == 'text': - return self.text.text_value + return getattr(self, 'text_value', None) elif self.value_type == 'single_choice': - if self.single_choice.choice: + single_choice = getattr(self, 'single_choice', None) + assert single_choice is not None + if single_choice.choice: try: - choice = self.ext_user_profile_field.single_choice.choices.get(id=self.single_choice.choice) + assert self.ext_user_profile_field is not None + choice = getattr(self.ext_user_profile_field, 'single_choice').choices.get(id=single_choice.choice) return choice.display_text except ExtendedUserProfileSingleChoiceFieldChoice.DoesNotExist: return None - elif self.single_choice.other_value: - return f"Other: {self.single_choice.other_value}" + elif single_choice.other_value: + return f"Other: {single_choice.other_value}" elif self.value_type == 'multi_choice': result = [] - if self.multi_choice.choices: - mc_field = self.ext_user_profile_field.multi_choice - for choice_value in self.multi_choice.choices.all(): + multi_choice = getattr(self, 'multi_choice', None) + assert multi_choice is not None + if multi_choice.choices: + mc_field = getattr(self.ext_user_profile_field, 'multi_choice') + for choice_value in multi_choice.choices.all(): try: choice = mc_field.choices.get(id=choice_value.value) result.append(choice.display_text) except ExtendedUserProfileMultiChoiceFieldChoice.DoesNotExist: continue - if self.multi_choice.other_value: - result.append(f"Other: {self.multi_choice.other_value}") + if multi_choice.other_value: + result.append(f"Other: {multi_choice.other_value}") return result elif self.value_type == 'user_agreement': - if self.user_agreement.agreement_value: + user_agreement = getattr(self, 'user_agreement', None) + assert user_agreement is not None + if user_agreement.agreement_value: return "Yes" else: return "No" @@ -328,28 +334,37 @@ class ExtendedUserProfileValue(models.Model): @property def valid(self): # if the field is deleted, whatever the value, consider it valid - if self.ext_user_profile_field.deleted: + ext_user_profile_field = getattr(self, 'ext_user_profile_field', None) + assert ext_user_profile_field is not None + if ext_user_profile_field.deleted: return True - if self.ext_user_profile_field.required: + if ext_user_profile_field.required: if self.value_type == 'text': - return self.text.text_value and len(self.text.text_value.strip()) > 0 + text_value = getattr(self, 'text_value', None) + return text_value and len(text_value.strip()) > 0 if self.value_type == 'single_choice': - choice_exists = (self.single_choice.choice and - self.ext_user_profile_field.single_choice.choices - .filter(id=self.single_choice.choice).exists()) - has_other = (self.ext_user_profile_field.single_choice.other and - self.single_choice.other_value and - len(self.single_choice.other_value.strip()) > 0) + single_choice = getattr(self, 'single_choice', None) + assert single_choice is not None + choice_exists = (single_choice.choice and + ext_user_profile_field.single_choice.choices + .filter(id=single_choice.choice).exists()) + has_other = (ext_user_profile_field.single_choice.other and + single_choice.other_value and + len(single_choice.other_value.strip()) > 0) return choice_exists or has_other if self.value_type == 'multi_choice': - choice_ids = list(map(lambda c: c.value, self.multi_choice.choices.all())) - choice_exists = self.ext_user_profile_field.multi_choice.choices.filter(id__in=choice_ids).exists() - has_other = (self.ext_user_profile_field.multi_choice.other and - self.multi_choice.other_value and - len(self.multi_choice.other_value.strip()) > 0) + multi_choice = getattr(self, 'multi_choice', None) + assert multi_choice is not None + choice_ids = list(map(lambda c: c.value, multi_choice.choices.all())) + choice_exists = ext_user_profile_field.multi_choice.choices.filter(id__in=choice_ids).exists() + has_other = (ext_user_profile_field.multi_choice.other and + multi_choice.other_value and + len(multi_choice.other_value.strip()) > 0) return choice_exists or has_other if self.value_type == 'user_agreement': - return self.user_agreement.agreement_value is True + user_agreement = getattr(self, 'user_agreement', None) + assert user_agreement is not None + return user_agreement.agreement_value is True return True diff --git a/airavata-django-portal/django_airavata/apps/auth/views.py b/airavata-django-portal/django_airavata/apps/auth/views.py index 1742cc091..816506976 100644 --- a/airavata-django-portal/django_airavata/apps/auth/views.py +++ b/airavata-django-portal/django_airavata/apps/auth/views.py @@ -1,7 +1,7 @@ import io import logging import time -from datetime import UTC, datetime, timedelta +from datetime import datetime, timedelta, timezone from urllib.parse import quote, urlencode, urlparse import requests @@ -142,9 +142,9 @@ def start_logout(request): def callback(request): + login_desktop = request.GET.get('login_desktop', "false") == "true" + idp_alias = request.GET.get('idp_alias') try: - login_desktop = request.GET.get('login_desktop', "false") == "true" - idp_alias = request.GET.get('idp_alias') user = authenticate(request=request, idp_alias=idp_alias) if user is not None: @@ -426,7 +426,7 @@ def reset_password(request, code): "Reset password link is invalid. Please try again.") return redirect(reverse('django_airavata_auth:forgot_password')) - now = datetime.now(UTC) + now = datetime.now(timezone.utc) if now - password_reset_request.created_date > timedelta(days=1): password_reset_request.delete() messages.error( @@ -588,7 +588,7 @@ class UserViewSet(viewsets.ModelViewSet): @action(detail=False) def current(self, request: AiravataHttpRequest): - return redirect(reverse('django_airavata_auth:user-detail', kwargs={'pk': request.user.id})) + return redirect(reverse('django_airavata_auth:user-detail', kwargs={'pk': request.user.pk})) @action(methods=['post'], detail=True) def resend_email_verification(self, request: AiravataHttpRequest, pk=None): diff --git a/airavata-django-portal/django_airavata/apps/dataparsers/apps.py b/airavata-django-portal/django_airavata/apps/dataparsers/apps.py index 836b34e2e..83914d1e4 100644 --- a/airavata-django-portal/django_airavata/apps/dataparsers/apps.py +++ b/airavata-django-portal/django_airavata/apps/dataparsers/apps.py @@ -5,13 +5,26 @@ class DataParsersConfig(AiravataAppConfig): name = 'django_airavata.apps.dataparsers' label = 'django_airavata_dataparsers' verbose_name = 'Data Parsers' - app_order = 20 - url_home = 'django_airavata_dataparsers:home' - fa_icon_class = 'fa-copy' - app_description = """ + + @property + def app_order(self): + return 20 + + @property + def url_home(self): + return 'django_airavata_dataparsers:home' + + @property + def fa_icon_class(self): + return 'fa-copy' + + @property + def app_description(self): + return """ Define data parsers for post-processing experimental and ad-hoc datasets. """ + nav = [ { 'label': 'Home', diff --git a/airavata-django-portal/django_airavata/apps/groups/apps.py b/airavata-django-portal/django_airavata/apps/groups/apps.py index f1e5e6f6a..2759a6a93 100755 --- a/airavata-django-portal/django_airavata/apps/groups/apps.py +++ b/airavata-django-portal/django_airavata/apps/groups/apps.py @@ -5,12 +5,25 @@ class GroupsConfig(AiravataAppConfig): name = 'django_airavata.apps.groups' label = 'django_airavata_groups' verbose_name = 'Groups' - app_order = 10 - url_home = 'django_airavata_groups:manage' - fa_icon_class = 'fa-users' - app_description = """ + + @property + def app_order(self): + return 10 + + @property + def url_home(self): + return 'django_airavata_groups:manage' + + @property + def fa_icon_class(self): + return 'fa-users' + + @property + def app_description(self): + return """ Create and manage user groups. """ + nav = [ { 'label': 'Groups', diff --git a/airavata-django-portal/django_airavata/apps/workspace/apps.py b/airavata-django-portal/django_airavata/apps/workspace/apps.py index fca2121bd..565f637b8 100644 --- a/airavata-django-portal/django_airavata/apps/workspace/apps.py +++ b/airavata-django-portal/django_airavata/apps/workspace/apps.py @@ -5,12 +5,25 @@ class WorkspaceConfig(AiravataAppConfig): name = 'django_airavata.apps.workspace' label = 'django_airavata_workspace' verbose_name = 'Workspace' - app_order = 0 - url_home = 'django_airavata_workspace:dashboard' - fa_icon_class = 'fa-flask' - app_description = """ + + @property + def app_order(self): + return 0 + + @property + def url_home(self): + return 'django_airavata_workspace:dashboard' + + @property + def fa_icon_class(self): + return 'fa-flask' + + @property + def app_description(self): + return """ Launch applications and manage your experiments and projects. """ + nav = [ { 'label': 'Dashboard', diff --git a/airavata-django-portal/django_airavata/wagtailapps/base/management/commands/set_wagtail_site.py b/airavata-django-portal/django_airavata/wagtailapps/base/management/commands/set_wagtail_site.py index 8118d4619..e6e2d0334 100644 --- a/airavata-django-portal/django_airavata/wagtailapps/base/management/commands/set_wagtail_site.py +++ b/airavata-django-portal/django_airavata/wagtailapps/base/management/commands/set_wagtail_site.py @@ -13,7 +13,7 @@ class Command(BaseCommand): settings.ALLOWED_HOSTS) > 0 else "localhost" if not Site.objects.filter(hostname=hostname, is_default_site=True).exists(): - with transaction.atomic(): + with transaction.atomic(): # type: ignore # Delete any current default site Site.objects.filter(is_default_site=True).delete() roots = Page.get_root_nodes() diff --git a/airavata-django-portal/django_airavata/wagtailapps/base/models.py b/airavata-django-portal/django_airavata/wagtailapps/base/models.py index aba2e3dbf..cbe00f652 100644 --- a/airavata-django-portal/django_airavata/wagtailapps/base/models.py +++ b/airavata-django-portal/django_airavata/wagtailapps/base/models.py @@ -158,15 +158,13 @@ class Navbar(models.Model): logo_width = models.IntegerField( help_text='Provide a width for the logo', null=True, - blank=True, - default=144 + blank=True ) logo_height = models.IntegerField( help_text='Provide a height for the logo', null=True, - blank=True, - default=43 + blank=True ) logo_text = models.CharField( diff --git a/airavata-django-portal/pyproject.toml b/airavata-django-portal/pyproject.toml index af1150646..65d54a789 100644 --- a/airavata-django-portal/pyproject.toml +++ b/airavata-django-portal/pyproject.toml @@ -29,6 +29,7 @@ dependencies = [ "papermill", "django-webpack-loader", "mysqlclient", + "grpcio", ] classifiers = [ "Development Status :: 4 - Beta", diff --git a/airavata-django-portal/pyrightconfig.json b/airavata-django-portal/pyrightconfig.json index 7b854e858..3f6088429 100644 --- a/airavata-django-portal/pyrightconfig.json +++ b/airavata-django-portal/pyrightconfig.json @@ -1,21 +1,15 @@ { - "include": [ - "airavata_django_portal_commons", - "airavata_django_portal_sdk", - "django_airavata", - ], - "exclude": [ - "**/node_modules", - "**/__pycache__", - "**/.*", - "**/fixtures", - "**/migrations", - "**/media", - "**/resources", - "**/static", - "**/templates", - "**/venv", - ], - "reportMissingImports": true, - "reportMissingTypeStubs": false + "typeCheckingMode": "basic", + "reportAttributeAccessIssue": false, + "reportMissingModuleSource": false, + "reportMissingImports": false, + "reportArgumentType": false, + "reportCallIssue": false, + "reportOptionalMemberAccess": false, + "reportOptionalSubscript": false, + "reportOptionalIterable": false, + "reportOptionalContextManager": false, + "reportOptionalOperand": false, + "reportOptionalCall": false, + "exclude": ["**/migrations/**", "**/tests/**", "**/node_modules/**", "**/build/**"] } \ No newline at end of file
