From a0f6e76f90365e0c6860fa56aa55cec1b227867d Mon Sep 17 00:00:00 2001 From: Sergei Kliuikov Date: Tue, 19 Mar 2024 19:38:54 +1000 Subject: [PATCH 01/46] Feature(backend): Migrate to Django 5.0 --- .gitlab-ci.yml | 4 +-- .pylintrc | 4 +-- pyproject.toml | 6 ++--- requirements-prod.txt | 4 +-- requirements-rpc.txt | 2 +- requirements-rtd.txt | 4 +-- requirements-test.txt | 4 +-- requirements.txt | 4 +-- setup.py | 2 +- .../0044_product_uniq_name_store.py | 2 +- .../0045_dynamicfields_generated_field.py | 19 +++++++++++++ test_src/test_proj/models/dynamic_fields.py | 8 +++++- test_src/test_proj/models/nested_models.py | 2 +- test_src/test_proj/tests.py | 23 +++++++++++++--- tox.ini | 27 +++++++++---------- tox_build.ini | 2 +- vstutils/api/endpoint.py | 3 +-- vstutils/api/health.py | 5 ++-- vstutils/api/schema/inspectors.py | 4 +-- vstutils/api/serializers.py | 16 +++++++++-- vstutils/management/commands/rpc_worker.py | 8 +++--- vstutils/models/fields.py | 2 +- vstutils/tests.py | 2 +- vstutils/utils.py | 3 +-- 24 files changed, 103 insertions(+), 57 deletions(-) create mode 100644 test_src/test_proj/migrations/0045_dynamicfields_generated_field.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 6e578540..d56d91ae 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -81,9 +81,9 @@ functional_test: parallel: matrix: - TOX_ENVS: - - py38-django42-install + - py310-django50-install - TOX_ENVS: - - py312-django42-coverage + - py312-django50-coverage js_tests: <<: *branch_js_tests diff --git a/.pylintrc b/.pylintrc index 297ec92a..927aae29 100644 --- a/.pylintrc +++ b/.pylintrc @@ -13,7 +13,7 @@ ignore=CVS,migrations,unittests,tests,settings.py,settings_production.py # Add files or directories matching the regex patterns to the blacklist. The # regex matches against base names, not paths. -# ignore-patterns= +ignore-patterns=.*.pyi # Pickle collected data for later comparisons. # persistent=yes @@ -56,7 +56,7 @@ confidence= # --enable=similarities". If you want to run only the classes checker, but have # no Warning level messages displayed, use"--disable=all --enable=classes # --disable=W" -disable=unused-private-member,super-with-arguments,duplicate-code,logging-fstring-interpolation,no-else-continue,unused-argument,signature-differs,no-else-return,consider-using-ternary,inconsistent-return-statements,len-as-condition,keyword-arg-before-vararg,expression-not-assigned,broad-except,logging-format-interpolation,model-no-explicit-unicode,too-many-ancestors,redefined-builtin,missing-docstring,line-too-long,suppressed-message,useless-suppression,model-has-unicode,bare-except,too-few-public-methods,fixme,dangerous-default-value,attribute-defined-outside-init,pointless-string-statement,too-many-instance-attributes,arguments-differ,binary-op-exception,bad-classmethod-argument,locally-disabled,file-ignored,multiple-statements,superfluous-parens,isinstance-second-argument-not-valid-type +disable=cyclic-import,unused-private-member,super-with-arguments,duplicate-code,logging-fstring-interpolation,no-else-continue,unused-argument,signature-differs,no-else-return,consider-using-ternary,inconsistent-return-statements,len-as-condition,keyword-arg-before-vararg,expression-not-assigned,broad-except,logging-format-interpolation,model-no-explicit-unicode,too-many-ancestors,redefined-builtin,missing-docstring,line-too-long,suppressed-message,useless-suppression,model-has-unicode,bare-except,too-few-public-methods,fixme,dangerous-default-value,attribute-defined-outside-init,pointless-string-statement,too-many-instance-attributes,arguments-differ,binary-op-exception,bad-classmethod-argument,locally-disabled,file-ignored,multiple-statements,superfluous-parens,isinstance-second-argument-not-valid-type [REPORTS] diff --git a/pyproject.toml b/pyproject.toml index 3b1994fd..79035ab8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -18,14 +18,12 @@ classifiers = [ "Development Status :: 5 - Production/Stable", "Environment :: Web Environment", "Framework :: Django", - "Framework :: Django :: 4.2", + "Framework :: Django :: 5.0", "Operating System :: POSIX", "License :: OSI Approved :: Apache Software License", "Programming Language :: Python", "Programming Language :: Python :: 3", "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", "Programming Language :: Python :: 3.11", "Programming Language :: Python :: 3.12", @@ -67,7 +65,7 @@ include = ['vstutils', 'vstutils.*'] namespaces = false [tool.flake8] -ignore = "E221,E222,E121,E123,E126,E226,E24,E704,E116,E731,E722,E741,W504,B001,B008,B023,C812,C815,CFQ004,B019,I100" +ignore = "E221,E222,E121,E123,E126,E226,E24,E704,E116,E731,E722,E741,W504,B001,B008,B023,C812,C815,CFQ004,B019,B026,I100" exclude = "./vstutils/*/migrations/*,./vstutils/settings*.py,.tox/*,./etc/*,./*/__init__.py,./t_openstack.py,./vstutils/projects/*,vstutils/__main__.py,vstutils/compile.py" max-line-length = 120 import-order-style = 'pep8' diff --git a/requirements-prod.txt b/requirements-prod.txt index 43b08fab..1eef07ae 100644 --- a/requirements-prod.txt +++ b/requirements-prod.txt @@ -2,6 +2,6 @@ # PyMySQL>=0.9.2,<=0.9.3; python_version<'3.0' # mysql-connector-python==8.0.15; python_version>'3.4' # Advanced cache support -redis[hiredis]~=5.0.1 +redis[hiredis]~=5.0.3 tarantool~=1.1.2 -pywebpush~=1.14.1 +pywebpush~=2.0.0 diff --git a/requirements-rpc.txt b/requirements-rpc.txt index 13f3ffb7..4fb933d7 100644 --- a/requirements-rpc.txt +++ b/requirements-rpc.txt @@ -1,3 +1,3 @@ # Packages needed for delayed jobs. celery[redis]==5.3.6 -django-celery-beat~=2.5.0 +django-celery-beat~=2.6.0 diff --git a/requirements-rtd.txt b/requirements-rtd.txt index ba58e785..783f8323 100644 --- a/requirements-rtd.txt +++ b/requirements-rtd.txt @@ -1,7 +1,7 @@ -rrequirements.txt -rrequirements-doc.txt -rrequirements-rpc.txt -django~=4.2.10 -httpx>=0.26.0 +django~=5.0.3 +httpx>=0.27.0 typing-extensions sphinx-intl~=2.1.0 diff --git a/requirements-test.txt b/requirements-test.txt index cd037381..070113bf 100644 --- a/requirements-test.txt +++ b/requirements-test.txt @@ -1,6 +1,6 @@ # Packages needed for test -coverage~=7.4.1 +coverage~=7.4.4 fakeldap~=0.6.6 tblib==3.0.0 beautifulsoup4~=4.12.3 -httpx~=0.26.0 +httpx~=0.27.0 diff --git a/requirements.txt b/requirements.txt index 9f09a551..afee8e18 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ Markdown~=3.5.2 django-environ~=0.11.2 # REST API packages -djangorestframework~=3.14.0 +djangorestframework~=3.15.0 drf-yasg==1.21.7 django-filter==23.5 drf_orjson_renderer==1.7.1 @@ -12,7 +12,7 @@ ormsgpack~=1.4.2 pyyaml~=6.0.1 # web server -uvicorn~=0.27.1 +uvicorn~=0.28.0 pyuwsgi==2.0.23.post0 # Restore it if some problems with pyuwsgi # uwsgi==2.0.23 diff --git a/setup.py b/setup.py index 35a6a1e0..86cd4f74 100644 --- a/setup.py +++ b/setup.py @@ -42,7 +42,7 @@ 'vstutils/static/bundle/.*\.js$' ], install_requires=[ - "django~=" + (os.environ.get('DJANGO_DEP', "") or "4.2.10"), + "django~=" + (os.environ.get('DJANGO_DEP', "") or "5.0.3"), ] + requirements + load_requirements('requirements-doc.txt'), diff --git a/test_src/test_proj/migrations/0044_product_uniq_name_store.py b/test_src/test_proj/migrations/0044_product_uniq_name_store.py index a79ccafa..007764b6 100644 --- a/test_src/test_proj/migrations/0044_product_uniq_name_store.py +++ b/test_src/test_proj/migrations/0044_product_uniq_name_store.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): operations = [ migrations.AddConstraint( model_name='product', - constraint=models.UniqueConstraint(fields=('name', 'store'), name='uniq_name_store'), + constraint=models.UniqueConstraint(models.F('name'), models.F('store'), name='uniq_name_store'), ), ] diff --git a/test_src/test_proj/migrations/0045_dynamicfields_generated_field.py b/test_src/test_proj/migrations/0045_dynamicfields_generated_field.py new file mode 100644 index 00000000..43660da6 --- /dev/null +++ b/test_src/test_proj/migrations/0045_dynamicfields_generated_field.py @@ -0,0 +1,19 @@ +# Generated by Django 5.0.3 on 2024-03-19 05:01 + +import django.db.models.functions.text +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('test_proj', '0044_product_uniq_name_store'), + ] + + operations = [ + migrations.AddField( + model_name='dynamicfields', + name='generated_field', + field=models.GeneratedField(db_persist=True, expression=django.db.models.functions.text.Upper('field_type'), output_field=models.CharField(max_length=100)), + ), + ] diff --git a/test_src/test_proj/models/dynamic_fields.py b/test_src/test_proj/models/dynamic_fields.py index a50ec780..73f7d855 100644 --- a/test_src/test_proj/models/dynamic_fields.py +++ b/test_src/test_proj/models/dynamic_fields.py @@ -1,4 +1,5 @@ from django.db import models +from django.db.models.functions import Upper from rest_framework.fields import CharField, IntegerField, ListField from rest_framework.serializers import Serializer from vstutils.api import fields @@ -27,9 +28,14 @@ class AnotherSerializer(BaseSerializer): class DynamicFields(BModel): field_type = models.CharField(max_length=100) dynamic_with_types = models.CharField(max_length=500) + generated_field = models.GeneratedField( + expression=Upper('field_type'), + output_field=models.CharField(max_length=100), + db_persist=True, + ) class Meta: - _list_fields = _detail_fields = ['id', 'field_type', 'dynamic_with_types'] + _list_fields = _detail_fields = ['id', 'field_type', 'dynamic_with_types', 'generated_field'] _override_list_fields = { 'dynamic_with_types': fields.DynamicJsonTypeField(field='field_type', types={ 'serializer': SomeSerializer(), diff --git a/test_src/test_proj/models/nested_models.py b/test_src/test_proj/models/nested_models.py index 16d3ced0..701ceabf 100644 --- a/test_src/test_proj/models/nested_models.py +++ b/test_src/test_proj/models/nested_models.py @@ -42,7 +42,7 @@ class Product(BaseModel): class Meta: default_related_name = 'products' constraints = [ - models.UniqueConstraint(fields=['name', 'store'], name='uniq_name_store') + models.UniqueConstraint(models.F('name'), models.F('store'), name='uniq_name_store') ] _nested = { 'options': { diff --git a/test_src/test_proj/tests.py b/test_src/test_proj/tests.py index af2acb0f..d11311e7 100644 --- a/test_src/test_proj/tests.py +++ b/test_src/test_proj/tests.py @@ -23,7 +23,6 @@ import ormsgpack import pytz from bs4 import BeautifulSoup -from django import VERSION as django_version from django.apps import apps from django.conf import settings from django.core.exceptions import ImproperlyConfigured @@ -2081,6 +2080,22 @@ def has_deep_parent_filter(params): # Check field without allow_blank will have minLength self.assertEqual(api['definitions']['OnePost']['properties']['text']['minLength'], 1) + # Check generated field type + self.assertDictEqual(api['definitions']['DynamicFields']['properties']['generated_field'], { + 'type': 'string', + 'title': 'Generated field', + 'minLength': 1, + 'maxLength': 100, + 'readOnly': True, + }) + self.assertDictEqual(api['definitions']['OneDynamicFields']['properties']['generated_field'], { + 'type': 'string', + 'title': 'Generated field', + 'minLength': 1, + 'maxLength': 100, + 'readOnly': True, + }) + # Check dynamic field with complex types nested_model = { 'type': 'object', @@ -4479,6 +4494,7 @@ def test_dynamic_field_types(self): self.assertEqual(results[3]['status'], 201) self.assertEqual(results[3]['data']['dynamic_with_types'], 5) + self.assertEqual(results[3]['data']['generated_field'], results[3]['data']['field_type'].upper()) self.assertEqual(results[4]['status'], 200) self.assertEqual(results[4]['data']['dynamic_with_types'], 5) @@ -5131,7 +5147,7 @@ def test_custom_exception_messages(self): 'data': dict( name='test prod', store=store.id, - price = 100, + price=100, manufacturer=manufacturer.id ) }, @@ -5716,6 +5732,7 @@ def test_file_reader(self): 'NAME': 'file:memorydb_default?mode=memory&cache=shared', 'TEST': { 'SERIALIZE': False, + 'MIGRATE': True, 'CHARSET': None, 'COLLATION': None, 'NAME': None, @@ -5730,8 +5747,6 @@ def test_file_reader(self): 'HOST': '', 'PORT': '' } - if django_version[0] >= 3 and django_version[1] in (1, 2): # nocv - db_default_val['TEST']['MIGRATE'] = True self.assertEqual(settings.ALLOWED_HOSTS, ['*', 'testserver']) diff --git a/tox.ini b/tox.ini index 08e29dce..e8ad5a14 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = flake,mypy,pylint,bandit,py312-django42-coverage,py38-django42-install,builddoc +envlist = flake,mypy,pylint,bandit,py312-django50-coverage,py310-django50-install,builddoc skipsdist = True allowlist_externals = rm @@ -8,8 +8,8 @@ allowlist_externals = [gh-actions] python = - 3.8: flake,mypy,pylint,bandit,py38-django42-install - 3.12: py312-django42-coverage,builddoc + 3.10: flake,mypy,pylint,bandit,py10-django50-install + 3.12: py312-django50-coverage,builddoc [testenv] passenv = * @@ -31,7 +31,7 @@ setenv = TEST_PROJ_MAIL_PORT = 26 TEST_PROJ_UWSGI_LISTEN = 121 UWSGI_PROFILE = minimal - django42: DJANGO_DEP = + django50: DJANGO_DEP = PYLINTHOME={envdir}/.pylint.d/ install: export BUILD_OPTIMIZATION=true install: export BUILD_COMPILE=true @@ -52,7 +52,7 @@ commands = coverage: pip install -U -e ..[all] coverage: python -m test_proj makemigrations vstutils vstutils_api vstutils_webpush --check coverage: coverage erase --rcfile={env:COVRC} - coverage: coverage run --rcfile={env:COVRC} {env:EXECUTE_ARGS} {posargs} + coverage: coverage run --rcfile={env:COVRC} {env:EXECUTE_ARGS} --durations 20 {posargs} coverage: coverage combine --rcfile={env:COVRC} coverage: coverage report --rcfile={env:COVRC} pip uninstall vstutils -y @@ -69,7 +69,7 @@ deps = changedir = ./ deps = flake8==6.1.0 - flake8-bugbear==23.9.16 + flake8-bugbear==24.2.6 flake8-commas==2.1.0 flake8-comprehensions==3.14.0 flake8-django==1.4.0 @@ -77,19 +77,18 @@ deps = flake8-functions==0.0.8 flake8-import-order==0.18.2 Flake8-pyproject==1.2.3 - # flake8-docstrings commands = flake8 vstutils [testenv:bandit] changedir = ./ deps = - bandit[toml]==1.7.5 + bandit[toml]==1.7.8 commands = bandit -r vstutils -c pyproject.toml [testenv:mypy] -basepython = python3.8 +basepython = python3.10 changedir = ./ setenv = DONT_YARN = true @@ -101,13 +100,13 @@ commands = mypy -p vstutils [testenv:pylint] -basepython = python3.8 +basepython = python3.10 changedir = ./ setenv = DONT_YARN = true deps = - pylint==2.17.5 - pylint-django==2.5.3 + pylint==3.1.0 + pylint-django==2.5.5 pylint-plugin-utils==0.8.2 -rrequirements-git.txt commands = @@ -146,7 +145,7 @@ deps = -rrequirements-rtd.txt [testenv:build] -basepython = python3.8 +basepython = python3.10 passenv = * changedir = . allowlist_externals = @@ -158,7 +157,7 @@ commands = deps = [testenv:contrib] -basepython = python3.8 +basepython = python3.10 skipsdist = False usedevelop = True envdir = {toxinidir}/env diff --git a/tox_build.ini b/tox_build.ini index 8df3bc1e..e8c827c0 100644 --- a/tox_build.ini +++ b/tox_build.ini @@ -1,5 +1,5 @@ [tox] -envlist = py38-build,py31{1,2}-wheel,auditwheel +envlist = py310-build,py31{1,2}-wheel,auditwheel skipsdist = True [testenv] diff --git a/vstutils/api/endpoint.py b/vstutils/api/endpoint.py index 350d101a..4dd78ab8 100644 --- a/vstutils/api/endpoint.py +++ b/vstutils/api/endpoint.py @@ -80,8 +80,7 @@ def handler(operation): return operation_handler(operation, context) with executor_class(max_workers=THREADS_COUNT) as executor: - for operation_result in executor.map(handler, _get_request_data(request.data)): - yield operation_result + yield from executor.map(handler, _get_request_data(request.data)) def _join_paths(*args) -> _t.Text: diff --git a/vstutils/api/health.py b/vstutils/api/health.py index fe35d553..914b59a4 100644 --- a/vstutils/api/health.py +++ b/vstutils/api/health.py @@ -13,7 +13,7 @@ def health_wrapper(method): def wrapper(self): try: return method(self) or 'ok', st.HTTP_200_OK - except BaseException as exception: + except Exception as exception: code = getattr(exception, 'status', st.HTTP_500_INTERNAL_SERVER_ERROR) return str(exception), code @@ -44,8 +44,7 @@ def get(self) -> Tuple[Dict, int]: result, status = {}, st.HTTP_200_OK for key, method in self.health_checks.items(): result[key], method_status = method(self) - if method_status > status: - status = method_status + status = max(status, method_status) return result, status diff --git a/vstutils/api/schema/inspectors.py b/vstutils/api/schema/inspectors.py index 1549fcda..34591bb0 100644 --- a/vstutils/api/schema/inspectors.py +++ b/vstutils/api/schema/inspectors.py @@ -388,12 +388,12 @@ class NamedBinaryImageInJsonFieldInspector(FieldInspector): x_nullable=v is None, ) for k, v in fields.DEFAULT_NAMED_FILE_DATA.items() - } + }, } default_multiple_schema_data = { 'type': openapi.TYPE_ARRAY, - 'items': openapi.Items(**default_schema_data) # type: ignore + 'items': openapi.Items(**default_schema_data), # type: ignore } def field_to_swagger_object(self, field, swagger_object_type, use_references, **kw): diff --git a/vstutils/api/serializers.py b/vstutils/api/serializers.py index b9f68e40..23979493 100644 --- a/vstutils/api/serializers.py +++ b/vstutils/api/serializers.py @@ -4,7 +4,7 @@ Read more in Django REST Framework documentation for `Serializers `_. """ - +import copy import typing as _t import orjson @@ -172,13 +172,25 @@ class Meta: }) def build_standard_field(self, field_name, model_field): - field_class, field_kwargs = super().build_standard_field(field_name, model_field) + if isinstance(model_field, models.GeneratedField): + model_field_copy = copy.copy(model_field.output_field) + model_field_copy.model = model_field.model + field_class, field_kwargs = super().build_standard_field(field_name, model_field_copy) + field_kwargs['read_only'] = True + else: + field_class, field_kwargs = super().build_standard_field(field_name, model_field) + if isinstance(model_field, models.FileField) and issubclass(field_class, fields.NamedBinaryFileInJsonField): field_kwargs['file'] = True if model_field.max_length: field_kwargs['max_length'] = model_field.max_length if isinstance(model_field.upload_to, str): field_kwargs['max_length'] -= len(model_field.upload_to) + + if issubclass(field_class, fields.NamedBinaryFileInJsonField) and isinstance(model_field, models.TextField): + if isinstance(model_field.default, str): + field_kwargs['default'] = orjson.loads(model_field.default) if model_field.default else drf_fields.empty + return field_class, field_kwargs def build_relational_field(self, field_name, relation_info): diff --git a/vstutils/management/commands/rpc_worker.py b/vstutils/management/commands/rpc_worker.py index 3ea5b828..ae3c2499 100644 --- a/vstutils/management/commands/rpc_worker.py +++ b/vstutils/management/commands/rpc_worker.py @@ -98,7 +98,7 @@ def handle(self, *args, **options): # nocv raise exc except KeyboardInterrupt: # nocv self._print('Exit by user...', 'WARNING') - except BaseException as err: # nocv - self._print(traceback.format_exc()) - self._print(str(err), 'ERROR') - sys.exit(1) + except BaseException as err: # noqa: B036 + self._print(traceback.format_exc()) # nocv + self._print(str(err), 'ERROR') # nocv + sys.exit(1) # nocv diff --git a/vstutils/models/fields.py b/vstutils/models/fields.py index 5500e4b9..bc16a21d 100644 --- a/vstutils/models/fields.py +++ b/vstutils/models/fields.py @@ -208,7 +208,7 @@ class MultipleImageField(MultipleFileMixin, ImageField): description = "List of Images" def update_dimension_fields(self, instance, force=False, *args, **kwargs): - pass + pass # nocv class NamedBinaryFileInJSONField(TextField): diff --git a/vstutils/tests.py b/vstutils/tests.py index b44a160f..1efd8a03 100644 --- a/vstutils/tests.py +++ b/vstutils/tests.py @@ -100,7 +100,7 @@ def _login(self): return client def _logout(self, client): - self.assertEqual(client.get(self.logout_url).status_code, 302) + self.assertEqual(client.post(self.logout_url).status_code, 302) def _check_update(self, url, data, **fields): """ diff --git a/vstutils/utils.py b/vstutils/utils.py index f44fff86..0e6e781a 100644 --- a/vstutils/utils.py +++ b/vstutils/utils.py @@ -1260,8 +1260,7 @@ def opts(self, name): def get_static_objects(self): for name in self.keys(): - for result in self.get_object(name).spa_static_list: - yield result + yield from self.get_object(name).spa_static_list def get_sorted_list(self): return tuple(multikeysort(self.get_static_objects(), ['priority'])) From 210d6416b22ae57b4a894a6420f0a8082e8c1804 Mon Sep 17 00:00:00 2001 From: Sergei Kliuikov Date: Tue, 19 Mar 2024 22:38:28 -0700 Subject: [PATCH 02/46] Fix(backend): Exception on EmptyResultSet for recursive queries. Closes vst/vst-utils#642 --- test_src/test_proj/tests.py | 5 +++++ vstutils/models/queryset.py | 16 +++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/test_src/test_proj/tests.py b/test_src/test_proj/tests.py index d11311e7..b10f7f35 100644 --- a/test_src/test_proj/tests.py +++ b/test_src/test_proj/tests.py @@ -5245,6 +5245,11 @@ def test_deep_nested(self): def test_m2m_fk_deep_nested(self): Group = self.get_model_class('test_proj.Group') + + # check empty result set + qs = Group.objects.filter(id__in=()).get_children(True) + self.assertTrue(qs.query.is_empty()) + results = self.bulk([ {'method': 'post', 'path': 'group', 'data': {'name': '1'}}, {'method': 'post', 'path': ['group', '<<0[data][id]>>', 'childrens'], 'data': {'name': '1.1'}}, diff --git a/vstutils/models/queryset.py b/vstutils/models/queryset.py index f8b4a5bc..837fbf1d 100644 --- a/vstutils/models/queryset.py +++ b/vstutils/models/queryset.py @@ -4,6 +4,7 @@ from django.db import models from django.db.models.expressions import RawSQL +from django.core.exceptions import EmptyResultSet from django.utils.functional import cached_property, lazy from django.conf import settings @@ -146,12 +147,19 @@ def _get_deep_nested_qs_with_cte(self, with_current=False, deep_children=True): initial_qs.query.clear_select_fields() initial_qs.query.clear_select_clause() initial_qs = initial_qs.values(origin_model_pk) + initial_qs.query.clear_limits() + + try: + initial_sql = str(initial_qs.query) + except EmptyResultSet: + return initial_qs.none() + sql = ' '.join(( 'WITH RECURSIVE NRQ777 as (', f'SELECT NU777.{sql_column_to_get}, NU777.{sql_deep_column}', # noqa: E131 f'FROM {sql_table} NU777', f'WHERE NU777.{sql_deep_column}', - f'IN ({str(initial_qs.query)})', # noqa: E131 + f'IN ({initial_sql})', # noqa: E131 'UNION', # noqa: E131 f'SELECT NU777_1.{sql_column_to_get}, NU777_1.{sql_deep_column}', f'FROM {sql_table} NU777_1', @@ -162,8 +170,10 @@ def _get_deep_nested_qs_with_cte(self, with_current=False, deep_children=True): )) if with_current: - sql += f' UNION {initial_qs.query}' - return self.model.objects.filter(id__in=RawSQL(sql, [])) # nosec + sql += f' UNION {initial_sql}' + chain = self.model.objects.filter(id__in=RawSQL(sql, [])) # nosec + chain.query.set_limits(self.query.low_mark, self.query.high_mark) + return chain def _deep_nested_ids_without_cte(self, accumulated=None, deep_children=True): deep_parent_field = self.model.deep_parent_field From 121c0d56cf5d316e4f063ea8eacf5ae336ce0b1b Mon Sep 17 00:00:00 2001 From: Sergei Kliuikov Date: Tue, 19 Mar 2024 22:57:29 -0700 Subject: [PATCH 03/46] Fix(backend): Add enum values for django filters. Closes vst/vst-utils#643 --- vstutils/api/filter_backends.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/vstutils/api/filter_backends.py b/vstutils/api/filter_backends.py index e58a79d4..d69b2d38 100644 --- a/vstutils/api/filter_backends.py +++ b/vstutils/api/filter_backends.py @@ -42,14 +42,14 @@ def get_openapi_field_schema(self, field_name, field, queryset): field_type = openapi.TYPE_NUMBER elif isinstance(field, filters.BooleanFilter): field_type = openapi.TYPE_BOOLEAN - elif isinstance(field, filters.ChoiceFilter): + elif isinstance(field, (filters.ChoiceFilter, filters.MultipleChoiceFilter)): kwargs['enum'] = tuple(dict(field.field.choices).keys()) elif field_name in {'id', 'id__not'}: search_field = field_name.split('__')[0] m_field = next((f for f in queryset.model._meta.fields if f.name == search_field), None) field_type, kwargs_update = get_field_type_from_queryset(m_field) - if field.method == extra_filter: + if field.method == extra_filter or isinstance(field, (filters.MultipleChoiceFilter, filters.BaseCSVFilter)): kwargs = { 'items': { 'type': field_type, From a823337c9af9579a8b3a4ac4fe6e6bf1a3bdda75 Mon Sep 17 00:00:00 2001 From: Alexander Vyaznikov Date: Wed, 3 Apr 2024 16:06:38 +1000 Subject: [PATCH 04/46] Fix(backend): Fixed popUp translations --- frontend_src/vstutils/popUp/PopUp.js | 19 ++++++++++--------- vstutils/translations/cn.py | 12 ++++++++---- vstutils/translations/ru.py | 12 ++++++++---- vstutils/translations/vi.py | 12 ++++++++---- 4 files changed, 34 insertions(+), 21 deletions(-) diff --git a/frontend_src/vstutils/popUp/PopUp.js b/frontend_src/vstutils/popUp/PopUp.js index 8175b65d..8487cd1e 100644 --- a/frontend_src/vstutils/popUp/PopUp.js +++ b/frontend_src/vstutils/popUp/PopUp.js @@ -1,4 +1,5 @@ import iziToast from 'izitoast'; +import { i18n } from '@/vstutils/translation'; /** * Class, that is responsible for showing of pop up notification. @@ -62,7 +63,11 @@ export class PopUp { */ _generatePopUp(type = 'show', message = '', opt = {}) { opt.message = message; - return this._showPopUp(type, this._getPopUpSettings(type, opt)); + const settings = this._getPopUpSettings(type, opt); + if (settings.title) { + settings.title = i18n.t(settings.title); + } + return this._showPopUp(type, settings); } /** * Method, that generates default pop up notification. @@ -164,8 +169,8 @@ export let pop_up_msg = { remove: '{1} "{0}" was successfully removed.', removeMany: 'Selected instances were successfully removed', save: 'Changes in {1} {0} were successfully saved.', - executeEmpty: 'Action {0} was successfully executed' + ' on {1}.
{2}', - execute: 'Action {0} was successfully executed.
{1}', + executeEmpty: 'Action {0} was successfully executed on {1}.
{2}', + execute: 'Action {0} was successfully executed on {1}.
{2}', }, error: { add: @@ -173,15 +178,11 @@ export let pop_up_msg = { ' to parent list.' + '
Error details: {1}', create: 'An error occurred during creation.
Error details:
{0}', - remove: - 'An error occurred during removal process of {1} "{0}".' + - '
Error details: {2}', + remove: 'An error occurred during removal process of {1} "{0}".
Error details: {2}', removeMany: 'An error occurred during removal process. Error details: {0}', save: 'An error occurred during process.
Error details:
{0}', executeEmpty: - 'Some error occurred during {0} action execution on {1}.' + - '
Error details: {2}' + - '
{3}', + 'Some error occurred during {0} action execution on {1}.
Error details: {2}
{3}', execute: 'An error occurred during {0}. Error details:
{1}', }, }, diff --git a/vstutils/translations/cn.py b/vstutils/translations/cn.py index 213a49b8..bcaf562a 100644 --- a/vstutils/translations/cn.py +++ b/vstutils/translations/cn.py @@ -229,22 +229,26 @@ # create 'New "{0}" instance was successfully created.': '新对象“ {0} ”已成功创建.', # remove - '"{0}" {1} was successfully removed.': '"{0}" {1} 已成功删除."', + '{1} "{0}" was successfully removed.': '{1} "{0}" 已成功删除."', # save - 'Changes in "{0}" {1} were successfully saved.': '对象变化{1}“ {0} ”的更改已成功保存.', + 'Changes in {1} {0} were successfully saved.': '对象变化 {1} {0}”的更改已成功保存.', # execute - 'Action "{0}" was successfully executed on "{1}" instance.': '在对象“ {1} ”上成功启动了操作“ {0} ”。', + 'Action {0} was successfully executed on {1}.
{2}': '在对象 {0} 上成功启动了操作 {1}.
{2}', # instance operation error # add: 'An error occurred during adding of child "{0}" instance to parent list.
Error details: {1}': '将对象“ {0} ”添加到目级列表时发生错误。
更多详细信息:{1}', # create: 'An error occurred during creation.
Error details:
{0}': '创建新对象时发生错误
更多详细信息:{0}', # remove: - 'An error occurred during removal process of "{0}" {1}.
Error details: {2}': '删除{1}“ {0} ”时发生错误
更多详细信息:{2}', + 'An error occurred during removal process of {1} "{0}".
Error details: {2}': '删除{1}“ {0} ”时发生错误
更多详细信息:{2}', + # removeMany: + 'An error occurred during removal process. Error details: {0}': '刪除過程中發生錯誤。錯誤詳細資訊:{0}', # save: 'An error occurred during process.
Error details:
{0}': '保存时发生错误。
更多详细信息:{0}', # execute: 'An error occurred during {0}. Error details:
{1}': '{0} 期间发生了一些错误。 错误详情:
{1}', + # Execute Empty: + 'Some error occurred during {0} action execution on {1}.
Error details: {2}
{3}': '{1} 上的 {0} 操作執行期間發生了一些錯誤。
錯誤詳細資料:{2}
{3}', 'Link': '链接', # csv diff --git a/vstutils/translations/ru.py b/vstutils/translations/ru.py index 4a34a657..c1a7335d 100644 --- a/vstutils/translations/ru.py +++ b/vstutils/translations/ru.py @@ -228,22 +228,26 @@ # create 'New "{0}" instance was successfully created.': 'Новый объект "{0}" был успешно создан.', # remove - '"{0}" {1} was successfully removed.': '"{0}" {1} был(а) успешно удален.', + '{1} "{0}" was successfully removed.': '{1} "{0}" был(а) успешно удален.', # save - 'Changes in "{0}" {1} were successfully saved.': 'Изменения в объекте {1} "{0}" были успешно сохранены.', + 'Changes in {1} {0} were successfully saved.': 'Изменения в объекте {1} {0} были успешно сохранены.', # execute - 'Action "{0}" was successfully executed on "{1}" instance.': 'Действие "{0}" было успешно запущено на объекте "{1}".', + 'Action {0} was successfully executed on {1}.
{2}': 'Действие {0} было успешно запущено на объекте {1}.
{2}', # instance operation error # add: 'An error occurred during adding of child "{0}" instance to parent list.
Error details: {1}': 'Во время добавления дочернего объекта "{0}" к родительскому списку произошла ошибка.
Подробнее: {1}', # create: 'An error occurred during creation.
Error details:
{0}': 'Во время создания нового объекта произошла ошибка.
Подробнее:
{0}', # remove: - 'An error occurred during removal process of "{0}" {1}.
Error details: {2}': 'Во время удаления {1} "{0}" произошла ошибка.
Подробнее: {2}', + 'An error occurred during removal process of {1} "{0}".
Error details: {2}': 'Во время удаления {1} "{0}" произошла ошибка.
Подробнее: {2}', + # removeMany: + 'An error occurred during removal process. Error details: {0}': 'Во время удаления произошла ошибка. Подробнее: {0}', # save: 'An error occurred during process.
Error details:
{0}': 'Во время сохранения произошла ошибка.
Подробнее:
{0}', # execute: 'An error occurred during {0}. Error details:
{1}': 'Во время запуска действия {0} произошла ошибка. Подробнее:
{1}', + # Execute Empty: + 'Some error occurred during {0} action execution on {1}.
Error details: {2}
{3}': 'Произошла ошибка во время выполнения действия {0} на {1}.
Подробнее: {2}
{3}', 'Link': 'Ссылка', # csv 'Actions': 'Действия', diff --git a/vstutils/translations/vi.py b/vstutils/translations/vi.py index 9872b0db..333323c2 100644 --- a/vstutils/translations/vi.py +++ b/vstutils/translations/vi.py @@ -229,22 +229,26 @@ # create 'New "{0}" instance was successfully created.': 'Đối tượng mới " {0} " đã được tạo thành công.', # remove - '"{0}" {1} was successfully removed.': '"{0}" {1} đã được xóa thành công."', + '{1} "{0}" was successfully removed.': '{1} "{0}" đã được xóa thành công."', # save - 'Changes in "{0}" {1} were successfully saved.': 'Thay đổi đối tượng {1} " {0} " đã được lưu thành công.', + 'Changes in {1} {0} were successfully saved.': 'Thay đổi đối tượng {1} {0} đã được lưu thành công.', # execute - 'Action "{0}" was successfully executed on "{1}" instance.': 'Thao tác " {0} " đã được thực hiện thành công trên đối tượng " {1} ".', + 'Action {0} was successfully executed on {1}.
{2}': 'Thao tác " {0} " đã được thực hiện thành công trên đối tượng {1}.
{2}', # instance operation error # add: 'An error occurred during adding of child "{0}" instance to parent list.
Error details: {1}': 'Khi thêm đối tượng con " {0} " vào danh sách mẹ đã xảy ra lỗi
thêm chi tiết: {1}', # create: 'An error occurred during creation.
Error details:
{0}': 'Đã xảy ra một số lỗi khi tạo phiên bản mới.
Chi tiết lỗi:
{0}', # remove: - 'An error occurred during removal process of "{0}" {1}.
Error details: {2}': 'Khi xóa {1} " {0} ". đã xảy ra lỗi
thêm chi tiết: {2}', + 'An error occurred during removal process of {1} "{0}".
Error details: {2}': 'Khi xóa {1} " {0} ". đã xảy ra lỗi
thêm chi tiết: {2}', + # removeMany: + 'An error occurred during removal process. Error details: {0}': 'Đã xảy ra lỗi trong quá trình xóa. Chi tiết lỗi: {0}', # save: 'An error occurred during process.
Error details:
{0}': 'Đã xảy ra một số lỗi trong quá trình lưu.
Chi tiết lỗi:
{0}', # execute: 'An error occurred during {0}. Error details:
{1}': 'Đã xảy ra một số lỗi trong {0} . Chi tiết lỗi:
{1}', + # Execute Empty: + 'Some error occurred during {0} action execution on {1}.
Error details: {2}
{3}': 'Đã xảy ra một số lỗi trong quá trình thực thi hành động {0 trên {1.
Chi tiết lỗi: {2}
{3}', 'Link': 'Liên kết', # csv From ee1a9825d13d6f67cb74ea4124730dd22497db9c Mon Sep 17 00:00:00 2001 From: Alexander Vyaznikov Date: Wed, 10 Apr 2024 15:38:38 +1000 Subject: [PATCH 05/46] Fix(frontend): Fixed card word break --- frontend_src/vstutils/AppRoot.vue | 3 +++ frontend_src/vstutils/components/Card.vue | 3 +++ 2 files changed, 6 insertions(+) diff --git a/frontend_src/vstutils/AppRoot.vue b/frontend_src/vstutils/AppRoot.vue index 96a29d65..10dc37cd 100644 --- a/frontend_src/vstutils/AppRoot.vue +++ b/frontend_src/vstutils/AppRoot.vue @@ -211,6 +211,9 @@ +./schema diff --git a/frontend_src/vstutils/ComponentsRegistrator.js b/frontend_src/vstutils/ComponentsRegistrator.js index 216fcd98..b49c1389 100644 --- a/frontend_src/vstutils/ComponentsRegistrator.js +++ b/frontend_src/vstutils/ComponentsRegistrator.js @@ -24,10 +24,9 @@ class ComponentsRegistrator { */ registerAll(vue) { for (let [name, component] of Object.entries(this.components)) { - if (vue.options.components[name]) { - throw new Error(`Component ${name} already registered`); + if (!vue.options.components[name]) { + vue.component(name, component); } - vue.component(name, component); } } } diff --git a/frontend_src/vstutils/OpenAPILoader.ts b/frontend_src/vstutils/OpenAPILoader.ts new file mode 100644 index 00000000..c6655499 --- /dev/null +++ b/frontend_src/vstutils/OpenAPILoader.ts @@ -0,0 +1,49 @@ +import { createApiFetch } from '@/vstutils/api-fetch'; +import { InitAppConfig } from './init-app'; +import { Cache } from '@/cache'; +import { type AppSchema } from './schema'; + +/** + * Class, that is responsible for loading of OpenAPI schema. + * Class has methods for loading of OpenAPI schema from API as well as from cache. + */ +export default class OpenAPILoader { + config: InitAppConfig; + cacheKey: string; + cache: Cache; + anon: boolean; + + constructor({ config, anon }: { config: InitAppConfig; anon?: boolean }) { + /** + * Object, that methods for manipulating with indexedDB. Instance of FilesCache. + */ + this.cache = config.cache; + this.config = config; + this.anon = anon ?? false; + this.cacheKey = anon ? 'openapi-anon' : 'openapi'; + } + + async loadSchemaFromApi(): Promise { + const apiFetch = this.anon ? fetch : createApiFetch({ config: this.config }); + const res = await apiFetch(new URL('endpoint/?format=openapi', this.config.api.url)); + if (!res.ok) { + throw new Error('API request for loading of OpenAPI schema failed.'); + } + return res.json(); + } + + async loadSchema(): Promise { + const cached = await this.cache.getJson(this.cacheKey); + if (cached) { + return cached as AppSchema; + } + try { + const schema = await this.loadSchemaFromApi(); + this.cache.set(this.cacheKey, JSON.stringify(schema)); + return schema; + } catch (error) { + console.error('Some error occurred during attempt of getting of OpenAPI schema.'); + throw error; + } + } +} diff --git a/frontend_src/vstutils/__tests__/AggregatedQueriesExecutor.test.js b/frontend_src/vstutils/__tests__/AggregatedQueriesExecutor.test.js index c03e88e6..71d77535 100644 --- a/frontend_src/vstutils/__tests__/AggregatedQueriesExecutor.test.js +++ b/frontend_src/vstutils/__tests__/AggregatedQueriesExecutor.test.js @@ -1,5 +1,4 @@ -import { expect, beforeEach, test, describe, beforeAll } from '@jest/globals'; -import fetchMock from 'jest-fetch-mock'; +import { createSchema, createApp } from '@/unittests'; import { makeModel, BaseModel } from '../models'; import { QuerySet } from '../querySet/QuerySet.ts'; import { StringField } from '../fields/text/'; @@ -7,13 +6,10 @@ import { IntegerField } from '../fields/numbers/integer.js'; import { RequestTypes } from '../utils'; import { AggregatedQueriesExecutor } from '../AggregatedQueriesExecutor.js'; import { NotFoundError } from '../querySet/errors.js'; -import { apiConnector } from '../api/ApiConnector'; -import { createAppConfig } from '../../unittests/create-app'; describe('AggregatedQueriesExecutor', () => { - beforeAll(() => { - apiConnector.initConfiguration(createAppConfig()); - fetchMock.enableMocks(); + beforeAll(async () => { + await createApp({ schema: createSchema() }); }); beforeEach(() => { diff --git a/frontend_src/vstutils/__tests__/actions.test.js b/frontend_src/vstutils/__tests__/actions.test.js index 7410bd2f..f6db2639 100644 --- a/frontend_src/vstutils/__tests__/actions.test.js +++ b/frontend_src/vstutils/__tests__/actions.test.js @@ -1,6 +1,4 @@ -import { test, describe, beforeAll, expect, jest, beforeEach } from '@jest/globals'; -import fetchMock from 'jest-fetch-mock'; -import { createApp } from '../../unittests/create-app.js'; +import { expectNthRequest, createApp } from '@/unittests'; describe('Actions', () => { /** @type {App} */ @@ -9,7 +7,6 @@ describe('Actions', () => { beforeAll(() => { return createApp().then((a) => { app = a; - fetchMock.enableMocks(); }); }); @@ -19,7 +16,7 @@ describe('Actions', () => { test('empty actions execution', async () => { fetchMock.mockOnce(JSON.stringify([{ status: 200, data: { some: 'value' } }])); - const callback = jest.fn(); + const callback = vitest.fn(); const action = { name: 'test_action', title: 'Test actions', @@ -31,9 +28,11 @@ describe('Actions', () => { await app.actions.execute({ action }); expect(fetchMock.mock.calls).toHaveLength(1); - const [url, req] = fetchMock.mock.calls[0]; - expect(url).toBe('http://localhost/api/endpoint/'); - expect(JSON.parse(req.body)).toEqual([{ method: 'put', path: '/test/path/' }]); + expectNthRequest(0, { + method: 'PUT', + url: 'http://localhost/api/endpoint/', + body: [{ method: 'put', path: '/test/path/' }], + }); expect(callback).toBeCalledTimes(1); const arg = callback.mock.calls[0][0]; @@ -45,7 +44,7 @@ describe('Actions', () => { }); test('action with custom handler', () => { - const action = { name: 'test_action', handler: jest.fn() }; + const action = { name: 'test_action', handler: vitest.fn() }; app.actions.execute({ action }); expect(action.handler).toBeCalledTimes(1); @@ -80,10 +79,10 @@ describe('Actions', () => { }); expect(result.data).toEqual({ some: 'return val' }); expect(fetchMock).toBeCalledTimes(1); - const [url, req] = fetchMock.mock.calls[0]; - expect(url).toBe('http://localhost/api/endpoint/'); - expect(JSON.parse(req.body)).toEqual([ - { method: 'patch', data: { testField: 'some val' }, path: 'execute' }, - ]); + expectNthRequest(0, { + method: 'PUT', + path: '/api/endpoint/', + body: [{ method: 'patch', data: { testField: 'some val' }, path: 'execute' }], + }); }); }); diff --git a/frontend_src/vstutils/__tests__/composables.test.js b/frontend_src/vstutils/__tests__/composables.test.js index 725953b6..8fc114bd 100644 --- a/frontend_src/vstutils/__tests__/composables.test.js +++ b/frontend_src/vstutils/__tests__/composables.test.js @@ -1,4 +1,3 @@ -import { test, beforeAll, expect, jest } from '@jest/globals'; import { reactive, computed } from 'vue'; import { createApp } from '@/unittests'; import { StringField } from '@/vstutils/fields/text'; @@ -22,7 +21,7 @@ test('useModelFieldsGroups', () => { const data = { field3: 'some value' }; - Model.fieldsGroups = jest.fn(() => [{ title: 'Some group', fields: ['field2'] }]); + Model.fieldsGroups = vitest.fn(() => [{ title: 'Some group', fields: ['field2'] }]); const filteredGroups = computed(() => getModelFieldsInstancesGroups(Model, data)); diff --git a/frontend_src/vstutils/__tests__/selected-filters.test.js b/frontend_src/vstutils/__tests__/selected-filters.test.js index f81e9c10..64b22d6b 100644 --- a/frontend_src/vstutils/__tests__/selected-filters.test.js +++ b/frontend_src/vstutils/__tests__/selected-filters.test.js @@ -1,16 +1,8 @@ -import { expect, test, beforeAll } from '@jest/globals'; -import fetchMock from 'jest-fetch-mock'; -import { createApp, createSchema, mountApp, waitForPageLoading } from '@/unittests'; - -let app; - -beforeAll(async () => { - app = await createApp({ schema: createSchema() }); - fetchMock.enableMocks(); -}); +import { createApp, createSchema, useTestCtx, waitForPageLoading } from '@/unittests'; test('Selected filters', async () => { - const wrapper = await mountApp(); + const app = await createApp({ schema: createSchema() }); + const { wrapper } = useTestCtx(); fetchMock.mockResponseOnce(JSON.stringify([{ status: 200, data: { count: 0, results: [] } }])); await app.router.push('/user?id=1,2,3'); diff --git a/frontend_src/vstutils/api-fetch.ts b/frontend_src/vstutils/api-fetch.ts new file mode 100644 index 00000000..d5344aa9 --- /dev/null +++ b/frontend_src/vstutils/api-fetch.ts @@ -0,0 +1,39 @@ +import type { InitAppConfig } from '@/vstutils/init-app'; + +type InternalFetch = (req: Request) => Promise; + +interface CreateApiFetchParams { + fetch?: typeof fetch; + config: InitAppConfig; +} + +export function createApiFetch({ config, fetch: _fetch = window.fetch }: CreateApiFetchParams): typeof fetch { + return internalFetchToFetch(createOauth2Fetch(config, _fetch)); +} + +function addHeadersToRequest(request: Request, headers: Record): Request { + const newHeaders = new Headers(request.headers); + for (const [key, value] of Object.entries(headers)) { + newHeaders.set(key, value); + } + return new Request(request, { headers: newHeaders }); +} + +function createOauth2Fetch(config: InitAppConfig, _fetch: InternalFetch): InternalFetch { + const userManger = config.auth.userManager; + return async (request: Request) => { + const user = await userManger.getUser(); + if (!user) { + throw new Error('User is not logged in'); + } + return _fetch( + addHeadersToRequest(request, { Authorization: `${user.token_type} ${user.access_token}` }), + ); + }; +} + +function internalFetchToFetch(_fetch: InternalFetch): typeof fetch { + return function fetch(input: RequestInfo | URL, init?: RequestInit) { + return _fetch(new Request(input, init)); + }; +} diff --git a/frontend_src/vstutils/api/ApiConnector.ts b/frontend_src/vstutils/api/ApiConnector.ts index ee8da51f..26d38762 100644 --- a/frontend_src/vstutils/api/ApiConnector.ts +++ b/frontend_src/vstutils/api/ApiConnector.ts @@ -1,8 +1,11 @@ -import { BulkType, getCookie, guiLocalSettings, makeQueryString } from '@/vstutils/utils'; +import { BulkType, guiLocalSettings, makeQueryString } from '@/vstutils/utils'; import { StatusError } from './StatusError'; -import type { AppConfiguration, AppSchema } from '@/vstutils/AppConfiguration'; +import type { AppSchema } from '@/vstutils/schema'; import type { HttpMethod, InnerData } from '@/vstutils/utils'; +import { createApiFetch } from '../api-fetch'; +import { type InitAppConfig } from '../init-app'; +import { type IApp } from '../app'; const isNativeCacheAvailable = 'caches' in window; @@ -130,48 +133,52 @@ export type MakeRequestParams = MakeRequestParamsBulk | MakeRequestParamsFetch | * Class, that sends API requests. */ export class ApiConnector { - appConfig: AppConfiguration | null = null; + appConfig: InitAppConfig | null = null; openapi: AppSchema | null = null; defaultVersion: string | null = null; endpointURL: string | null = null; - headers: Record; + headers: Record; bulkCollector: BulkCollector = { bulkParts: [] }; baseURL: string | null = null; disableBulk = false; private _etagsCachePrefix: string | null = null; private _etagsCacheName: string | null = null; + fetch: typeof fetch = window.fetch; /** * Constructor of ApiConnector class. */ constructor() { this.headers = {}; + } - const csrftoken = getCookie('csrftoken'); - if (csrftoken) { - this.headers['X-CSRFToken'] = csrftoken; - } + private initPromises: Promise[] = []; + + initialized() { + return Promise.all(this.initPromises); } /** * Method that sets application configuration. Must be called before making any requests. */ - initConfiguration(appConfig: AppConfiguration): this { - this.appConfig = appConfig; - this.openapi = appConfig.schema; + initConfiguration(app: IApp): this { + this.appConfig = app.config; + this.openapi = app.schema; this.defaultVersion = this.openapi.info.version; - this.endpointURL = String(appConfig.endpointUrl); // TODO fetchMock does not support URL + this.endpointURL = String(new URL(app.config.api.endpointPath, app.config.api.url)); // TODO fetchMock does not support URL - // remove version and ending slash from path (/api/v1/) - const path = this.openapi.basePath?.replace(this.defaultVersion, '').replace(/\/$/, ''); - this.baseURL = `${this.openapi.schemes![0]}://${this.openapi.host!}${path!}`; + this.baseURL = new URL(app.config.api.url).toString().replace(/\/$/, ''); if (isNativeCacheAvailable) { this._etagsCachePrefix = 'etags-cache'; - this._etagsCacheName = `${this._etagsCachePrefix}-${this.appConfig.fullUserVersion}`; + // TODO full version required for cache invalidation + this._etagsCacheName = `${this._etagsCachePrefix}-${app.version}`; void this._removeOldEtagsCaches(); } + this.fetch = createApiFetch({ config: app.config }); + this.disableBulk = app.config.api.disableBulk; + return this; } @@ -200,7 +207,7 @@ export class ApiConnector { if (query) { realBulk.query = query; } - if (req.headers) { + if (req.headers && Object.keys(req.headers).length > 0) { realBulk.headers = req.headers; } if (req.data) { @@ -224,7 +231,7 @@ export class ApiConnector { const pathStr = Array.isArray(req.path) ? req.path.join('/') : req.path.replace(/^\//, ''); const pathToSend = `${this.getFullUrl(pathStr)}${makeQueryString(req.query)}`; - const response = await fetch(pathToSend, { + const response = await this.fetch(pathToSend, { method: req.method, headers, body: preparedData, @@ -314,7 +321,7 @@ export class ApiConnector { } async sendBulk(requests: RealBulkRequest[], type = BulkType.SIMPLE) { - const response = await fetch(this.endpointURL!, { + const response = await this.fetch(this.endpointURL!, { method: type, headers: { ...this.headers, 'Content-Type': 'application/json' }, body: JSON.stringify(requests), @@ -371,6 +378,9 @@ export class ApiConnector { } else { results = await this.sendBulk(bulkData); } + if (results.length < bulkParts.length) { + throw new Error('Responses count does not match requests count'); + } for (const [idx, item] of results.entries()) { try { APIResponse.checkStatus(item.status, item.data); @@ -412,8 +422,8 @@ export class ApiConnector { * Method, that loads data of authorized user. */ loadUser() { - return this.bulkQuery({ - path: ['user', this.getUserId()], + return this.makeRequest({ + path: ['user', 'profile'], method: 'get', }).then((response) => { return response.data; diff --git a/frontend_src/vstutils/api/TranslationsManager.ts b/frontend_src/vstutils/api/TranslationsManager.ts index 726f1fc4..693bc3f7 100644 --- a/frontend_src/vstutils/api/TranslationsManager.ts +++ b/frontend_src/vstutils/api/TranslationsManager.ts @@ -1,30 +1,35 @@ import type { Cache } from '@/cache'; import type { LocaleMessageObject } from 'vue-i18n'; -import { HttpMethods } from '../utils'; +import { type InitAppConfig } from '@/vstutils/init-app'; export interface Language { code: string; name: string; } -import type { ApiConnector } from './ApiConnector'; - /** * Class for requesting translations related data */ export class TranslationsManager { - api: ApiConnector; cache: Cache; + config: InitAppConfig; - constructor(api: ApiConnector, cache: Cache) { - this.api = api; - this.cache = cache; + constructor(config: InitAppConfig) { + this.cache = config.cache; + this.config = config; } - loadLanguages(): Promise { - return this.api - .bulkQuery<{ results: Language[] }>({ path: '/_lang/', method: HttpMethods.GET }) - .then((response) => response.data.results); + async loadLanguages(): Promise { + const response = await fetch( + new Request(new URL(this.config.api.endpointPath, this.config.api.url), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([{ method: 'GET', path: ['_lang'] }]), + }), + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const data = (await response.json())[0].data as { results: Language[] }; + return data.results; } /** @@ -44,13 +49,17 @@ export class TranslationsManager { /** * Method, that loads translations for some language from API. */ - loadTranslations(lang: string): Promise { - return this.api - .bulkQuery<{ translations: LocaleMessageObject }>({ - path: ['_lang', lang], - method: HttpMethods.GET, - }) - .then((response) => response.data.translations); + async loadTranslations(lang: string): Promise { + const response = await fetch( + new Request(new URL(this.config.api.endpointPath, this.config.api.url), { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify([{ method: 'GET', path: ['_lang', lang] }]), + }), + ); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const data = (await response.json())[0].data as { translations: LocaleMessageObject }; + return data.translations; } /** diff --git a/frontend_src/vstutils/app.ts b/frontend_src/vstutils/app.ts index 7c13684b..feae65b8 100644 --- a/frontend_src/vstutils/app.ts +++ b/frontend_src/vstutils/app.ts @@ -1,6 +1,6 @@ -import type { Vue } from 'vue/types/vue'; import type { ComponentOptions } from 'vue'; import type VueRouter from 'vue-router'; +import Vue from 'vue'; import Centrifuge from 'centrifuge'; import { defineStore } from 'pinia'; @@ -11,7 +11,7 @@ import type { ApiConnector } from '@/vstutils/api'; import { apiConnector } from '@/vstutils/api'; import type { Language } from '@/vstutils/api/TranslationsManager'; import { TranslationsManager } from '@/vstutils/api/TranslationsManager'; -import type { AppConfiguration } from '@/vstutils/AppConfiguration'; +import type { AppSchema } from '@/vstutils/schema'; import AppRoot from '@/vstutils/AppRoot.vue'; import { AutoUpdateController } from '@/vstutils/autoupdate'; import type { ComponentsRegistrator } from '@/vstutils/ComponentsRegistrator'; @@ -37,6 +37,7 @@ import type { IView, BaseView } from '@/vstutils/views'; import { ListView, PageNewView, PageView, ViewsTree } from '@/vstutils/views'; import ViewConstructor from '@/vstutils/views/ViewConstructor.js'; import { setupPushNotifications } from '@/vstutils/webpush'; +import { type InitAppConfig } from '@/vstutils/init-app'; import type { Cache } from '@/cache'; import type { InnerData } from '@/vstutils/utils'; @@ -56,11 +57,13 @@ export function getCentrifugoClient(address?: string, token?: string) { type TAppRoot = InstanceType; export interface IApp { - config: AppConfiguration; + config: InitAppConfig; vue: typeof Vue; cache: Cache; - - schema: AppConfiguration['schema']; + version: string; + defaultPageLimit: number; + schema: AppSchema; + projectName: string; fieldsResolver: FieldsResolver; modelsResolver: ModelsResolver; @@ -98,12 +101,14 @@ export interface IApp { darkModeEnabled: boolean; - start(): void; + start(): Promise; mount(target: HTMLElement | string): void; initActionConfirmationModal(options: { title: string }): Promise; openReloadPageModal(): void; setLanguage(lang: string): void; + + _mounted?: InstanceType; } export interface IAppInitialized extends IApp { @@ -117,12 +122,20 @@ export interface IAppInitialized extends IApp { store: GlobalStoreInitialized; } +interface AppParams { + config: InitAppConfig; + schema: AppSchema; + vue?: typeof Vue; +} + export class App implements IApp { - config: AppConfiguration; + config: InitAppConfig; vue: typeof Vue; cache: Cache; - - schema: AppConfiguration['schema']; + version: string; + defaultPageLimit: number; + schema: AppSchema; + projectName: string; fieldsResolver: FieldsResolver; modelsResolver: ModelsResolver; @@ -159,23 +172,27 @@ export class App implements IApp { rootVm: TAppRoot | null = null; application: unknown | null = null; + _mounted?: InstanceType; - constructor(config: AppConfiguration, cache: Cache, vue?: typeof Vue) { + constructor({ config, schema, vue }: AppParams) { globalThis.__currentApp = this; this.config = config; - this.vue = vue ?? globalThis.Vue; - this.schema = config.schema; + this.vue = vue ?? Vue; + this.schema = schema; this.router = null; - this.cache = cache; + this.cache = config.cache; this.i18n = i18n; + this.version = this.schema.info['x-versions'].application; + this.defaultPageLimit = this.schema.info['x-page-limit'] ?? 20; + this.projectName = this.schema.info.title; /** * Object, that manages connection with API (sends API requests). */ - this.api = apiConnector.initConfiguration(config); + this.api = apiConnector.initConfiguration(this); - this.translationsManager = new TranslationsManager(apiConnector, cache); + this.translationsManager = new TranslationsManager(config); this.centrifugoClient = getCentrifugoClient( this.schema.info['x-centrifugo-address'], @@ -206,9 +223,9 @@ export class App implements IApp { this.appRootComponent = AppRoot as unknown as ComponentOptions; this.additionalRootMixins = []; - this.fieldsResolver = new FieldsResolver(this.config.schema); + this.fieldsResolver = new FieldsResolver(this.schema); addDefaultFields(this.fieldsResolver); - this.modelsResolver = new ModelsResolver(this.fieldsResolver, this.config.schema); + this.modelsResolver = new ModelsResolver(this.fieldsResolver, this.schema); /** @type {QuerySetsResolver} */ this.qsResolver = null; @@ -226,10 +243,20 @@ export class App implements IApp { setupPushNotifications(this); + config.auth.userManager.startSilentRenew(); + config.auth.userManager.events.addSilentRenewError((e) => { + if ('error' in e && e.error === 'invalid_request') { + window.location.reload(); + return; + } + console.log('Silent renew error', e); + }); + signals.emit(APP_CREATED, this); } async start() { + await this.api.initialized(); if (this.centrifugoClient) { this.centrifugoClient.connect(); } @@ -277,7 +304,7 @@ export class App implements IApp { // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment this.views = new ViewConstructor(undefined, this.modelsResolver, this.fieldsResolver).generateViews( - this.config.schema, + this.schema, ); this.viewsTree = new ViewsTree(this.views); @@ -287,7 +314,7 @@ export class App implements IApp { } generateDefinitionsModels() { - for (const name of Object.keys(this.config.schema.definitions)) { + for (const name of Object.keys(this.schema.definitions)) { this.modelsResolver.byReferencePath(`#/definitions/${name}`); } signals.emit(SCHEMA_MODELS_CREATED, { app: this, models: this.modelsResolver._definitionsModels }); @@ -434,9 +461,9 @@ export class App implements IApp { this.rootVm = new Vue({ mixins: [this.appRootComponent, ...this.additionalRootMixins], propsData: { - info: this.config.schema.info, - x_menu: this.config.schema.info['x-menu'], - x_docs: this.config.schema.info['x-docs'], + info: this.schema.info, + x_menu: this.schema.info['x-menu'], + x_docs: this.schema.info['x-docs'], }, pinia, router: this.router, @@ -451,11 +478,12 @@ export class App implements IApp { return (this.userSettingsStore?.settings.main?.dark_mode as boolean | undefined) ?? false; } - mount(target: HTMLElement | string = '#RealBody') { + mount(target: HTMLElement | string) { if (!this.rootVm) { throw new Error('Please initialize app first'); } - this.rootVm.$mount(target); + // @ts-expect-error Vue 2 types is a mess + this._mounted = this.rootVm.$mount(target); } initActionConfirmationModal({ title }: { title: string }): Promise { diff --git a/frontend_src/vstutils/auth-app.ts b/frontend_src/vstutils/auth-app.ts new file mode 100644 index 00000000..05bc3371 --- /dev/null +++ b/frontend_src/vstutils/auth-app.ts @@ -0,0 +1,15 @@ +import { type InitAppConfig } from './init-app'; + +export interface CreateAuthAppParams { + config: InitAppConfig; + openMainApp: (path?: string) => void; +} + +export interface AuthApp { + mount: (el: HTMLElement | string) => void; + destroy: () => void; +} + +export type AuthAppFactory = (params: CreateAuthAppParams) => AuthApp | Promise; + +export const createAuthAppFactory = (f: AuthAppFactory) => f; diff --git a/frontend_src/vstutils/autoupdate/__tests__/AutoUpdateController.test.js b/frontend_src/vstutils/autoupdate/__tests__/AutoUpdateController.test.js index 1e2907d6..ad2352e9 100644 --- a/frontend_src/vstutils/autoupdate/__tests__/AutoUpdateController.test.js +++ b/frontend_src/vstutils/autoupdate/__tests__/AutoUpdateController.test.js @@ -1,4 +1,3 @@ -import { expect, describe, test, jest } from '@jest/globals'; import Centrifuge from 'centrifuge'; import { AutoUpdateController } from '../AutoUpdateController.ts'; @@ -6,7 +5,7 @@ describe('AutoUpdateController', () => { test('timer subscriptions', () => { const controller = new AutoUpdateController(null); - const callback = jest.fn(); + const callback = vitest.fn(); expect(controller.timerSubscribers.size).toBe(0); controller.subscribe({ @@ -32,8 +31,8 @@ describe('AutoUpdateController', () => { const channelSubscriptions = controller.channelSubscriptions; const channelSubscribers = controller.channelSubscribers; - const callback1 = jest.fn(); - const callback2 = jest.fn(); + const callback1 = vitest.fn(); + const callback2 = vitest.fn(); expect(subscribers.size).toBe(0); diff --git a/frontend_src/app_loader/cleanCacheHelpers.js b/frontend_src/vstutils/cleanCacheHelpers.js similarity index 100% rename from frontend_src/app_loader/cleanCacheHelpers.js rename to frontend_src/vstutils/cleanCacheHelpers.js diff --git a/frontend_src/vstutils/components/Spinner.vue b/frontend_src/vstutils/components/Spinner.vue new file mode 100644 index 00000000..916931b4 --- /dev/null +++ b/frontend_src/vstutils/components/Spinner.vue @@ -0,0 +1,76 @@ + + + diff --git a/frontend_src/vstutils/components/items/ControlSidebar.vue b/frontend_src/vstutils/components/items/ControlSidebar.vue index a06d66bd..264ab758 100644 --- a/frontend_src/vstutils/components/items/ControlSidebar.vue +++ b/frontend_src/vstutils/components/items/ControlSidebar.vue @@ -3,7 +3,7 @@
{{ $u.capitalize($tc('version', 1)) }}: - {{ $app.config.projectVersion }} + {{ $app.version }}
@@ -44,6 +44,7 @@ diff --git a/frontend_src/vstutils/components/items/sidebar/utils.ts b/frontend_src/vstutils/components/items/sidebar/utils.ts index ad41e11f..1a08ef0f 100644 --- a/frontend_src/vstutils/components/items/sidebar/utils.ts +++ b/frontend_src/vstutils/components/items/sidebar/utils.ts @@ -1,7 +1,7 @@ import $ from 'jquery'; import type { RawLocation } from 'vue-router'; import type { Action } from '../../../views'; -import type { XMenu } from '../../../AppConfiguration'; +import type { XMenu } from '../../../schema'; import { getApp } from '../../../utils'; export interface MenuItem { diff --git a/frontend_src/vstutils/components/wysiwyg-editor/index.ts b/frontend_src/vstutils/components/wysiwyg-editor/index.ts index 31c71707..8ff2ba41 100644 --- a/frontend_src/vstutils/components/wysiwyg-editor/index.ts +++ b/frontend_src/vstutils/components/wysiwyg-editor/index.ts @@ -29,11 +29,9 @@ const realComponent = (readOnly: boolean, lang?: LangInfo) => const [ToastUIEditor, ToastUIEditorVue] = await Promise.all([ import('@toast-ui/editor'), import('@toast-ui/vue-editor'), - // @ts-expect-error Styles are not typed import('@toast-ui/editor/dist/toastui-editor.css'), getApp().darkModeEnabled - ? // @ts-expect-error Styles are not typed - import('@toast-ui/editor/dist/theme/toastui-editor-dark.css') + ? import('@toast-ui/editor/dist/theme/toastui-editor-dark.css') : Promise.resolve(), ]); if (lang) { diff --git a/frontend_src/vstutils/default-auth-app/components/BackButton.vue b/frontend_src/vstutils/default-auth-app/components/BackButton.vue new file mode 100644 index 00000000..c89edf67 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/components/BackButton.vue @@ -0,0 +1,13 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/components/FormGroup.vue b/frontend_src/vstutils/default-auth-app/components/FormGroup.vue new file mode 100644 index 00000000..740ad09c --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/components/FormGroup.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frontend_src/vstutils/default-auth-app/components/LangForm.vue b/frontend_src/vstutils/default-auth-app/components/LangForm.vue new file mode 100644 index 00000000..c03c1e2d --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/components/LangForm.vue @@ -0,0 +1,35 @@ + + + + + diff --git a/frontend_src/vstutils/default-auth-app/components/Layout.vue b/frontend_src/vstutils/default-auth-app/components/Layout.vue new file mode 100644 index 00000000..419ce454 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/components/Layout.vue @@ -0,0 +1,34 @@ + + + + + diff --git a/frontend_src/vstutils/default-auth-app/components/Oauth2ClientCredentialsGrantPageWrapper.vue b/frontend_src/vstutils/default-auth-app/components/Oauth2ClientCredentialsGrantPageWrapper.vue new file mode 100644 index 00000000..8030b335 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/components/Oauth2ClientCredentialsGrantPageWrapper.vue @@ -0,0 +1,84 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/helpers.ts b/frontend_src/vstutils/default-auth-app/helpers.ts new file mode 100644 index 00000000..16313f50 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/helpers.ts @@ -0,0 +1,55 @@ +import { type UserManager } from 'oidc-client-ts'; +import { provide, type InjectionKey, inject } from 'vue'; +import type VueI18n from 'vue-i18n'; +import { type InitAppConfig } from '@/vstutils/init-app'; +import { TranslationsManager } from '../api/TranslationsManager'; +import { createVueI18n } from '../translation'; +import { getCookie } from '../utils'; + +function createProviderWithInjector(name: string) { + const key = Symbol(name) as InjectionKey; + return { + provide: (value: T) => provide(key, value), + inject: () => { + const value = inject(key); + if (!value) { + throw new Error(`${name} is not provided`); + } + return value; + }, + }; +} + +export const { provide: provideOauth2UserManager, inject: useOauth2UserManager } = + createProviderWithInjector('UserManager'); + +export const { provide: provideMainAppOpener, inject: useMainAppOpener } = + createProviderWithInjector<() => void>('mainAppOpener'); + +export const { provide: provideTranslationsManager, inject: useTranslationsManager } = + createProviderWithInjector<{ + i18n: VueI18n; + availableLanguages: { code: string; name: string }[]; + setLanguage: (code: string) => Promise; + }>('TranslationManager'); + +export const { provide: provideInitAppConfig, inject: useInitAppConfig } = + createProviderWithInjector('InitAppConfig'); + +export async function createTranslationsManager(config: InitAppConfig) { + const baseTranslationsManager = new TranslationsManager(config); + const i18n = createVueI18n(); + const availableLanguages = await baseTranslationsManager.getLanguages(); + i18n.locale = document.documentElement.lang || getCookie('lang') || availableLanguages[0]?.code || 'en'; + if (availableLanguages.length > 0) { + i18n.setLocaleMessage(i18n.locale, await baseTranslationsManager.getTranslations(i18n.locale)); + } + return { + availableLanguages, + i18n, + async setLanguage(code: string) { + i18n.locale = code; + i18n.setLocaleMessage(code, await baseTranslationsManager.getTranslations(code)); + }, + }; +} diff --git a/frontend_src/vstutils/default-auth-app/index.ts b/frontend_src/vstutils/default-auth-app/index.ts new file mode 100644 index 00000000..990c16b2 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/index.ts @@ -0,0 +1,121 @@ +import Vue, { type Component, type AsyncComponent, h, defineAsyncComponent } from 'vue'; +import VueRouter, { type RouteConfig } from 'vue-router'; +import { type AuthAppFactory } from '@/vstutils/auth-app'; +import { + provideOauth2UserManager, + provideMainAppOpener, + provideTranslationsManager, + provideInitAppConfig, + createTranslationsManager, +} from './helpers'; + +export const createDefaultAuthApp = customizeDefaultAuthAppCreator({}); + +export function customizeDefaultAuthAppCreator({ + components, +}: { + components?: { + layout?: Component; + loginPage?: Component; + registrationPage?: Component; + registrationConfirmEmailPage?: Component; + passwordResetPage?: Component; + passwordResetConfirmPage?: Component; + }; +}) { + return createBaseAuthApp({ + routes: [ + { + path: '/auth/login', + name: 'login', + component: + components?.loginPage ?? + defineAsyncComponent(async () => (await import('./pages/Login.vue')).default), + }, + { + path: '/auth/registration', + name: 'registration', + component: + components?.registrationPage ?? + defineAsyncComponent(async () => (await import('./pages/Registration.vue')).default), + }, + { + path: '/auth/registration/confirm-email/:code', + name: 'registration-confirm-email', + component: + components?.registrationConfirmEmailPage ?? + defineAsyncComponent( + async () => (await import('./pages/RegistrationConfirmEmail.vue')).default, + ), + }, + { + path: '/auth/password-reset', + name: 'password-reset', + component: + components?.passwordResetPage ?? + defineAsyncComponent(async () => (await import('./pages/PasswordReset.vue')).default), + }, + { + path: '/auth/password-reset/:uid/:token', + name: 'password-reset-confirm', + component: + components?.passwordResetConfirmPage ?? + defineAsyncComponent( + async () => (await import('./pages/PasswordResetConfirm.vue')).default, + ), + }, + { + path: '*', + redirect: { name: 'login' }, + }, + ], + layoutComponent: + components?.layout ?? + defineAsyncComponent(async () => (await import('./components/Layout.vue')).default), + }); +} + +export function createBaseAuthApp(params: { + routes: RouteConfig[]; + layoutComponent?: Component | AsyncComponent; +}): AuthAppFactory { + return async ({ config, openMainApp }) => { + Vue.use(VueRouter); + + const router = new VueRouter({ + routes: params.routes, + }); + + const { availableLanguages, i18n, setLanguage } = await createTranslationsManager(config); + + const vm = new Vue({ + setup() { + provideOauth2UserManager(config.auth.userManager); + provideMainAppOpener(openMainApp); + provideInitAppConfig(config); + provideTranslationsManager({ + availableLanguages, + i18n, + setLanguage, + }); + return () => { + if (params.layoutComponent) { + return h(params.layoutComponent, [h('router-view')]); + } + return h('router-view'); + }; + }, + i18n, + router, + }); + + return { + mount(el: HTMLElement | string) { + vm.$mount(el); + }, + destroy() { + vm.$destroy(); + }, + }; + }; +} diff --git a/frontend_src/vstutils/default-auth-app/pages/Login.vue b/frontend_src/vstutils/default-auth-app/pages/Login.vue new file mode 100644 index 00000000..406a11bc --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/pages/Login.vue @@ -0,0 +1,105 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/pages/PasswordReset.vue b/frontend_src/vstutils/default-auth-app/pages/PasswordReset.vue new file mode 100644 index 00000000..28347276 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/pages/PasswordReset.vue @@ -0,0 +1,75 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/pages/PasswordResetConfirm.vue b/frontend_src/vstutils/default-auth-app/pages/PasswordResetConfirm.vue new file mode 100644 index 00000000..96265e43 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/pages/PasswordResetConfirm.vue @@ -0,0 +1,87 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/pages/Registration.vue b/frontend_src/vstutils/default-auth-app/pages/Registration.vue new file mode 100644 index 00000000..a2a9fe83 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/pages/Registration.vue @@ -0,0 +1,122 @@ + + + diff --git a/frontend_src/vstutils/default-auth-app/pages/RegistrationConfirmEmail.vue b/frontend_src/vstutils/default-auth-app/pages/RegistrationConfirmEmail.vue new file mode 100644 index 00000000..dded9aa0 --- /dev/null +++ b/frontend_src/vstutils/default-auth-app/pages/RegistrationConfirmEmail.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend_src/vstutils/default-page-loader.ts b/frontend_src/vstutils/default-page-loader.ts new file mode 100644 index 00000000..5b7f47a9 --- /dev/null +++ b/frontend_src/vstutils/default-page-loader.ts @@ -0,0 +1,42 @@ +import { createApp, ref, defineComponent, h } from 'vue-demi'; +import Spinner from './components/Spinner.vue'; + +export function createDefaultPageLoader() { + const show = ref(false); + const app = createApp( + defineComponent({ + setup() { + return () => + show.value + ? h( + 'div', + { + staticStyle: { + position: 'absolute', + top: '0', + left: '0', + display: 'flex', + 'justify-content': 'center', + 'align-items': 'center', + width: '100vw', + height: '100vh', + }, + }, + [h(Spinner)], + ) + : undefined; + }, + }), + ); + const div = document.createElement('div'); + document.body.insertAdjacentElement('beforeend', div); + app.mount(div); + return { + show() { + show.value = true; + }, + hide() { + show.value = false; + }, + }; +} diff --git a/frontend_src/vstutils/fetch-values.ts b/frontend_src/vstutils/fetch-values.ts index 5a221960..6671dfc6 100644 --- a/frontend_src/vstutils/fetch-values.ts +++ b/frontend_src/vstutils/fetch-values.ts @@ -1,7 +1,6 @@ import { i18n } from '@/vstutils/translation'; import { AggregatedQueriesExecutor } from '@/vstutils/AggregatedQueriesExecutor'; -import { RequestTypes, createPropertyProxy, getApp } from '@/vstutils/utils'; -import { FKField } from '@/vstutils/fields/fk/fk/FKField'; +import { OBJECT_NOT_FOUND_TEXT, RequestTypes, createPropertyProxy, getApp } from '@/vstutils/utils'; import { ArrayField } from '@/vstutils/fields/array/ArrayField'; import { DynamicField } from '@/vstutils/fields/dynamic'; @@ -67,7 +66,7 @@ export function fetchPKs( const model = qs!.getResponseModelClass(RequestTypes.LIST); const notFound = new model({ [field.valueField]: pk, - [field.viewField]: i18n.t(FKField.NOT_FOUND_TEXT), + [field.viewField]: i18n.t(OBJECT_NOT_FOUND_TEXT), } as InnerData); notFound.__notFound = true; return notFound; diff --git a/frontend_src/vstutils/fields/FieldsResolver.ts b/frontend_src/vstutils/fields/FieldsResolver.ts index 96c1e7a5..5a9911e4 100644 --- a/frontend_src/vstutils/fields/FieldsResolver.ts +++ b/frontend_src/vstutils/fields/FieldsResolver.ts @@ -1,7 +1,7 @@ import type { Field, FieldOptions, FieldXOptions } from './base'; import { BaseField } from './base'; import { ENUM_TYPES, hasOwnProp, SCHEMA_DATA_TYPE, SCHEMA_DATA_TYPE_VALUES } from '../utils'; -import type { AppSchema } from '../AppConfiguration'; +import type { AppSchema } from '../schema'; import type { ParameterType } from 'swagger-schema-official'; const X_FORMAT = 'x-format'; diff --git a/frontend_src/vstutils/fields/__tests__/FieldsResolver.test.js b/frontend_src/vstutils/fields/__tests__/FieldsResolver.test.js index da7ae3c0..7fc4f27e 100644 --- a/frontend_src/vstutils/fields/__tests__/FieldsResolver.test.js +++ b/frontend_src/vstutils/fields/__tests__/FieldsResolver.test.js @@ -1,4 +1,3 @@ -import { expect, test, describe, beforeAll } from '@jest/globals'; import * as files from '../files'; import { FieldsResolver } from '../FieldsResolver.ts'; import { addDefaultFields } from '../index.ts'; diff --git a/frontend_src/vstutils/fields/__tests__/MultipleNamedBinFileField.test.js b/frontend_src/vstutils/fields/__tests__/MultipleNamedBinFileField.test.js index 45715a91..2711fac0 100644 --- a/frontend_src/vstutils/fields/__tests__/MultipleNamedBinFileField.test.js +++ b/frontend_src/vstutils/fields/__tests__/MultipleNamedBinFileField.test.js @@ -1,4 +1,3 @@ -import { expect, test, describe } from '@jest/globals'; import MultipleNamedBinFileField from '../files/multiple-named-binary-file/MultipleNamedBinaryFileField'; describe('MultipleNamedBinFileField', () => { diff --git a/frontend_src/vstutils/fields/__tests__/fields-container-classes.test.ts b/frontend_src/vstutils/fields/__tests__/fields-container-classes.test.ts index 8321344f..9e95dee3 100644 --- a/frontend_src/vstutils/fields/__tests__/fields-container-classes.test.ts +++ b/frontend_src/vstutils/fields/__tests__/fields-container-classes.test.ts @@ -1,4 +1,3 @@ -import { expect, test } from '@jest/globals'; import { createApp, createSchema } from '@/unittests'; import { useEntityViewClasses } from '@/vstutils/store'; import { ref } from 'vue'; diff --git a/frontend_src/vstutils/fields/__tests__/fields-types.test.ts b/frontend_src/vstutils/fields/__tests__/fields-types.test.ts index 380c7958..72d21ac1 100644 --- a/frontend_src/vstutils/fields/__tests__/fields-types.test.ts +++ b/frontend_src/vstutils/fields/__tests__/fields-types.test.ts @@ -1,4 +1,3 @@ -import { test, describe, expect } from '@jest/globals'; import StringField from '../text/StringField'; import { FKField } from '../fk/fk/FKField'; diff --git a/frontend_src/vstutils/fields/__tests__/nested-object.test.js b/frontend_src/vstutils/fields/__tests__/nested-object.test.js index 75bd8d7f..b473aa96 100644 --- a/frontend_src/vstutils/fields/__tests__/nested-object.test.js +++ b/frontend_src/vstutils/fields/__tests__/nested-object.test.js @@ -1,5 +1,4 @@ -import { expect, test, describe, beforeAll } from '@jest/globals'; -import { createApp } from '../../../unittests/create-app.js'; +import { createApp } from '../../../unittests/create-app.ts'; describe('NestedObject field', () => { let app; diff --git a/frontend_src/vstutils/fields/autocomplete/AutocompleteFieldContentEditMixin.vue b/frontend_src/vstutils/fields/autocomplete/AutocompleteFieldContentEditMixin.vue index 4a005041..5bd6d237 100644 --- a/frontend_src/vstutils/fields/autocomplete/AutocompleteFieldContentEditMixin.vue +++ b/frontend_src/vstutils/fields/autocomplete/AutocompleteFieldContentEditMixin.vue @@ -20,7 +20,7 @@ diff --git a/frontend_src/vstutils/fields/files/multiple-named-binary-image/MultipleNamedBinaryImageFieldListView.vue b/frontend_src/vstutils/fields/files/multiple-named-binary-image/MultipleNamedBinaryImageFieldListView.vue index 2d01433d..c4160843 100644 --- a/frontend_src/vstutils/fields/files/multiple-named-binary-image/MultipleNamedBinaryImageFieldListView.vue +++ b/frontend_src/vstutils/fields/files/multiple-named-binary-image/MultipleNamedBinaryImageFieldListView.vue @@ -7,11 +7,11 @@ @shown="shown = true" >