From f15bba0419f4d11f782631941c9204b02b0faa2b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 5 Sep 2024 13:59:42 -0400 Subject: [PATCH 01/67] Refs #253 Updated requirements for pyotp --- teraserver/python/env/requirements.txt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/teraserver/python/env/requirements.txt b/teraserver/python/env/requirements.txt index a6b51afd..a1950683 100644 --- a/teraserver/python/env/requirements.txt +++ b/teraserver/python/env/requirements.txt @@ -1,7 +1,7 @@ pypiwin32==223; sys_platform == 'win32' Twisted==24.3.0 treq==23.11.0 -cryptography==42.0.5 +cryptography==43.0.1 autobahn==23.6.2 SQLAlchemy==2.0.28 sqlalchemy-schemadisplay==2.0 @@ -28,7 +28,7 @@ txredisapi==1.4.10 passlib==1.7.4 bcrypt==4.1.2 WTForms==3.1.2 -pyOpenSSL==24.0.0 +pyOpenSSL==24.2.1 service-identity==24.1.0 PyJWT==2.8.0 pylzma==0.5.0 @@ -38,4 +38,6 @@ websocket-client==1.7.0 pytest==8.0.2 Jinja2==3.1.3 ua-parser==0.18.0 +pyotp==2.9.0 +pyqrcode==1.2.1 From 8f95e7a17a21cdbdf5052949f31703042523106c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 5 Sep 2024 15:58:04 -0400 Subject: [PATCH 02/67] Refs #253, Added 2fa columns to t_users --- .../60f5b2ed8b5a_assets_table_rework.py | 2 +- .../65a42f6ee567_soft_delete_upgrade.py | 2 +- .../versions/89343f5c95b9_allow_2fa_login.py | 50 +++++++++++++++++++ teraserver/python/alembic/versions/README.md | 10 ++++ 4 files changed, 62 insertions(+), 2 deletions(-) create mode 100644 teraserver/python/alembic/versions/89343f5c95b9_allow_2fa_login.py diff --git a/teraserver/python/alembic/versions/60f5b2ed8b5a_assets_table_rework.py b/teraserver/python/alembic/versions/60f5b2ed8b5a_assets_table_rework.py index ba6c2844..1adc5c9f 100644 --- a/teraserver/python/alembic/versions/60f5b2ed8b5a_assets_table_rework.py +++ b/teraserver/python/alembic/versions/60f5b2ed8b5a_assets_table_rework.py @@ -18,7 +18,7 @@ def upgrade(): # Change t_assets column asset_type to string - integers values should be converted directly in Postgresql - op.alter_column(table_name='t_assets', column_name='asset_type', type_=sa.String) + op.alter_column(table_name='t_assets', column_name='asset_type', type=sa.String) # Change all current values to "application/octet-stream" since that is what we have right now op.execute("UPDATE t_assets SET asset_type=\'application/octet-stream\'") diff --git a/teraserver/python/alembic/versions/65a42f6ee567_soft_delete_upgrade.py b/teraserver/python/alembic/versions/65a42f6ee567_soft_delete_upgrade.py index 0caf54f5..0656d22a 100644 --- a/teraserver/python/alembic/versions/65a42f6ee567_soft_delete_upgrade.py +++ b/teraserver/python/alembic/versions/65a42f6ee567_soft_delete_upgrade.py @@ -18,7 +18,7 @@ def upgrade(): # Remove site_name unique constraint on t_sites - op.drop_constraint(constraint_name='t_sites_site_name_key', table_name='t_sites', type_='unique') + op.drop_constraint(constraint_name='t_sites_site_name_key', table_name='t_sites', type='unique') # TeraSessionParticipants.id_session add ondelete='cascade' op.drop_constraint(constraint_name='t_sessions_participants_id_session_fkey', table_name='t_sessions_participants', diff --git a/teraserver/python/alembic/versions/89343f5c95b9_allow_2fa_login.py b/teraserver/python/alembic/versions/89343f5c95b9_allow_2fa_login.py new file mode 100644 index 00000000..a113bed6 --- /dev/null +++ b/teraserver/python/alembic/versions/89343f5c95b9_allow_2fa_login.py @@ -0,0 +1,50 @@ +"""allow 2fa login + +Revision ID: 89343f5c95b9 +Revises: 09764faa2d57 +Create Date: 2024-09-05 14:49:04.781595 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '89343f5c95b9' +down_revision = '09764faa2d57' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add 2fa_enabled column to t_users table + op.add_column(table_name='t_users', column=sa.Column('user_2fa_enabled', + sa.Boolean, nullable=False, server_default=str(False))) + + # Add 2fa_otp_enabled column to t_users table + op.add_column(table_name='t_users', column=sa.Column('user_2fa_otp_enabled', + sa.Boolean, nullable=False, server_default=str(False))) + + # Add 2fa_email_enabled_column to t_users table + # Will user user_email as 2fa email + op.add_column(table_name='t_users', column=sa.Column('user_2fa_email_enabled', + sa.Boolean, nullable=False, server_default=str(False))) + + # Add 2fa_otp_secret column to t_users table + # Secrets will be generated with pytop.random_base32() + op.add_column(table_name='t_users', column=sa.Column('user_2fa_otp_secret', + sa.String(32), nullable=True)) + + # Add a force_password_change column to t_users table + op.add_column(table_name='t_users', column=sa.Column('user_force_password_change', + sa.Boolean, nullable=False, server_default=str(False))) + + +def downgrade(): + # Remove columns + op.drop_column('t_users', 'user_2fa_enabled') + op.drop_column('t_users', 'user_2fa_otp_enabled') + op.drop_column('t_users', 'user_2fa_email_enabled') + op.drop_column('t_users', 'user_2fa_otp_secret') + op.drop_column('t_users', 'user_force_password_change') + diff --git a/teraserver/python/alembic/versions/README.md b/teraserver/python/alembic/versions/README.md index e7265c31..096fe54b 100644 --- a/teraserver/python/alembic/versions/README.md +++ b/teraserver/python/alembic/versions/README.md @@ -6,6 +6,16 @@ alembic revision -m "create account table" ``` +## Changes for next version (Sept 5 2024) + +### TeraServer +**Modified t_users table** +* Add column user_2fa_enabled (Boolean, default=False) +* Add column user_2fa_otp_enabled (Boolean, default=False) +* Add column user_2fa_email_enabled (Boolean, default=False) +* Add column user_2fa_otp_secret (String(32), nullable=True) +* Add column user_force_password_change (Boolean, default=False) + ## Changes for next version (Feb 6 2023) ### TeraServer From a55e5354b8ed2dadb174f3cbb84546e0c616bb64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 5 Sep 2024 16:34:19 -0400 Subject: [PATCH 03/67] Refs #253, added new columns to ignored fields. --- teraserver/python/opentera/db/models/TeraUser.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index ab60555c..10a88d45 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -68,7 +68,8 @@ def to_json(self, ignore_fields=None, minimal=False): if ignore_fields is None: ignore_fields = [] ignore_fields.extend(['authenticated', 'user_password', 'user_user_groups', - 'user_sessions']) + 'user_sessions', 'user_2fa_enabled', 'user_2fa_otp_enabled', + 'user_2fa_email_enabled', 'user_2fa_otp_secret', 'user_force_password_change']) if minimal: ignore_fields.extend(['user_username', 'user_email', 'user_profile', 'user_notes', 'user_lastonline', 'user_superadmin']) From 876487917a80921d2e00ee7dd90f280877d1b945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 9 Sep 2024 16:46:53 -0400 Subject: [PATCH 04/67] Refs #253, starting 2FA API. --- .../modules/FlaskModule/API/user/UserLogin.py | 5 +- .../FlaskModule/API/user/UserLogin2FA.py | 166 ++++++++++++++++++ .../python/modules/FlaskModule/FlaskModule.py | 2 + .../python/opentera/db/models/TeraUser.py | 17 ++ .../FlaskModule/API/user/test_UserLogin2FA.py | 63 +++++++ .../opentera/db/models/test_TeraSession.py | 4 +- 6 files changed, 252 insertions(+), 5 deletions(-) create mode 100644 teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py index 596c2ca8..6caaa512 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py @@ -30,12 +30,11 @@ def __init__(self, _api, *args, **kwargs): self.module = kwargs.get('flaskModule', None) self.test = kwargs.get('test', False) - @api.doc(description='Login to the server using HTTP Basic Authentification (HTTPAuth)') + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)') @api.expect(get_parser) @user_http_auth.login_required def get(self): - parser = get_parser - args = parser.parse_args() + args = get_parser.parse_args() # Redis key is handled in LoginModule servername = self.module.config.server_config['hostname'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py new file mode 100644 index 00000000..9eb0a365 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -0,0 +1,166 @@ +from flask import session, request +from flask_restx import Resource, reqparse, inputs +from flask_babel import gettext +from modules.LoginModule.LoginModule import user_http_auth, LoginModule, current_user +from modules.FlaskModule.FlaskModule import user_api_ns as api +from opentera.redis.RedisRPCClient import RedisRPCClient +from opentera.modules.BaseModule import ModuleNames +from opentera.utils.UserAgentParser import UserAgentParser + +import opentera.messages.python as messages +from opentera.redis.RedisVars import RedisVars +import pyotp +from opentera.db.models.TeraUser import TeraUser + +get_parser = api.parser() +get_parser.add_argument('with_websocket', type=inputs.boolean, + help='If set, requires that a websocket url is returned.' + 'If not possible to do so, return a 403 error.', + default=False) + +get_parser.add_argument('otp_code', type=str, required=True, help='2FA otp code') + + +class UserLogin2FA(Resource): + + def __init__(self, _api, *args, **kwargs): + Resource.__init__(self, _api, *args, **kwargs) + self.module = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) + + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth) and 2FA') + @api.expect(get_parser) + @user_http_auth.login_required + def get(self): + args = get_parser.parse_args(strict=True) + + # Current user is logged in with HTTPAuth + # Let's verify if 2FA is enabled and if OTP is valid + if not current_user.user_2fa_enabled: + return gettext('User does not have 2FA enabled'), 403 + if not current_user.user_2fa_otp_enabled or not current_user.user_2fa_otp_secret: + return gettext('User does not have 2FA OTP enabled'), 403 + + # Verify OTP + totp = pyotp.TOTP(current_user.user_2fa_otp_secret) + if not totp.verify(args['otp_code']): + return gettext('Invalid OTP code'), 403 + + # Redis key is handled in LoginModule + servername = self.module.config.server_config['hostname'] + port = self.module.config.server_config['port'] + if 'X_EXTERNALSERVER' in request.headers: + servername = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + websocket_url = None + + # Get user token key from redis + token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) + + # Get login information for log + login_infos = UserAgentParser.parse_request_for_login_infos(request) + + # Verify if user already logged in + online_users = [] + if not self.test: + rpc = RedisRPCClient(self.module.config.redis_config) + online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') + + if current_user.user_uuid not in online_users: + websocket_url = "wss://" + servername + ":" + str(port) + "/wss/user?id=" + session['_id'] + # print('Login - setting key with expiration in 60s', session['_id'], session['_user_id']) + self.module.redisSet(session['_id'], session['_user_id'], ex=60) + elif args['with_websocket']: + # User is online and a websocket is required + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_FAILED_WITH_ALREADY_LOGGED_IN, + client_name=login_infos['client_name'], + client_version=login_infos['client_version'], + client_ip=login_infos['client_ip'], + os_name=login_infos['os_name'], + os_version=login_infos['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=login_infos['server_endpoint']) + + return gettext('User already logged in.'), 403 + + current_user.update_last_online() + user_token = current_user.get_token(token_key) + + # Return reply as json object + reply = {"user_uuid": session['_user_id'], + "user_token": user_token} + if websocket_url: + reply["websocket_url"] = websocket_url + + # Verify client version (optional for now) + # And add info to reply + if 'X-Client-Name' in request.headers and 'X-Client-Version' in request.headers: + try: + # Extract information + client_name = request.headers['X-Client-Name'] + client_version = request.headers['X-Client-Version'] + + client_version_parts = client_version.split('.') + + # Load known version from database. + from opentera.utils.TeraVersions import TeraVersions + versions = TeraVersions() + versions.load_from_db() + + # Verify if we have client information in DB + client_info = versions.get_client_version_with_name(client_name) + if client_info: + # We have something stored for this client, let's verify version numbers + # For now, we still allow login even when version mismatch + # Reply full version information + reply['version_latest'] = client_info.to_dict() + if client_info.version != client_version: + reply['version_error'] = gettext('Client version mismatch') + # If major version mismatch, kill client, first part of the version + stored_client_version_parts = client_info.version.split('.') + if len(stored_client_version_parts) and len(client_version_parts): + if stored_client_version_parts[0] != client_version_parts[0]: + # return 426 = upgrade required + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_UNKNOWN, + client_name=login_infos['client_name'], + client_version=login_infos['client_version'], + client_ip=login_infos['client_ip'], + os_name=login_infos['os_name'], + os_version=login_infos['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=login_infos['server_endpoint'], + message=gettext('Client version mismatch')) + + return gettext('Client major version too old, not accepting login'), 426 + # else: + # return gettext('Invalid client name :') + client_name, 403 + except BaseException as e: + self.module.logger.log_error(self.module.module_name, + UserLogin.__name__, + 'get', 500, 'Invalid client version handler', str(e)) + return gettext('Invalid client version handler') + str(e), 500 + + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_INFO, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status=messages.LoginEvent.LOGIN_STATUS_SUCCESS, + client_name=login_infos['client_name'], + client_version=login_infos['client_version'], + client_ip=login_infos['client_ip'], + os_name=login_infos['os_name'], + os_version=login_infos['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=login_infos['server_endpoint']) + + return reply diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index 9b2d8465..85891fa3 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -138,6 +138,7 @@ def init_user_api(module: object, namespace: Namespace, additional_args: dict = # Users... from modules.FlaskModule.API.user.UserLogin import UserLogin + from modules.FlaskModule.API.user.UserLogin2FA import UserLogin2FA from modules.FlaskModule.API.user.UserLogout import UserLogout from modules.FlaskModule.API.user.UserQueryUsers import UserQueryUsers from modules.FlaskModule.API.user.UserQueryUserPreferences import UserQueryUserPreferences @@ -200,6 +201,7 @@ def init_user_api(module: object, namespace: Namespace, additional_args: dict = namespace.add_resource(UserQueryForms, '/forms', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryParticipantGroup, '/groups', resource_class_kwargs=kwargs) namespace.add_resource(UserLogin, '/login', resource_class_kwargs=kwargs) + namespace.add_resource(UserLogin2FA, '/login_2fa', resource_class_kwargs=kwargs) namespace.add_resource(UserLogout, '/logout', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryParticipants, '/participants', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryOnlineParticipants, '/participants/online', resource_class_kwargs=kwargs) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index 10a88d45..07bc20a1 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -18,6 +18,7 @@ import json import time import jwt +import pyotp # Generator for jti @@ -46,6 +47,12 @@ class TeraUser(BaseModel, SoftDeleteMixin): user_notes = Column(String, nullable=True) user_lastonline = Column(TIMESTAMP(timezone=True), nullable=True) user_superadmin = Column(Boolean, nullable=False, default=False) + # Fields added for 2FA + user_2fa_enabled = Column(Boolean, nullable=False, default=False) + user_2fa_otp_enabled = Column(Boolean, nullable=False, default=False) + user_2fa_email_enabled = Column(Boolean, nullable=False, default=False) + user_2fa_otp_secret = Column(String, nullable=True) + user_force_password_change = Column(Boolean, nullable=False, default=False) # user_sites_access = relationship('TeraSiteAccess', cascade="all,delete") # user_projects_access = relationship("TeraProjectAccess", cascade="all,delete") @@ -122,6 +129,16 @@ def get_token(self, token_key: str, expiration: int = 3600): return jwt.encode(payload, token_key, algorithm='HS256') + def enable_2fa_otp(self) -> bool: + if self.user_2fa_enabled and self.user_2fa_otp_enabled and self.user_2fa_otp_secret: + return False + + self.user_2fa_enabled = True + self.user_2fa_otp_enabled = True + self.user_2fa_email_enabled = False + self.user_2fa_otp_secret = pyotp.random_base32() + return True + def get_service_access_dict(self): service_access = {} diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py new file mode 100644 index 00000000..88a215f8 --- /dev/null +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -0,0 +1,63 @@ +from BaseUserAPITest import BaseUserAPITest +from opentera.db.models.TeraUser import TeraUser + + +class UserLogin2FATest(BaseUserAPITest): + test_endpoint = '/api/user/login_2fa' + + def setUp(self): + super().setUp() + + def tearDown(self): + super().tearDown() + + def test_get_endpoint_no_auth(self): + with self._flask_app.app_context(): + response = self.test_client.get(self.test_endpoint) + self.assertEqual(401, response.status_code) + + def test_get_endpoint_invalid_token_auth(self): + with self._flask_app.app_context(): + response = self._get_with_user_token_auth(self.test_client, 'invalid') + self.assertEqual(401, response.status_code) + + def test_get_endpoint_login_admin_user_http_auth_no_code(self): + with self._flask_app.app_context(): + # Using default admin information + response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin') + self.assertEqual(400, response.status_code) + + def test_get_endpoint_login_admin_user_http_auth_invalid_code(self): + with self._flask_app.app_context(): + # Using default admin information + # Admin account has no 2FA enabled by default + params = {'otp_code': 'invalid'} + response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin', + params=params) + self.assertEqual(403, response.status_code) + + def test_get_endpoint_login_2fa_enabled_user_no_code(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = 'test' + password = 'test' + user = self.create_user_with_2fa_enabled(username, password) + # Login with user + response = self._get_with_user_http_auth(self.test_client, username, password) + self.assertEqual(400, response.status_code) + + def create_user_with_2fa_enabled(self, username='test', password='test') -> TeraUser: + # Create user with 2FA enabled + user = TeraUser() + user.user_firstname = 'Test' + user.user_lastname = 'Test' + user.user_email = 'test@hotmail.com' + user.user_username = username + user.user_password = password # Password will be hashed in insert + user.user_enabled = True + user.user_profile = {} + user.enable_2fa_otp() + TeraUser.insert(user) + return user + + diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSession.py b/teraserver/python/tests/opentera/db/models/test_TeraSession.py index a0678963..c57aaa7c 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSession.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSession.py @@ -19,8 +19,8 @@ def test_session_defaults(self): for session in TeraSession.query.all(): my_list = [session.id_creator_device, session.id_creator_participant, session.id_creator_service, session.id_creator_user] - # Only one not None - self.assertEqual(1, len([x for x in my_list if x is not None])) + # At least one creator should be set + self.assertTrue(any(my_list)) def test_session_new(self): from datetime import datetime From cfddb38e6b30395ad3269349443a546e45f9dbe6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Tue, 10 Sep 2024 16:02:42 -0400 Subject: [PATCH 05/67] Refs #253, starting 2FA API. logout user when 2fa fails. --- .../FlaskModule/API/user/UserLogin2FA.py | 5 ++ .../FlaskModule/API/user/test_UserLogin2FA.py | 64 ++++++++++++++++++- 2 files changed, 66 insertions(+), 3 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py index 9eb0a365..a203e34b 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -1,4 +1,5 @@ from flask import session, request +from flask_login import logout_user from flask_restx import Resource, reqparse, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import user_http_auth, LoginModule, current_user @@ -37,8 +38,12 @@ def get(self): # Current user is logged in with HTTPAuth # Let's verify if 2FA is enabled and if OTP is valid if not current_user.user_2fa_enabled: + logout_user() + session.clear() return gettext('User does not have 2FA enabled'), 403 if not current_user.user_2fa_otp_enabled or not current_user.user_2fa_otp_secret: + logout_user() + session.clear() return gettext('User does not have 2FA OTP enabled'), 403 # Verify OTP diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index 88a215f8..c99fdd0f 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -1,5 +1,6 @@ from BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser +import pyotp class UserLogin2FATest(BaseUserAPITest): @@ -39,19 +40,76 @@ def test_get_endpoint_login_admin_user_http_auth_invalid_code(self): def test_get_endpoint_login_2fa_enabled_user_no_code(self): with self._flask_app.app_context(): # Create user with 2FA enabled - username = 'test' - password = 'test' + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) # Login with user response = self._get_with_user_http_auth(self.test_client, username, password) self.assertEqual(400, response.status_code) + def test_get_endpoint_login_2fa_enabled_user_wrong_code(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + params = {'otp_code': 'invalid'} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params) + self.assertEqual(403, response.status_code) + + def test_get_endpoint_login_2fa_enabled_user_valid_code(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + totp = pyotp.TOTP(user.user_2fa_otp_secret) + params = {'otp_code': totp.now()} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params) + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + self.assertTrue('user_uuid' in response.json) + self.assertTrue('user_token' in response.json) + + def test_get_endpoint_login_2fa_enabled_user_valid_code_with_websockets(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + totp = pyotp.TOTP(user.user_2fa_otp_secret) + params = {'otp_code': totp.now(), 'with_websocket': True} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params) + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + self.assertTrue('user_uuid' in response.json) + self.assertTrue('user_token' in response.json) + self.assertTrue('websocket_url' in response.json) + self.assertIsNotNone(response.json['websocket_url']) + def create_user_with_2fa_enabled(self, username='test', password='test') -> TeraUser: # Create user with 2FA enabled user = TeraUser() user.user_firstname = 'Test' user.user_lastname = 'Test' - user.user_email = 'test@hotmail.com' + user.user_email = f'{username}@hotmail.com' user.user_username = username user.user_password = password # Password will be hashed in insert user.user_enabled = True From 7009500acd0f8fb3ab8c20628ee84bc3ab29f56a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 16 Sep 2024 09:56:51 -0400 Subject: [PATCH 06/67] Refs #253 Updated base implementation with exceptions. --- .../FlaskModule/API/user/UserLogin2FA.py | 207 ++++++------------ .../FlaskModule/API/user/UserLoginBase.py | 178 +++++++++++++++ .../FlaskModule/API/user/UserLogout.py | 1 - .../FlaskModule/API/user/BaseUserAPITest.py | 41 +++- .../FlaskModule/API/user/test_UserLogin2FA.py | 54 +++++ 5 files changed, 333 insertions(+), 148 deletions(-) create mode 100644 teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py index a203e34b..2b0570cf 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -4,6 +4,10 @@ from flask_babel import gettext from modules.LoginModule.LoginModule import user_http_auth, LoginModule, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api +from modules.FlaskModule.API.user.UserLoginBase import UserLoginBase +from modules.FlaskModule.API.user.UserLoginBase import OutdatedClientVersionError, InvalidClientVersionError, \ + InvalidClientVersionHandlerError, UserAlreadyLoggedInError +from werkzeug.exceptions import BadRequest from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames from opentera.utils.UserAgentParser import UserAgentParser @@ -22,150 +26,75 @@ get_parser.add_argument('otp_code', type=str, required=True, help='2FA otp code') -class UserLogin2FA(Resource): +class UserLogin2FA(UserLoginBase): def __init__(self, _api, *args, **kwargs): - Resource.__init__(self, _api, *args, **kwargs) - self.module = kwargs.get('flaskModule', None) - self.test = kwargs.get('test', False) + UserLoginBase.__init__(self, _api, *args, **kwargs) @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth) and 2FA') - @api.expect(get_parser) + @api.expect(get_parser, validate=True) @user_http_auth.login_required def get(self): - args = get_parser.parse_args(strict=True) - - # Current user is logged in with HTTPAuth - # Let's verify if 2FA is enabled and if OTP is valid - if not current_user.user_2fa_enabled: - logout_user() - session.clear() - return gettext('User does not have 2FA enabled'), 403 - if not current_user.user_2fa_otp_enabled or not current_user.user_2fa_otp_secret: - logout_user() - session.clear() - return gettext('User does not have 2FA OTP enabled'), 403 - - # Verify OTP - totp = pyotp.TOTP(current_user.user_2fa_otp_secret) - if not totp.verify(args['otp_code']): - return gettext('Invalid OTP code'), 403 - - # Redis key is handled in LoginModule - servername = self.module.config.server_config['hostname'] - port = self.module.config.server_config['port'] - if 'X_EXTERNALSERVER' in request.headers: - servername = request.headers['X_EXTERNALSERVER'] - - if 'X_EXTERNALPORT' in request.headers: - port = request.headers['X_EXTERNALPORT'] - - websocket_url = None - - # Get user token key from redis - token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) - - # Get login information for log - login_infos = UserAgentParser.parse_request_for_login_infos(request) - - # Verify if user already logged in - online_users = [] - if not self.test: - rpc = RedisRPCClient(self.module.config.redis_config) - online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') - - if current_user.user_uuid not in online_users: - websocket_url = "wss://" + servername + ":" + str(port) + "/wss/user?id=" + session['_id'] - # print('Login - setting key with expiration in 60s', session['_id'], session['_user_id']) - self.module.redisSet(session['_id'], session['_user_id'], ex=60) - elif args['with_websocket']: - # User is online and a websocket is required - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_ERROR, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status= - messages.LoginEvent.LOGIN_STATUS_FAILED_WITH_ALREADY_LOGGED_IN, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint']) - + try: + args = get_parser.parse_args(strict=True) + + # Current user is logged in with HTTPAuth + # Let's verify if 2FA is enabled and if OTP is valid + if not current_user.user_2fa_enabled: + self._user_logout() + return gettext('User does not have 2FA enabled'), 403 + if not current_user.user_2fa_otp_enabled or not current_user.user_2fa_otp_secret: + self._user_logout() + return gettext('User does not have 2FA OTP enabled or secret set'), 403 + + # Verify OTP + totp = pyotp.TOTP(current_user.user_2fa_otp_secret) + if not totp.verify(args['otp_code']): + self._user_logout() + return gettext('Invalid OTP code'), 403 + + # OTP validation completed, proceed with standard login + response = {} + + version_info = self._verify_client_version() + + if args['with_websocket']: + self._verify_user_already_logged_in() + response['websocket_url'] = self._generate_websocket_url() + + # Generate user token + response['user_uuid'] = current_user.user_uuid + response['user_token'] = self._generate_user_token() + + if version_info: + response.update(version_info) + + except OutdatedClientVersionError as e: + self._user_logout() + + return { + 'version_latest': e.version_latest, + 'current_version': e.current_version, + 'version_error': e.version_error, + 'message': gettext('Client major version too old, not accepting login')}, 426 + except InvalidClientVersionError as e: + # Invalid client version, will not be handled for now + pass + except InvalidClientVersionHandlerError as e: + self._user_logout() + return gettext('Invalid client version handler'), 500 + except UserAlreadyLoggedInError as e: + self._user_logout() return gettext('User already logged in.'), 403 + except Exception as e: + # Something went wrong, logout user + self._user_logout() + raise e + else: + # Everything went well, return response + self._send_login_success_message() + return response, 200 + + + - current_user.update_last_online() - user_token = current_user.get_token(token_key) - - # Return reply as json object - reply = {"user_uuid": session['_user_id'], - "user_token": user_token} - if websocket_url: - reply["websocket_url"] = websocket_url - - # Verify client version (optional for now) - # And add info to reply - if 'X-Client-Name' in request.headers and 'X-Client-Version' in request.headers: - try: - # Extract information - client_name = request.headers['X-Client-Name'] - client_version = request.headers['X-Client-Version'] - - client_version_parts = client_version.split('.') - - # Load known version from database. - from opentera.utils.TeraVersions import TeraVersions - versions = TeraVersions() - versions.load_from_db() - - # Verify if we have client information in DB - client_info = versions.get_client_version_with_name(client_name) - if client_info: - # We have something stored for this client, let's verify version numbers - # For now, we still allow login even when version mismatch - # Reply full version information - reply['version_latest'] = client_info.to_dict() - if client_info.version != client_version: - reply['version_error'] = gettext('Client version mismatch') - # If major version mismatch, kill client, first part of the version - stored_client_version_parts = client_info.version.split('.') - if len(stored_client_version_parts) and len(client_version_parts): - if stored_client_version_parts[0] != client_version_parts[0]: - # return 426 = upgrade required - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_ERROR, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status= - messages.LoginEvent.LOGIN_STATUS_UNKNOWN, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint'], - message=gettext('Client version mismatch')) - - return gettext('Client major version too old, not accepting login'), 426 - # else: - # return gettext('Invalid client name :') + client_name, 403 - except BaseException as e: - self.module.logger.log_error(self.module.module_name, - UserLogin.__name__, - 'get', 500, 'Invalid client version handler', str(e)) - return gettext('Invalid client version handler') + str(e), 500 - - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_INFO, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status=messages.LoginEvent.LOGIN_STATUS_SUCCESS, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint']) - - return reply diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py new file mode 100644 index 00000000..da7d6213 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -0,0 +1,178 @@ +from flask import session, request +from flask_login import logout_user +from flask_restx import Resource, reqparse, inputs +from flask_babel import gettext +from modules.LoginModule.LoginModule import user_http_auth, current_user +from modules.FlaskModule.FlaskModule import user_api_ns as api +from opentera.redis.RedisRPCClient import RedisRPCClient +from opentera.modules.BaseModule import ModuleNames +from opentera.utils.UserAgentParser import UserAgentParser +from opentera.utils.TeraVersions import TeraVersions +import opentera.messages.python as messages +from opentera.redis.RedisVars import RedisVars + + +class OutdatedClientVersionError(Exception): + # Raised when the client version is too old + def __init__(self, message, version_latest=None, current_version=None, version_error=None): + super().__init__(message) + self.version_latest = version_latest + self.current_version = current_version + self.version_error = version_error + + +class InvalidClientVersionError(Exception): + # Raised when the client version is invalid + def __init__(self, message): + super().__init__(message) + + +class InvalidClientVersionHandlerError(Exception): + # Raised when the client version handler is invalid + def __init__(self, message): + super().__init__(message) + + +class UserAlreadyLoggedInError(Exception): + # Raised when the user is already logged in + def __init__(self, message): + super().__init__(message) + + +class UserLoginBase(Resource): + + def __init__(self, _api, *args, **kwargs): + Resource.__init__(self, _api, *args, **kwargs) + self.module = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) + self.servername = self.module.config.server_config['hostname'] + self.port = self.module.config.server_config['port'] + + # Setup servername and port from headers if available + # This will happen when the server is behind a reverse proxy + if 'X_EXTERNALSERVER' in request.headers: + self.servername = request.headers['X_EXTERNALSERVER'] + if 'X_EXTERNALPORT' in request.headers: + self.port = request.headers['X_EXTERNALPORT'] + + def _verify_user_already_logged_in(self) -> None: + online_users = [] + if not self.test: + rpc = RedisRPCClient(self.module.config.redis_config) + online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') + + if current_user.user_uuid in online_users: + user_agent_info = UserAgentParser.parse_request_for_login_infos(request) + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_UNKNOWN, + client_name=user_agent_info['client_name'], + client_version=user_agent_info['client_version'], + client_ip=user_agent_info['client_ip'], + os_name=user_agent_info['os_name'], + os_version=user_agent_info['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=user_agent_info['server_endpoint'], + message=gettext('User already logged in :' + + current_user.user_name)) + raise UserAlreadyLoggedInError(gettext('User already logged in.')) + + def _verify_client_version(self) -> dict or None: + reply = {} + + # Extract login information + user_agent_info = UserAgentParser.parse_request_for_login_infos(request) + + # Extract information + if 'X-Client-Name' not in request.headers or 'X-Client-Version' not in request.headers: + # raise InvalidClientVersionError(gettext('Client information missing')) + return None + + client_name = request.headers['X-Client-Name'] + client_version = request.headers['X-Client-Version'] + + client_version_parts = client_version.split('.') + + # Load known version from database. + versions = TeraVersions() + versions.load_from_db() + + # Verify if we have client information in DB + client_info = versions.get_client_version_with_name(client_name) + if client_info: + # We have something stored for this client, let's verify version numbers + # For now, we still allow login even when version mismatch + # Reply full version information + reply = {'version_latest': client_info.to_dict()} + if client_info.version != client_version: + reply['version_error'] = gettext('Client major version mismatch') + # If major version mismatch, kill client, first part of the version + stored_client_version_parts = client_info.version.split('.') + if len(stored_client_version_parts) and len(client_version_parts): + if stored_client_version_parts[0] != client_version_parts[0]: + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_UNKNOWN, + client_name=user_agent_info['client_name'], + client_version=user_agent_info['client_version'], + client_ip=user_agent_info['client_ip'], + os_name=user_agent_info['os_name'], + os_version=user_agent_info['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=user_agent_info['server_endpoint'], + message=gettext('Client version mismatch')) + + raise OutdatedClientVersionError( + gettext('Client major version too old, not accepting login'), + version_latest=reply['version_latest'], + current_version=client_version_parts, + version_error=reply['version_error']) + else: + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_UNKNOWN, + client_name=user_agent_info['client_name'], + client_version=user_agent_info['client_version'], + client_ip=user_agent_info['client_ip'], + os_name=user_agent_info['os_name'], + os_version=user_agent_info['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=user_agent_info['server_endpoint'], + message=gettext('Unknown client name :') + client_name) + # For now, simply log the error, this will allow unknown clients to login + # raise InvalidClientVersionError(gettext('Invalid client name :') + client_name) + return reply + + def _generate_websocket_url(self) -> str: + websocket_url = f"wss://{self.servername}:{str(self.port)}/wss/user?id=\"{session['_id']}\"" + # The key is set with an expiration of 60s, will be verify when the websocket is opened in the TwistedModule + self.module.redisSet(session['_id'], session['_user_id'], ex=60) + return websocket_url + + def _generate_user_token(self) -> str: + token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) + return current_user.get_token(token_key) + + def _user_logout(self): + logout_user() + session.clear() + + def _send_login_success_message(self): + user_agent_info = UserAgentParser.parse_request_for_login_infos(request) + self.module.logger.send_login_event(sender=self.module.module_name, + level=messages.LogEvent.LOGLEVEL_INFO, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status=messages.LoginEvent.LOGIN_STATUS_SUCCESS, + client_name=user_agent_info['client_name'], + client_version=user_agent_info['client_version'], + client_ip=user_agent_info['client_ip'], + os_name=user_agent_info['os_name'], + os_version=user_agent_info['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=user_agent_info['server_endpoint']) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogout.py b/teraserver/python/modules/FlaskModule/API/user/UserLogout.py index 8ffaad43..597953fb 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogout.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogout.py @@ -20,7 +20,6 @@ def __init__(self, _api, *args, **kwargs): @user_multi_auth.login_required def get(self): if current_user: - print('logout user') logout_user() session.clear() self.module.send_user_disconnect_module_message(current_user.user_uuid) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/BaseUserAPITest.py b/teraserver/python/tests/modules/FlaskModule/API/user/BaseUserAPITest.py index 27ea6440..10d0459d 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/BaseUserAPITest.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/BaseUserAPITest.py @@ -151,65 +151,90 @@ def setup_redis_keys(self): TeraServerSettings.get_server_setting_value( TeraServerSettings.ServerParticipantTokenKey)) - def _get_with_user_token_auth(self, client: FlaskClient, token: str = '', params=None, endpoint=None): + def _get_with_user_token_auth(self, client: FlaskClient, token: str = '', params=None, endpoint=None, + opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': 'OpenTera ' + token} + if opt_headers is not None: + headers.update(opt_headers) + return client.get(endpoint, headers=headers, query_string=params) def _get_with_user_http_auth(self, client: FlaskClient, username: str = '', password: str = '', - params=None, endpoint=None): + params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: - params = {} + params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': _basic_auth_str(username, password)} + if opt_headers is not None: + headers.update(opt_headers) + return client.get(endpoint, headers=headers, query_string=params) def _post_with_user_token_auth(self, client: FlaskClient, token: str = '', json: dict = {}, - params: dict = None, endpoint: str = None): + params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': 'OpenTera ' + token} + + if opt_headers is not None: + headers.update(opt_headers) + return client.post(endpoint, headers=headers, query_string=params, json=json) def _post_with_user_http_auth(self, client: FlaskClient, username: str = '', password: str = '', - json: dict = {}, params: dict = None, endpoint: str = None): + json: dict = {}, params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': _basic_auth_str(username, password)} + if opt_headers is not None: + headers.update(opt_headers) return client.post(endpoint, headers=headers, query_string=params, json=json) def _post_file_with_user_http_auth(self, client: FlaskClient, files: dict, username: str = '', password: str = '', - params: dict = None, endpoint: str = None): + params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': _basic_auth_str(username, password)} + + if opt_headers is not None: + headers.update(opt_headers) + return client.post(endpoint, headers=headers, query_string=params, data=files) def _delete_with_user_token_auth(self, client: FlaskClient, token: str = '', - params: dict = None, endpoint: str = None): + params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': 'OpenTera ' + token} + + if opt_headers is not None: + headers.update(opt_headers) + return client.delete(endpoint, headers=headers, query_string=params) def _delete_with_user_http_auth(self, client: FlaskClient, username: str = '', password: str = '', - params: dict = None, endpoint: str = None): + params: dict = None, endpoint: str = None, opt_headers: dict = None): if params is None: params = {} if endpoint is None: endpoint = self.test_endpoint headers = {'Authorization': _basic_auth_str(username, password)} + + if opt_headers is not None: + headers.update(opt_headers) + return client.delete(endpoint, headers=headers, query_string=params) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index c99fdd0f..db8a62de 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -104,6 +104,60 @@ def test_get_endpoint_login_2fa_enabled_user_valid_code_with_websockets(self): self.assertTrue('websocket_url' in response.json) self.assertIsNotNone(response.json['websocket_url']) + def test_get_endpoint_login_2fa_enabled_user_unknown_app_name_and_version(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + totp = pyotp.TOTP(user.user_2fa_otp_secret) + params = {'otp_code': totp.now(), 'with_websocket': True} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + opt_headers={'X-Client-Name': 'test', 'X-Client-Version': '0.0.0'}) + self.assertEqual(200, response.status_code) + + def test_get_endpoint_login_2fa_enabled_user_outdated_app_version(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + totp = pyotp.TOTP(user.user_2fa_otp_secret) + params = {'otp_code': totp.now(), 'with_websocket': True} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + opt_headers={'X-Client-Name': 'OpenTeraPlus', + 'X-Client-Version': '0.0.0'}) + self.assertTrue('version_latest' in response.json) + self.assertTrue('version_error' in response.json) + self.assertEqual(426, response.status_code) + + def test_get_endpoint_login_2fa_enabled_user_valid_app_version(self): + with self._flask_app.app_context(): + # Create user with 2FA enabled + username = f'test_{pyotp.random_base32(32)}' + password = pyotp.random_base32(32) + user = self.create_user_with_2fa_enabled(username, password) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + # Login with user + totp = pyotp.TOTP(user.user_2fa_otp_secret) + params = {'otp_code': totp.now(), 'with_websocket': True} + response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + opt_headers={'X-Client-Name': 'OpenTeraPlus', + 'X-Client-Version': '1.0.0'}) + self.assertTrue('version_latest' in response.json) + self.assertFalse('version_error' in response.json) + self.assertEqual(200, response.status_code) + def create_user_with_2fa_enabled(self, username='test', password='test') -> TeraUser: # Create user with 2FA enabled user = TeraUser() From 73eb372710d302b5afeee229ddd89ec554a4c059 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 16 Sep 2024 10:07:02 -0400 Subject: [PATCH 07/67] Refs #253 Updated base implementation with exceptions. --- .../modules/FlaskModule/API/user/UserLogin2FA.py | 11 ++++------- .../modules/FlaskModule/API/user/UserLoginBase.py | 6 ------ 2 files changed, 4 insertions(+), 13 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py index 2b0570cf..fe66bc13 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -6,7 +6,7 @@ from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.FlaskModule.API.user.UserLoginBase import UserLoginBase from modules.FlaskModule.API.user.UserLoginBase import OutdatedClientVersionError, InvalidClientVersionError, \ - InvalidClientVersionHandlerError, UserAlreadyLoggedInError + UserAlreadyLoggedInError from werkzeug.exceptions import BadRequest from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames @@ -77,12 +77,9 @@ def get(self): 'current_version': e.current_version, 'version_error': e.version_error, 'message': gettext('Client major version too old, not accepting login')}, 426 - except InvalidClientVersionError as e: - # Invalid client version, will not be handled for now - pass - except InvalidClientVersionHandlerError as e: - self._user_logout() - return gettext('Invalid client version handler'), 500 +# except InvalidClientVersionError as e: +# # Invalid client version, will not be handled for now +# pass except UserAlreadyLoggedInError as e: self._user_logout() return gettext('User already logged in.'), 403 diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py index da7d6213..1a703a55 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -27,12 +27,6 @@ def __init__(self, message): super().__init__(message) -class InvalidClientVersionHandlerError(Exception): - # Raised when the client version handler is invalid - def __init__(self, message): - super().__init__(message) - - class UserAlreadyLoggedInError(Exception): # Raised when the user is already logged in def __init__(self, message): From af1cb3227bbb0759a572a45d9152bf3dab5fb058 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 16 Sep 2024 16:34:03 -0400 Subject: [PATCH 08/67] Refs #253 First tests with login views and 2fa. --- teraserver/python/env/requirements.txt | 1 + .../python/modules/FlaskModule/FlaskModule.py | 10 +++ .../modules/FlaskModule/Views/Login2FAView.py | 44 ++++++++++++ .../FlaskModule/Views/LoginEnable2FAView.py | 68 ++++++++++++++++++ .../modules/FlaskModule/Views/LoginView.py | 70 +++++++++++++++++++ .../python/modules/LoginModule/LoginModule.py | 24 ++++++- teraserver/python/templates/login.html | 45 ++++++++++++ teraserver/python/templates/login_2fa.html | 38 ++++++++++ .../python/templates/login_enable_2fa.html | 43 ++++++++++++ 9 files changed, 342 insertions(+), 1 deletion(-) create mode 100644 teraserver/python/modules/FlaskModule/Views/Login2FAView.py create mode 100644 teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py create mode 100644 teraserver/python/modules/FlaskModule/Views/LoginView.py create mode 100644 teraserver/python/templates/login.html create mode 100644 teraserver/python/templates/login_2fa.html create mode 100644 teraserver/python/templates/login_enable_2fa.html diff --git a/teraserver/python/env/requirements.txt b/teraserver/python/env/requirements.txt index a1950683..5dda103f 100644 --- a/teraserver/python/env/requirements.txt +++ b/teraserver/python/env/requirements.txt @@ -40,4 +40,5 @@ Jinja2==3.1.3 ua-parser==0.18.0 pyotp==2.9.0 pyqrcode==1.2.1 +pypng==0.20220715.0 diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index 85891fa3..5d7b1f56 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -353,6 +353,9 @@ def init_test_api(module: object, namespace: Namespace, additional_args: dict = def init_views(self): from modules.FlaskModule.Views.About import About from modules.FlaskModule.Views.DisabledDoc import DisabledDoc + from modules.FlaskModule.Views.LoginView import LoginView + from modules.FlaskModule.Views.LoginEnable2FAView import LoginEnable2FAView + from modules.FlaskModule.Views.Login2FAView import Login2FAView # Default arguments args = [] @@ -361,6 +364,13 @@ def init_views(self): # About flask_app.add_url_rule('/about', view_func=About.as_view('about', *args, **kwargs)) + # Login + flask_app.add_url_rule('/login', view_func=LoginView.as_view('login', *args, **kwargs)) + flask_app.add_url_rule('/login_enable_2fa', view_func=LoginEnable2FAView.as_view( + 'login_enable_2fa', *args, **kwargs)) + flask_app.add_url_rule('/login_2fa', view_func=Login2FAView.as_view( + 'login_2fa', *args, **kwargs)) + if not self.config.server_config['enable_docs']: # Disabled docs view flask_app.add_url_rule('/doc', view_func=DisabledDoc.as_view('doc', *args, **kwargs)) diff --git a/teraserver/python/modules/FlaskModule/Views/Login2FAView.py b/teraserver/python/modules/FlaskModule/Views/Login2FAView.py new file mode 100644 index 00000000..cfe394dd --- /dev/null +++ b/teraserver/python/modules/FlaskModule/Views/Login2FAView.py @@ -0,0 +1,44 @@ +from flask.views import MethodView +from flask import render_template, request, redirect, url_for, session +from opentera.utils.TeraVersions import TeraVersions +from modules.LoginModule.LoginModule import current_user, LoginModule + + +class Login2FAView(MethodView): + + def __init__(self, *args, **kwargs): + self.flaskModule = kwargs.get('flaskModule', None) + + @LoginModule.user_session_required + def get(self): + """ + GET method for the login enable 2FA page. This page is displayed when a user logs in and has 2FA disabled. + User must be authenticated to access this page. User will need to set 2FA to continue. + """ + + # Verify if user is authenticated, should be stored in session + # Return to login page + if not current_user: + return redirect(url_for('login')) + + hostname = self.flaskModule.config.server_config['hostname'] + port = self.flaskModule.config.server_config['port'] + + if 'X_EXTERNALSERVER' in request.headers: + hostname = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + versions = TeraVersions() + versions.load_from_db() + + return render_template('login_2fa.html', hostname=hostname, port=port, + server_version=versions.version_string, + openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + + def post(self): + pass + + + diff --git a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py new file mode 100644 index 00000000..1dd230e1 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py @@ -0,0 +1,68 @@ +from flask.views import MethodView +from flask import render_template, request, redirect, url_for, session +from opentera.utils.TeraVersions import TeraVersions +from modules.LoginModule.LoginModule import current_user, LoginModule +import pyotp +import pyqrcode + + +class LoginEnable2FAView(MethodView): + + def __init__(self, *args, **kwargs): + self.flaskModule = kwargs.get('flaskModule', None) + + @LoginModule.user_session_required + def get(self): + """ + GET method for the login enable 2FA page. This page is displayed when a user logs in and has 2FA disabled. + User must be authenticated to access this page. User will need to set 2FA to continue. + """ + + # Verify if user is authenticated, should be stored in session + # Return to login page + if not current_user: + return redirect(url_for('login')) + + hostname = self.flaskModule.config.server_config['hostname'] + port = self.flaskModule.config.server_config['port'] + + if 'X_EXTERNALSERVER' in request.headers: + hostname = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + versions = TeraVersions() + versions.load_from_db() + + # Generate a new secret for the user + current_user.user_2fa_enabled = False + current_user.user_2fa_enabled = False + current_user.user_2fa_otp_enabled = True + current_user.user_2fa_email_enabled = False + current_user.user_2fa_otp_secret = pyotp.random_base32() + # TODO Save user to db + + # Generate OTP URI for QR Code + totp = pyotp.TOTP(current_user.user_2fa_otp_secret) + + # TODO issuer_name should be configurable for each server + otp_uri = totp.provisioning_uri(current_user.user_email, issuer_name='OpenTera') + + # Generate QR Code with otp_uri + qr_code = pyqrcode.create(otp_uri) + + # Generate image as base64 + qr_code_base64 = qr_code.png_as_base64_str(scale=5) + + return render_template('login_enable_2fa.html', hostname=hostname, port=port, + server_version=versions.version_string, + qr_code=qr_code_base64, + openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + + @LoginModule.user_session_required + def post(self): + pass + + + diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py new file mode 100644 index 00000000..51a36394 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -0,0 +1,70 @@ +from flask.views import MethodView +from flask import render_template, request, redirect, url_for +from flask_login import login_user +from opentera.utils.TeraVersions import TeraVersions +from opentera.db.models.TeraUser import TeraUser +from modules.LoginModule.LoginModule import LoginModule + + +class LoginView(MethodView): + + def __init__(self, *args, **kwargs): + self.flaskModule = kwargs.get('flaskModule', None) + + def get(self): + hostname = self.flaskModule.config.server_config['hostname'] + port = self.flaskModule.config.server_config['port'] + + if 'X_EXTERNALSERVER' in request.headers: + hostname = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + versions = TeraVersions() + versions.load_from_db() + + return render_template('login.html', hostname=hostname, port=port, + server_version=versions.version_string, + openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + + def post(self): + # Verify the form + if 'username' not in request.form or 'password' not in request.form: + return 'Missing username or password', 400 + + # Get the user's name and password from the form + username = request.form['username'] + password = request.form['password'] + print(f'Username: {username}, Password: ******') + + # TODO Should use LoginModule instead of TeraUser directly + # Check the user's credentials + user = TeraUser.verify_password(username, password) + if user is None: + return 'Invalid username or password', 401 + + login_user(user, remember=False) + + # Check if the user has 2FA enabled + # We may want to change the behavior here according to a configuration flag + if not user.user_2fa_enabled: + # Redirect to enable 2FA page + return redirect(url_for('login_enable_2fa')) + else: + # Redirect to 2FA validation page + return redirect(url_for('login_validate_2fa')) + + return 'OK', 200 + + + + + + # Log the user in + + # redirect to the 2fa page + return 'Success', 200 + + + diff --git a/teraserver/python/modules/LoginModule/LoginModule.py b/teraserver/python/modules/LoginModule/LoginModule.py index a974d19b..7c4b84ba 100755 --- a/teraserver/python/modules/LoginModule/LoginModule.py +++ b/teraserver/python/modules/LoginModule/LoginModule.py @@ -16,7 +16,7 @@ import datetime import redis -from flask import request, g +from flask import request, g, session from flask_babel import gettext from werkzeug.local import LocalProxy from flask_restx import reqparse @@ -796,3 +796,25 @@ def decorated(*args, **kwargs): return gettext('Unauthorized'), 401 return decorated + + @staticmethod + def user_session_required(f): + """ + Use this decorator if a user session is required. A session is created when a user logs in. The session contains + the user UUID. + """ + @wraps(f) + def decorated(*args, **kwargs): + if '_user_id' in session: + # Verify if we have a valid user + user = TeraUser.get_user_by_uuid(session['_user_id']) + if user and user.user_enabled: + g.current_user = user + return f(*args, **kwargs) + else: + return gettext('Unauthorized'), 401 + else: + return gettext('Unauthorized'), 401 + + return decorated + diff --git a/teraserver/python/templates/login.html b/teraserver/python/templates/login.html new file mode 100644 index 00000000..c38ce36c --- /dev/null +++ b/teraserver/python/templates/login.html @@ -0,0 +1,45 @@ + + + + + OpenTera Login Page + + + + + + + + + +
+
+
+
+
Login
+
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/teraserver/python/templates/login_2fa.html b/teraserver/python/templates/login_2fa.html new file mode 100644 index 00000000..e5804b1f --- /dev/null +++ b/teraserver/python/templates/login_2fa.html @@ -0,0 +1,38 @@ + + + + + OpenTera Login Page + + + + + + + + + +
+
+
+
+
2FA
+
+
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/teraserver/python/templates/login_enable_2fa.html b/teraserver/python/templates/login_enable_2fa.html new file mode 100644 index 00000000..3c1d7cb6 --- /dev/null +++ b/teraserver/python/templates/login_enable_2fa.html @@ -0,0 +1,43 @@ + + + + + OpenTera Setup 2FA + + + + + + + + + + + +
+
+
+
+
Enable 2FA
+
+ QR Code +
+
+ +
+ +
+
+
+ +
+
+
+
+
+
+
+ + + + \ No newline at end of file From c22049bcc914144944a9e89128b5996ddb8d413c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Tue, 17 Sep 2024 09:46:26 -0400 Subject: [PATCH 09/67] Refs #253 Working login flow with otp enabled. --- .../python/modules/FlaskModule/FlaskModule.py | 6 +-- .../FlaskModule/Views/LoginEnable2FAView.py | 40 ++++++++++++++----- ...ogin2FAView.py => LoginValidate2FAView.py} | 22 ++++++++-- .../modules/FlaskModule/Views/LoginView.py | 27 +++---------- .../python/opentera/db/models/TeraUser.py | 11 +++++ .../python/templates/login_enable_2fa.html | 2 + ...login_2fa.html => login_validate_2fa.html} | 2 +- 7 files changed, 71 insertions(+), 39 deletions(-) rename teraserver/python/modules/FlaskModule/Views/{Login2FAView.py => LoginValidate2FAView.py} (64%) rename teraserver/python/templates/{login_2fa.html => login_validate_2fa.html} (95%) diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index 5d7b1f56..b7a6b08a 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -355,7 +355,7 @@ def init_views(self): from modules.FlaskModule.Views.DisabledDoc import DisabledDoc from modules.FlaskModule.Views.LoginView import LoginView from modules.FlaskModule.Views.LoginEnable2FAView import LoginEnable2FAView - from modules.FlaskModule.Views.Login2FAView import Login2FAView + from modules.FlaskModule.Views.LoginValidate2FAView import LoginValidate2FAView # Default arguments args = [] @@ -368,8 +368,8 @@ def init_views(self): flask_app.add_url_rule('/login', view_func=LoginView.as_view('login', *args, **kwargs)) flask_app.add_url_rule('/login_enable_2fa', view_func=LoginEnable2FAView.as_view( 'login_enable_2fa', *args, **kwargs)) - flask_app.add_url_rule('/login_2fa', view_func=Login2FAView.as_view( - 'login_2fa', *args, **kwargs)) + flask_app.add_url_rule('/login_validate_2fa', view_func=LoginValidate2FAView.as_view( + 'login_validate_2fa', *args, **kwargs)) if not self.config.server_config['enable_docs']: # Disabled docs view diff --git a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py index 1dd230e1..2b1996eb 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py @@ -2,8 +2,10 @@ from flask import render_template, request, redirect, url_for, session from opentera.utils.TeraVersions import TeraVersions from modules.LoginModule.LoginModule import current_user, LoginModule +from opentera.db.models.TeraUser import TeraUser import pyotp import pyqrcode +from flask_babel import gettext class LoginEnable2FAView(MethodView): @@ -36,18 +38,14 @@ def get(self): versions.load_from_db() # Generate a new secret for the user - current_user.user_2fa_enabled = False - current_user.user_2fa_enabled = False - current_user.user_2fa_otp_enabled = True - current_user.user_2fa_email_enabled = False - current_user.user_2fa_otp_secret = pyotp.random_base32() - # TODO Save user to db + secret = pyotp.random_base32() # Generate OTP URI for QR Code - totp = pyotp.TOTP(current_user.user_2fa_otp_secret) + totp = pyotp.TOTP(secret) - # TODO issuer_name should be configurable for each server - otp_uri = totp.provisioning_uri(current_user.user_email, issuer_name='OpenTera') + # Get the server name in the config + server_name = self.flaskModule.config.server_config['name'] + otp_uri = totp.provisioning_uri(current_user.user_username, issuer_name=f'OpenTera-{server_name}') # Generate QR Code with otp_uri qr_code = pyqrcode.create(otp_uri) @@ -58,11 +56,31 @@ def get(self): return render_template('login_enable_2fa.html', hostname=hostname, port=port, server_version=versions.version_string, qr_code=qr_code_base64, - openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + otp_secret=secret) @LoginModule.user_session_required def post(self): - pass + # Verify if user is authenticated, should be stored in session + if not current_user: + return redirect(url_for('login')) + + if 'enable_2fa' in request.form and request.form['enable_2fa'] == 'on' and 'otp_secret' in request.form: + # Enable 2FA + current_user.user_2fa_enabled = True + current_user.user_2fa_otp_enabled = True + current_user.user_2fa_email_enabled = False + # Save user to db + # TODO enable email 2FA + TeraUser.update(current_user.id_user, {'user_2fa_enabled': True, + 'user_2fa_otp_enabled': True, + 'user_2fa_otp_secret': request.form['otp_secret'], + 'user_2fa_email_enabled': False}) + + # Redirect to 2FA validation page + return redirect(url_for('login_2fa')) + + # Redirect to login page + return redirect(url_for('login')) diff --git a/teraserver/python/modules/FlaskModule/Views/Login2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py similarity index 64% rename from teraserver/python/modules/FlaskModule/Views/Login2FAView.py rename to teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py index cfe394dd..630334f1 100644 --- a/teraserver/python/modules/FlaskModule/Views/Login2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py @@ -2,9 +2,11 @@ from flask import render_template, request, redirect, url_for, session from opentera.utils.TeraVersions import TeraVersions from modules.LoginModule.LoginModule import current_user, LoginModule +from opentera.db.models.TeraUser import TeraUser +from flask_babel import gettext -class Login2FAView(MethodView): +class LoginValidate2FAView(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) @@ -33,12 +35,26 @@ def get(self): versions = TeraVersions() versions.load_from_db() - return render_template('login_2fa.html', hostname=hostname, port=port, + return render_template('login_validate_2fa.html', hostname=hostname, port=port, server_version=versions.version_string, openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + @LoginModule.user_session_required def post(self): - pass + # Verify the form + if '2fa_code' not in request.form: + return gettext('Missing 2FA code'), 400 + + # Get the user's 2FA code from the form + code = request.form['2fa_code'] + + # TODO Should use LoginModule instead of TeraUser directly ? + # Check the user's 2FA code + if not current_user.verify_2fa(code): + return gettext('Invalid 2FA code'), 401 + + # 2FA code is valid, return user information to client + return current_user.to_json() diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 51a36394..0ad4c9c4 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -4,6 +4,7 @@ from opentera.utils.TeraVersions import TeraVersions from opentera.db.models.TeraUser import TeraUser from modules.LoginModule.LoginModule import LoginModule +from flask_babel import gettext class LoginView(MethodView): @@ -25,24 +26,22 @@ def get(self): versions.load_from_db() return render_template('login.html', hostname=hostname, port=port, - server_version=versions.version_string, - openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) + server_version=versions.version_string) def post(self): # Verify the form if 'username' not in request.form or 'password' not in request.form: - return 'Missing username or password', 400 + return gettext('Missing username or password'), 400 # Get the user's name and password from the form username = request.form['username'] password = request.form['password'] - print(f'Username: {username}, Password: ******') # TODO Should use LoginModule instead of TeraUser directly # Check the user's credentials user = TeraUser.verify_password(username, password) if user is None: - return 'Invalid username or password', 401 + return gettext('Invalid username or password'), 401 login_user(user, remember=False) @@ -51,20 +50,6 @@ def post(self): if not user.user_2fa_enabled: # Redirect to enable 2FA page return redirect(url_for('login_enable_2fa')) - else: - # Redirect to 2FA validation page - return redirect(url_for('login_validate_2fa')) - - return 'OK', 200 - - - - - - # Log the user in - - # redirect to the 2fa page - return 'Success', 200 - - + # Redirect to 2FA validation page + return redirect(url_for('login_validate_2fa')) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index 07bc20a1..32ce2430 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -139,6 +139,17 @@ def enable_2fa_otp(self) -> bool: self.user_2fa_otp_secret = pyotp.random_base32() return True + def verify_2fa(self, code: str) -> bool: + if not self.user_2fa_enabled: + return False + + if self.user_2fa_otp_enabled: + # Default is 6 digits with interval of 30 seconds + totp = pyotp.TOTP(self.user_2fa_otp_secret) + return totp.verify(code) + + return False + def get_service_access_dict(self): service_access = {} diff --git a/teraserver/python/templates/login_enable_2fa.html b/teraserver/python/templates/login_enable_2fa.html index 3c1d7cb6..8207107d 100644 --- a/teraserver/python/templates/login_enable_2fa.html +++ b/teraserver/python/templates/login_enable_2fa.html @@ -31,6 +31,8 @@
+ + diff --git a/teraserver/python/templates/login_2fa.html b/teraserver/python/templates/login_validate_2fa.html similarity index 95% rename from teraserver/python/templates/login_2fa.html rename to teraserver/python/templates/login_validate_2fa.html index e5804b1f..5722c4fd 100644 --- a/teraserver/python/templates/login_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -18,7 +18,7 @@
2FA
-
+
From b6c8ba5ae786cca786ef92afb1610687a3983073 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Tue, 17 Sep 2024 15:48:38 -0400 Subject: [PATCH 10/67] Refs #253 Sending JSON output when logged in. --- .../python/modules/FlaskModule/Views/About.py | 1 + .../modules/FlaskModule/Views/DisabledDoc.py | 1 + .../FlaskModule/Views/LoginEnable2FAView.py | 1 + .../FlaskModule/Views/LoginValidate2FAView.py | 64 ++++++++++++++++++- .../modules/FlaskModule/Views/LoginView.py | 1 + 5 files changed, 65 insertions(+), 3 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/Views/About.py b/teraserver/python/modules/FlaskModule/Views/About.py index 238e8698..cc5de3e1 100644 --- a/teraserver/python/modules/FlaskModule/Views/About.py +++ b/teraserver/python/modules/FlaskModule/Views/About.py @@ -8,6 +8,7 @@ class About(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) # Anybody can view this? # @user_multi_auth.login_required diff --git a/teraserver/python/modules/FlaskModule/Views/DisabledDoc.py b/teraserver/python/modules/FlaskModule/Views/DisabledDoc.py index 561a738c..f22bcc22 100644 --- a/teraserver/python/modules/FlaskModule/Views/DisabledDoc.py +++ b/teraserver/python/modules/FlaskModule/Views/DisabledDoc.py @@ -7,6 +7,7 @@ class DisabledDoc(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) # Anybody can view this? # @user_multi_auth.login_required diff --git a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py index 2b1996eb..e2ccb2f5 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py @@ -12,6 +12,7 @@ class LoginEnable2FAView(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) @LoginModule.user_session_required def get(self): diff --git a/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py index 630334f1..bfd663c3 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py @@ -1,15 +1,22 @@ from flask.views import MethodView -from flask import render_template, request, redirect, url_for, session +from flask import render_template, request, redirect, url_for, session, jsonify from opentera.utils.TeraVersions import TeraVersions from modules.LoginModule.LoginModule import current_user, LoginModule from opentera.db.models.TeraUser import TeraUser from flask_babel import gettext +from opentera.utils.UserAgentParser import UserAgentParser + +import opentera.messages.python as messages +from opentera.redis.RedisVars import RedisVars +from opentera.redis.RedisRPCClient import RedisRPCClient +from opentera.modules.BaseModule import ModuleNames class LoginValidate2FAView(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) @LoginModule.user_session_required def get(self): @@ -53,8 +60,59 @@ def post(self): if not current_user.verify_2fa(code): return gettext('Invalid 2FA code'), 401 - # 2FA code is valid, return user information to client - return current_user.to_json() + # TODO This is duplication from the API login endpoint, how to avoid this ? + hostname = self.flaskModule.config.server_config['hostname'] + port = self.flaskModule.config.server_config['port'] + + if 'X_EXTERNALSERVER' in request.headers: + hostname = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + # Generate user token + # Get user token key from redis + token_key = self.flaskModule.redisGet(RedisVars.RedisVar_UserTokenAPIKey) + + # Get login information for log + login_info = UserAgentParser.parse_request_for_login_infos(request) + + # Verify if user already logged in + online_users = [] + websocket_url = None + + if not self.test: + rpc = RedisRPCClient(self.flaskModule.config.redis_config) + online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') + + if current_user.user_uuid not in online_users: + websocket_url = "wss://" + hostname + ":" + str(port) + "/wss/user?id=" + session['_id'] + self.flaskModule.redisSet(session['_id'], session['_user_id'], ex=60) + else: + # User is online and a websocket is required + self.flaskModule.logger.send_login_event(sender=self.flaskModule.module_name, + level=messages.LogEvent.LOGLEVEL_ERROR, + login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, + login_status= + messages.LoginEvent.LOGIN_STATUS_FAILED_WITH_ALREADY_LOGGED_IN, + client_name=login_info['client_name'], + client_version=login_info['client_version'], + client_ip=login_info['client_ip'], + os_name=login_info['os_name'], + os_version=login_info['os_version'], + user_uuid=current_user.user_uuid, + server_endpoint=login_info['server_endpoint']) + + return gettext('User already logged in.'), 403 + + current_user.update_last_online() + user_token = current_user.get_token(token_key) + + reply = {"user_uuid": session['_user_id'], + "user_token": user_token, + "websocket_url": websocket_url} + + return jsonify(reply) diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 0ad4c9c4..5ca323cb 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -11,6 +11,7 @@ class LoginView(MethodView): def __init__(self, *args, **kwargs): self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) def get(self): hostname = self.flaskModule.config.server_config['hostname'] From c0c9b6a3fd3fc30581316fed476c15e661fcfeea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 19 Sep 2024 12:09:51 -0400 Subject: [PATCH 11/67] Refs #253, testing Qt app with webchannel. --- .../modules/FlaskModule/Views/LoginEnable2FAView.py | 2 +- teraserver/python/templates/login_validate_2fa.html | 11 +++++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py index e2ccb2f5..64ec2f29 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginEnable2FAView.py @@ -78,7 +78,7 @@ def post(self): 'user_2fa_email_enabled': False}) # Redirect to 2FA validation page - return redirect(url_for('login_2fa')) + return redirect(url_for('login_validate_2fa')) # Redirect to login page return redirect(url_for('login')) diff --git a/teraserver/python/templates/login_validate_2fa.html b/teraserver/python/templates/login_validate_2fa.html index 5722c4fd..6dd2649a 100644 --- a/teraserver/python/templates/login_validate_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -9,6 +9,17 @@ + + From 17ae671b917be8a16986065b6413442a00a67819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 19 Sep 2024 16:38:48 -0400 Subject: [PATCH 12/67] Refs #253, working Qt with webchannel. --- .../FlaskModule/API/user/UserLogin2FA.py | 38 ++++++++--- .../FlaskModule/API/user/UserLoginBase.py | 4 +- .../python/templates/login_validate_2fa.html | 67 +++++++++++++++++++ 3 files changed, 96 insertions(+), 13 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py index fe66bc13..65b9df87 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -17,13 +17,21 @@ import pyotp from opentera.db.models.TeraUser import TeraUser +# Get parser get_parser = api.parser() +get_parser.add_argument('otp_code', type=str, required=True, help='2FA otp code') get_parser.add_argument('with_websocket', type=inputs.boolean, help='If set, requires that a websocket url is returned.' 'If not possible to do so, return a 403 error.', default=False) -get_parser.add_argument('otp_code', type=str, required=True, help='2FA otp code') +# Post parser +post_parser = api.parser() +post_parser.add_argument('otp_code', type=str, required=True, help='2FA otp code') +post_parser.add_argument('with_websocket', type=inputs.boolean, + help='If set, requires that a websocket url is returned.' + 'If not possible to do so, return a 403 error.', + default=False) class UserLogin2FA(UserLoginBase): @@ -31,14 +39,13 @@ class UserLogin2FA(UserLoginBase): def __init__(self, _api, *args, **kwargs): UserLoginBase.__init__(self, _api, *args, **kwargs) - @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth) and 2FA') - @api.expect(get_parser, validate=True) - @user_http_auth.login_required - def get(self): + # TODO Move this to UserLoginBase ? + def _common_2fa_login_response(self, parser): try: - args = get_parser.parse_args(strict=True) + # Validate args + args = parser.parse_args(strict=True) - # Current user is logged in with HTTPAuth + # Current user is logged in with HTTPAuth, or session # Let's verify if 2FA is enabled and if OTP is valid if not current_user.user_2fa_enabled: self._user_logout() @@ -77,9 +84,9 @@ def get(self): 'current_version': e.current_version, 'version_error': e.version_error, 'message': gettext('Client major version too old, not accepting login')}, 426 -# except InvalidClientVersionError as e: -# # Invalid client version, will not be handled for now -# pass + # except InvalidClientVersionError as e: + # # Invalid client version, will not be handled for now + # pass except UserAlreadyLoggedInError as e: self._user_logout() return gettext('User already logged in.'), 403 @@ -92,6 +99,15 @@ def get(self): self._send_login_success_message() return response, 200 + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth) and 2FA') + @api.expect(get_parser, validate=True) + @user_http_auth.login_required + def get(self): + return self._common_2fa_login_response(get_parser) - + @api.doc(description='Login to the server using HTTP Basic Authentication (session auth) and 2FA') + @api.expect(post_parser, validate=True) + @LoginModule.user_session_required + def post(self): + return self._common_2fa_login_response(post_parser) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py index 1a703a55..debe2e8c 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -144,8 +144,8 @@ def _verify_client_version(self) -> dict or None: return reply def _generate_websocket_url(self) -> str: - websocket_url = f"wss://{self.servername}:{str(self.port)}/wss/user?id=\"{session['_id']}\"" - # The key is set with an expiration of 60s, will be verify when the websocket is opened in the TwistedModule + websocket_url = f"wss://{self.servername}:{str(self.port)}/wss/user?id={session['_id']}" + # The key is set with an expiration of 60s, will be verified when the websocket is opened in the TwistedModule self.module.redisSet(session['_id'], session['_user_id'], ex=60) return websocket_url diff --git a/teraserver/python/templates/login_validate_2fa.html b/teraserver/python/templates/login_validate_2fa.html index 6dd2649a..6b5b50f1 100644 --- a/teraserver/python/templates/login_validate_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -20,6 +20,48 @@ qtObject.handleToken(token); } + + + + + + @@ -29,6 +71,9 @@
2FA
+ + + + + + +
+ +
+ +
+ + +
+ +
+
+ + + + +
From 5a3e8376bc37b38ac5af07503f6c104df73b47d1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 23 Sep 2024 11:43:14 -0400 Subject: [PATCH 13/67] Refs #253, implementing change password, work in progress --- .../python/modules/FlaskModule/FlaskModule.py | 3 + .../Views/LoginChangePasswordView.py | 63 +++++++++++++++++++ .../modules/FlaskModule/Views/LoginView.py | 16 +++-- .../python/opentera/db/models/TeraUser.py | 6 +- .../python/opentera/forms/TeraUserForm.py | 14 ++++- .../templates/login_change_password.html | 49 +++++++++++++++ .../python/templates/login_validate_2fa.html | 4 -- 7 files changed, 143 insertions(+), 12 deletions(-) create mode 100644 teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py create mode 100644 teraserver/python/templates/login_change_password.html diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index b7a6b08a..4e20dcb1 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -354,6 +354,7 @@ def init_views(self): from modules.FlaskModule.Views.About import About from modules.FlaskModule.Views.DisabledDoc import DisabledDoc from modules.FlaskModule.Views.LoginView import LoginView + from modules.FlaskModule.Views.LoginChangePasswordView import LoginChangePasswordView from modules.FlaskModule.Views.LoginEnable2FAView import LoginEnable2FAView from modules.FlaskModule.Views.LoginValidate2FAView import LoginValidate2FAView @@ -366,6 +367,8 @@ def init_views(self): # Login flask_app.add_url_rule('/login', view_func=LoginView.as_view('login', *args, **kwargs)) + flask_app.add_url_rule('/login_change_password', view_func=LoginChangePasswordView.as_view( + 'login_change_password', *args, **kwargs)) flask_app.add_url_rule('/login_enable_2fa', view_func=LoginEnable2FAView.as_view( 'login_enable_2fa', *args, **kwargs)) flask_app.add_url_rule('/login_validate_2fa', view_func=LoginValidate2FAView.as_view( diff --git a/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py b/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py new file mode 100644 index 00000000..b8f305d5 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py @@ -0,0 +1,63 @@ +from flask.views import MethodView +from flask import render_template, request, redirect, url_for +from flask_login import login_user, logout_user +from opentera.utils.TeraVersions import TeraVersions +from opentera.db.models.TeraUser import TeraUser +from modules.LoginModule.LoginModule import LoginModule +from flask_babel import gettext +from modules.LoginModule.LoginModule import current_user, LoginModule + +class LoginChangePasswordView(MethodView): + + def __init__(self, *args, **kwargs): + self.flaskModule = kwargs.get('flaskModule', None) + self.test = kwargs.get('test', False) + + @LoginModule.user_session_required + def get(self): + hostname = self.flaskModule.config.server_config['hostname'] + port = self.flaskModule.config.server_config['port'] + + if 'X_EXTERNALSERVER' in request.headers: + hostname = request.headers['X_EXTERNALSERVER'] + + if 'X_EXTERNALPORT' in request.headers: + port = request.headers['X_EXTERNALPORT'] + + versions = TeraVersions() + versions.load_from_db() + + return render_template('login_change_password.html', hostname=hostname, port=port, + server_version=versions.version_string, username=current_user.user_username) + + @LoginModule.user_session_required + def post(self): + + # Verify if form is complete + if ('old_password' not in request.form or 'new_password' not in request.form + or 'confirm_password' not in request.form): + return gettext('Missing information'), 400 + + # Get form information + old_password = request.form['old_password'] + new_password = request.form['new_password'] + confirm_password = request.form['confirm_password'] + + # Validate if new password and confirm password are the same + if new_password != confirm_password: + logout_user() + return gettext('New password and confirm password do not match'), 400 + + # Validate if old password is correct + if TeraUser.verify_password(current_user.user_username, old_password) is None: + logout_user() + return gettext('Invalid old password'), 400 + + # Change password, will be encrypted + # Will also reset force password change flag + TeraUser.update(current_user.id_user, {'user_password': new_password, + 'user_force_password_change': False }) + + logout_user() + + return redirect(url_for('login')) diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 5ca323cb..150c6864 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -46,11 +46,19 @@ def post(self): login_user(user, remember=False) + # Test for password change first + if user.user_force_password_change: + return redirect(url_for('login_change_password')) + # Check if the user has 2FA enabled # We may want to change the behavior here according to a configuration flag - if not user.user_2fa_enabled: + if user.user_2fa_enabled and user.user_2fa_secret is None: # Redirect to enable 2FA page return redirect(url_for('login_enable_2fa')) - - # Redirect to 2FA validation page - return redirect(url_for('login_validate_2fa')) + elif user.user_2fa_enabled: + # Redirect to 2FA validation page + return redirect(url_for('login_validate_2fa')) + else: + # TODO Make standard user log in properly + # Should be logged in as a standard user + return redirect(url_for('login')) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index 32ce2430..95920d0e 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -75,11 +75,11 @@ def to_json(self, ignore_fields=None, minimal=False): if ignore_fields is None: ignore_fields = [] ignore_fields.extend(['authenticated', 'user_password', 'user_user_groups', - 'user_sessions', 'user_2fa_enabled', 'user_2fa_otp_enabled', - 'user_2fa_email_enabled', 'user_2fa_otp_secret', 'user_force_password_change']) + 'user_sessions', 'user_2fa_otp_secret']) if minimal: ignore_fields.extend(['user_username', 'user_email', 'user_profile', 'user_notes', 'user_lastonline', - 'user_superadmin']) + 'user_superadmin' 'user_2fa_enabled', 'user_2fa_otp_enabled', + 'user_2fa_email_enabled', 'user_force_password_change']) rval = super().to_json(ignore_fields=ignore_fields) rval['user_name'] = self.get_fullname() return rval diff --git a/teraserver/python/opentera/forms/TeraUserForm.py b/teraserver/python/opentera/forms/TeraUserForm.py index 7811b35c..e0a7127c 100644 --- a/teraserver/python/opentera/forms/TeraUserForm.py +++ b/teraserver/python/opentera/forms/TeraUserForm.py @@ -18,7 +18,19 @@ def get_user_form(): section.add_item(TeraFormItem("user_uuid", gettext("User UUID"), "hidden")) section.add_item(TeraFormItem("user_name", gettext("User Full Name"), "hidden")) section.add_item(TeraFormItem("user_username", gettext("Username"), "text", True)) - section.add_item(TeraFormItem("user_enabled", gettext("User Enabled"), "boolean", True, item_default=True)) + section.add_item(TeraFormItem("user_enabled", gettext("User Enabled"), "boolean", + True, item_default=True)) + section.add_item(TeraFormItem("user_force_password_change", gettext("Force password change"), + "boolean", False, item_default=False)) + section.add_item(TeraFormItem("user_2fa_enabled", gettext("2FA Enabled"), "boolean", + False, item_default=False)) + + section.add_item(TeraFormItem("user_2fa_otp_enabled", gettext("2FA OTP Enabled"), "boolean", + False, item_default=False)) + section.add_item(TeraFormItem("user_2fa_email_enabled", gettext("2FA Email Enabled"), "boolean", + False, item_default=False)) + + # section.add_item(TeraFormItem("user_2fa_otp_secret", gettext("OTP Secret"), "hidden")) section.add_item(TeraFormItem("user_firstname", gettext("First Name"), "text", True)) section.add_item(TeraFormItem("user_lastname", gettext("Last Name"), "text", True)) section.add_item(TeraFormItem("user_email", gettext("Email"), "text")) diff --git a/teraserver/python/templates/login_change_password.html b/teraserver/python/templates/login_change_password.html new file mode 100644 index 00000000..1777b163 --- /dev/null +++ b/teraserver/python/templates/login_change_password.html @@ -0,0 +1,49 @@ + + + + + OpenTera Login Change Password + + + + + + + + + +
+
+
+
+
Change password for user: {{ username }}
+
+
+
+ +
+ +
+ + +
+ +
+ + +
+ +
+
+
+ +
+
+
+
+
+
+
+ + \ No newline at end of file diff --git a/teraserver/python/templates/login_validate_2fa.html b/teraserver/python/templates/login_validate_2fa.html index 6b5b50f1..ce36ec95 100644 --- a/teraserver/python/templates/login_validate_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -103,10 +103,6 @@
- - - -
From 125084a17f27610e05f612c437d16a7d0ebc9791 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 23 Sep 2024 16:41:03 -0400 Subject: [PATCH 14/67] Refs #253, implementing change password, work in progress --- .../modules/FlaskModule/Views/LoginView.py | 2 +- teraserver/python/templates/login.html | 37 ++++++++++++++++++- .../python/templates/login_validate_2fa.html | 6 +-- 3 files changed, 39 insertions(+), 6 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 150c6864..7967ec28 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -52,7 +52,7 @@ def post(self): # Check if the user has 2FA enabled # We may want to change the behavior here according to a configuration flag - if user.user_2fa_enabled and user.user_2fa_secret is None: + if user.user_2fa_enabled and user.user_2fa_otp_secret is None: # Redirect to enable 2FA page return redirect(url_for('login_enable_2fa')) elif user.user_2fa_enabled: diff --git a/teraserver/python/templates/login.html b/teraserver/python/templates/login.html index c38ce36c..11226c8d 100644 --- a/teraserver/python/templates/login.html +++ b/teraserver/python/templates/login.html @@ -9,6 +9,37 @@ + @@ -18,7 +49,7 @@
Login
-
+
@@ -37,6 +68,10 @@
+ +
diff --git a/teraserver/python/templates/login_validate_2fa.html b/teraserver/python/templates/login_validate_2fa.html index ce36ec95..4832091c 100644 --- a/teraserver/python/templates/login_validate_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -15,10 +15,6 @@ new QWebChannel(qt.webChannelTransport, function(channel) { qtObject = channel.objects.qtObject; }); - - function sendTokenToQt(token) { - qtObject.handleToken(token); - } @@ -51,10 +47,12 @@ qtObject.sendLoginSuccess(data.user_token, data.websocket_url, data.user_uuid); } else { alert("Error validating 2FA code"); + qtObject.sendLoginFailure("Error validating 2FA code"); } }, error: function() { alert("Error validating 2FA code"); + qtObject.sendLoginFailure("Error validating 2FA code"); } }); From 1070e6c79d9ec458ae2dddff1762f0d6fb595eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Wed, 25 Sep 2024 09:35:55 -0400 Subject: [PATCH 15/67] Refs #253, updating requirements. --- teraserver/python/env/requirements.txt | 37 +++++++++++++------------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/teraserver/python/env/requirements.txt b/teraserver/python/env/requirements.txt index 5dda103f..63620809 100644 --- a/teraserver/python/env/requirements.txt +++ b/teraserver/python/env/requirements.txt @@ -1,44 +1,43 @@ pypiwin32==223; sys_platform == 'win32' -Twisted==24.3.0 -treq==23.11.0 +Twisted==24.7.0 +treq==24.9.1 cryptography==43.0.1 -autobahn==23.6.2 -SQLAlchemy==2.0.28 +autobahn==24.4.2 +SQLAlchemy==2.0.35 sqlalchemy-schemadisplay==2.0 -pydot==2.0.0 +pydot==3.0.1 psycopg2-binary==2.9.9 -Flask==3.0.2 +Flask==3.0.3 Flask-SQLAlchemy==3.1.1 Flask-Login==0.6.3 Flask-Login-Multi==0.1.2 Flask-HTTPAuth==4.8.0 -Flask-SocketIO==5.3.6 -Flask-Session==0.6.0 +Flask-SocketIO==5.3.7 +Flask-Session==0.8.0 flask-restx==1.3.0 -Flask-Security==3.0.0 +Flask-Security==5.5.2 Flask-Babel==4.0.0 Flask-BabelEx==0.9.4 -Flask-Migrate==4.0.5 +Flask-Migrate==4.0.7 flask-swagger-ui==4.11.1 -Flask-Limiter==3.5.1 -Flask-Mail==0.9.1 +Flask-Limiter==3.8.0 +Flask-Mail==0.10.0 Flask-Principal==0.4.0 -redis==5.0.2 +redis==5.0.8 txredisapi==1.4.10 passlib==1.7.4 -bcrypt==4.1.2 +bcrypt==4.2.0 WTForms==3.1.2 pyOpenSSL==24.2.1 service-identity==24.1.0 -PyJWT==2.8.0 +PyJWT==2.9.0 pylzma==0.5.0 bz2file==0.98 python-slugify==8.0.4 -websocket-client==1.7.0 -pytest==8.0.2 -Jinja2==3.1.3 +websocket-client==1.8.0 +pytest==8.3.3 +Jinja2==3.1.4 ua-parser==0.18.0 pyotp==2.9.0 pyqrcode==1.2.1 pypng==0.20220715.0 - From cdb084fbe01304f0b13393a8ce2d1e263ca1312d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Wed, 25 Sep 2024 11:56:05 -0400 Subject: [PATCH 16/67] Refs #253, fixing tests for vscode. --- teraserver/python/.env.vscode | 1 + teraserver/python/.vscode/launch.json | 33 +++++++++++++++++++ teraserver/python/.vscode/settings.json | 13 ++++++++ teraserver/python/tests/__init__.py | 0 .../tests/modules/FlaskModule/API/__init__.py | 0 .../FlaskModule/API/device/__init__.py | 0 .../API/device/test_DeviceLogin.py | 2 +- .../API/device/test_DeviceLogout.py | 2 +- .../API/device/test_DeviceQueryAssets.py | 2 +- .../API/device/test_DeviceQueryDevices.py | 2 +- .../device/test_DeviceQueryParticipants.py | 2 +- .../device/test_DeviceQuerySessionEvents.py | 2 +- .../API/device/test_DeviceQuerySessions.py | 2 +- .../API/device/test_DeviceQueryStatus.py | 2 +- .../API/device/test_DeviceRegister.py | 2 +- .../FlaskModule/API/participant/__init__.py | 0 .../API/participant/test_ParticipantLogin.py | 2 +- .../API/participant/test_ParticipantLogout.py | 2 +- .../test_ParticipantQueryAssets.py | 2 +- .../test_ParticipantQueryDevices.py | 2 +- .../test_ParticipantQueryParticipants.py | 2 +- .../test_ParticipantQuerySessions.py | 2 +- .../test_ParticipantRefreshToken.py | 2 +- .../FlaskModule/API/service/__init__.py | 0 .../API/service/test_ServiceQueryAccess.py | 3 +- .../API/service/test_ServiceQueryAssets.py | 2 +- .../API/service/test_ServiceQueryDevices.py | 2 +- .../service/test_ServiceQueryDisconnect.py | 2 +- .../test_ServiceQueryParticipantGroups.py | 4 +-- .../service/test_ServiceQueryParticipants.py | 2 +- .../API/service/test_ServiceQueryProjects.py | 2 +- .../API/service/test_ServiceQueryRoles.py | 2 +- .../service/test_ServiceQueryServiceAccess.py | 4 +-- .../API/service/test_ServiceQueryServices.py | 2 +- .../service/test_ServiceQuerySessionEvents.py | 2 +- .../service/test_ServiceQuerySessionTypes.py | 2 +- .../API/service/test_ServiceQuerySessions.py | 2 +- ...test_ServiceQuerySiteProjectAccessRoles.py | 4 +-- .../API/service/test_ServiceQuerySites.py | 2 +- .../test_ServiceQueryTestTypeProjects.py | 2 +- .../API/service/test_ServiceQueryTestTypes.py | 4 +-- .../API/service/test_ServiceQueryTests.py | 2 +- .../service/test_ServiceQueryUserGroups.py | 2 +- .../API/service/test_ServiceQueryUsers.py | 2 +- .../API/service/test_ServiceSessionManager.py | 2 +- .../modules/FlaskModule/API/user/__init__.py | 0 .../user/_test_UserQueryServiceAccessToken.py | 2 +- .../FlaskModule/API/user/test_UserLogin.py | 2 +- .../FlaskModule/API/user/test_UserLogin2FA.py | 4 +-- .../FlaskModule/API/user/test_UserLogout.py | 2 +- .../API/user/test_UserQueryAssets.py | 4 +-- .../user/test_UserQueryDeviceParticipants.py | 2 +- .../API/user/test_UserQueryDeviceProjects.py | 2 +- .../API/user/test_UserQueryDisconnect.py | 2 +- .../API/user/test_UserQueryForms.py | 2 +- .../user/test_UserQueryParticipantGroup.py | 2 +- .../API/user/test_UserQueryParticipants.py | 2 +- .../API/user/test_UserQueryProjectAccess.py | 10 +++--- .../API/user/test_UserQueryProjects.py | 2 +- .../API/user/test_UserQueryServerSettings.py | 2 +- .../API/user/test_UserQueryServiceAccess.py | 2 +- .../API/user/test_UserQueryServiceConfigs.py | 2 +- .../API/user/test_UserQueryServiceProjects.py | 2 +- .../API/user/test_UserQueryServiceRoles.py | 2 +- .../API/user/test_UserQueryServiceSites.py | 2 +- .../API/user/test_UserQueryServices.py | 2 +- .../API/user/test_UserQuerySessionEvents.py | 2 +- .../user/test_UserQuerySessionTypeProject.py | 2 +- .../user/test_UserQuerySessionTypeSites.py | 6 ++-- .../API/user/test_UserQuerySiteAccess.py | 2 +- .../API/user/test_UserQuerySites.py | 2 +- .../user/test_UserQueryTestTypeProjects.py | 8 ++--- .../API/user/test_UserQueryTestTypeSites.py | 2 +- .../API/user/test_UserQueryUserGroups.py | 2 +- .../API/user/test_UserQueryVersions.py | 2 +- .../API/user/test_UserRefreshToken.py | 2 +- .../API/user/test_UserSessionManager.py | 4 +-- .../tests/modules/FlaskModule/__init__.py | 0 teraserver/python/tests/modules/__init__.py | 0 teraserver/python/tests/opentera/__init__.py | 0 .../python/tests/opentera/db/__init__.py | 0 .../tests/opentera/db/models/__init__.py | 0 .../opentera/db/models/test_TeraAsset.py | 9 +++-- .../opentera/db/models/test_TeraDevice.py | 28 ++++++++-------- .../db/models/test_TeraParticipant.py | 18 +++++----- .../db/models/test_TeraParticipantGroup.py | 6 ++-- .../opentera/db/models/test_TeraProject.py | 14 ++++---- .../opentera/db/models/test_TeraService.py | 8 ++--- .../opentera/db/models/test_TeraSession.py | 12 +++---- .../db/models/test_TeraSessionType.py | 10 +++--- .../tests/opentera/db/models/test_TeraSite.py | 20 +++++------ .../tests/opentera/db/models/test_TeraTest.py | 8 ++--- .../opentera/db/models/test_TeraTestType.py | 8 ++--- .../tests/opentera/db/models/test_TeraUser.py | 16 ++++----- .../opentera/db/models/test_TeraUserGroup.py | 6 ++-- .../BaseFileTransferServiceAPITest.py | 2 +- .../services/FileTransferService/__init__.py | 0 .../BaseLoggingServiceAPITest.py | 2 +- .../tests/services/LoggingService/__init__.py | 0 .../LoggingService/test_QueryLogEntries.py | 10 +++--- .../LoggingService/test_QueryLoginEntries.py | 2 +- teraserver/python/tests/services/__init__.py | 0 102 files changed, 218 insertions(+), 177 deletions(-) create mode 100644 teraserver/python/.env.vscode create mode 100644 teraserver/python/.vscode/launch.json create mode 100644 teraserver/python/.vscode/settings.json create mode 100644 teraserver/python/tests/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/device/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/participant/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/service/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/API/user/__init__.py create mode 100644 teraserver/python/tests/modules/FlaskModule/__init__.py create mode 100644 teraserver/python/tests/modules/__init__.py create mode 100644 teraserver/python/tests/opentera/__init__.py create mode 100644 teraserver/python/tests/opentera/db/__init__.py create mode 100644 teraserver/python/tests/opentera/db/models/__init__.py create mode 100644 teraserver/python/tests/services/FileTransferService/__init__.py create mode 100644 teraserver/python/tests/services/LoggingService/__init__.py create mode 100644 teraserver/python/tests/services/__init__.py diff --git a/teraserver/python/.env.vscode b/teraserver/python/.env.vscode new file mode 100644 index 00000000..74e866aa --- /dev/null +++ b/teraserver/python/.env.vscode @@ -0,0 +1 @@ +PYTHONPATH=${workspaceFolder} diff --git a/teraserver/python/.vscode/launch.json b/teraserver/python/.vscode/launch.json new file mode 100644 index 00000000..8b0ce7ae --- /dev/null +++ b/teraserver/python/.vscode/launch.json @@ -0,0 +1,33 @@ +{ + // Utilisez IntelliSense pour en savoir plus sur les attributs possibles. + // Pointez pour afficher la description des attributs existants. + // Pour plus d'informations, visitez : https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "Python: OpenTera Server TeraServer.py", + "type": "debugpy", + "request": "launch", + "program": "${workspaceFolder}/TeraServer.py", + "console": "integratedTerminal", + "env": { + "PYTHONPATH": "${workspaceFolder}" + } + }, + { + "name": "Python Debugger: Attach", + "type": "debugpy", + "request": "attach", + "connect": { + "host": "localhost", + "port": 5688 + }, + "pathMappings": [ + { + "localRoot": "${workspaceFolder}", // Maps C:\Users\user1\project1 + "remoteRoot": "/root/opentera/teraserver/python" // To current working directory ~/project1 + } + ] + } + ] +} diff --git a/teraserver/python/.vscode/settings.json b/teraserver/python/.vscode/settings.json new file mode 100644 index 00000000..3fdc29e4 --- /dev/null +++ b/teraserver/python/.vscode/settings.json @@ -0,0 +1,13 @@ + +{ + "python.testing.unittestArgs": [ + "-v", + "-s", + "${workspaceFolder}", + "-p", + "test_*.py" + ], + "python.testing.pytestEnabled": false, + "python.testing.unittestEnabled": true, + "python.envFile": "${workspaceFolder}/.env.vscode" +} diff --git a/teraserver/python/tests/__init__.py b/teraserver/python/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/__init__.py b/teraserver/python/tests/modules/FlaskModule/API/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/__init__.py b/teraserver/python/tests/modules/FlaskModule/API/device/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogin.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogin.py index 84f95952..609a6c04 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogin.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogout.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogout.py index ee2f24d1..bee36e8c 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogout.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceLogout.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryAssets.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryAssets.py index 429b303f..3513240b 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryAssets.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryAssets.py @@ -1,5 +1,5 @@ from typing import List -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraAsset import TeraAsset from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryDevices.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryDevices.py index 9363b2ba..3e3e24a4 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryDevices.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryDevices.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryParticipants.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryParticipants.py index ec71c871..6869a173 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryParticipants.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryParticipants.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessionEvents.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessionEvents.py index c2f7ade6..66e92859 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessionEvents.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessionEvents.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraSession import TeraSession from opentera.db.models.TeraSessionEvent import TeraSessionEvent diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessions.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessions.py index 358839fa..9f0de6e2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessions.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQuerySessions.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraSession import TeraSession from modules.DatabaseModule.DBManagerTeraDeviceAccess import DBManagerTeraDeviceAccess diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryStatus.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryStatus.py index 26b3053f..78fbdb3c 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryStatus.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceQueryStatus.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraSession import TeraSession from modules.DatabaseModule.DBManagerTeraDeviceAccess import DBManagerTeraDeviceAccess diff --git a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceRegister.py b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceRegister.py index c347e103..cab8355f 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceRegister.py +++ b/teraserver/python/tests/modules/FlaskModule/API/device/test_DeviceRegister.py @@ -1,4 +1,4 @@ -from BaseDeviceAPITest import BaseDeviceAPITest +from tests.modules.FlaskModule.API.device.BaseDeviceAPITest import BaseDeviceAPITest from opentera.db.models.TeraServerSettings import TeraServerSettings from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraDeviceType import TeraDeviceType diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/__init__.py b/teraserver/python/tests/modules/FlaskModule/API/participant/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogin.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogin.py index 76659601..d35aaeda 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogin.py @@ -1,4 +1,4 @@ -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraSessionType import TeraSessionType diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogout.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogout.py index cd1fcff4..633e1c57 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogout.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantLogout.py @@ -1,4 +1,4 @@ -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest from opentera.db.models.TeraParticipant import TeraParticipant diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryAssets.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryAssets.py index cdc4b762..83fce2da 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryAssets.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryAssets.py @@ -1,5 +1,5 @@ from typing import List -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest from opentera.db.models.TeraAsset import TeraAsset from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryDevices.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryDevices.py index 1f7aaaf2..0aee1f29 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryDevices.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryDevices.py @@ -1,4 +1,4 @@ -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest class ParticipantQueryDevicesTest(BaseParticipantAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryParticipants.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryParticipants.py index 7a33afd7..e1ade5e5 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryParticipants.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQueryParticipants.py @@ -1,4 +1,4 @@ -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest class ParticipantQueryParticipantsTest(BaseParticipantAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQuerySessions.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQuerySessions.py index b153ed80..2daf8a41 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQuerySessions.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantQuerySessions.py @@ -1,5 +1,5 @@ from datetime import datetime, timedelta -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest from modules.DatabaseModule.DBManagerTeraParticipantAccess import DBManagerTeraParticipantAccess from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantRefreshToken.py b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantRefreshToken.py index f9fefe4a..c8d64d7d 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantRefreshToken.py +++ b/teraserver/python/tests/modules/FlaskModule/API/participant/test_ParticipantRefreshToken.py @@ -1,4 +1,4 @@ -from BaseParticipantAPITest import BaseParticipantAPITest +from tests.modules.FlaskModule.API.participant.BaseParticipantAPITest import BaseParticipantAPITest class ParticipantRefreshTokenTest(BaseParticipantAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/__init__.py b/teraserver/python/tests/modules/FlaskModule/API/service/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAccess.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAccess.py index c2c368e3..f2d7b572 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAccess.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAccess.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraUser import TeraUser @@ -214,4 +214,3 @@ def test_get_endpoint_with_token_auth_all_params_admin_and_not_admin_for_device_ # TODO complete tests with devices access = DBManagerTeraDeviceAccess(device) - diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAssets.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAssets.py index 45367e33..de14650e 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAssets.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryAssets.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from datetime import datetime diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDevices.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDevices.py index 24ed98ad..76c1a01c 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDevices.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDevices.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraDeviceSubType import TeraDeviceSubType diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDisconnect.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDisconnect.py index e0511aef..ced6bd05 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDisconnect.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryDisconnect.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraParticipant import TeraParticipant diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipantGroups.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipantGroups.py index 4138754d..651954ab 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipantGroups.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipantGroups.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models import TeraParticipantGroup @@ -213,4 +213,4 @@ def test_post_and_delete_endpoint_with_token(self): # Test case: Delete with no problem response = self._delete_with_service_token_auth(self.test_client, token=self.service_token, params={'id': 3}) - self.assertEqual(200, response.status_code, msg="Delete OK") \ No newline at end of file + self.assertEqual(200, response.status_code, msg="Delete OK") diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipants.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipants.py index 25e1789c..2cb9473d 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipants.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryParticipants.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraProject import TeraProject diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryProjects.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryProjects.py index 235b1400..d6465374 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryProjects.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraProject import TeraProject from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraServiceProject import TeraServiceProject diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryRoles.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryRoles.py index 621d09b2..6be36e9a 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryRoles.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryRoles.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraServiceRole import TeraServiceRole diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServiceAccess.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServiceAccess.py index c0258391..b7d1c780 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServiceAccess.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServiceAccess.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraServiceAccess import TeraServiceAccess from opentera.db.models.TeraServiceRole import TeraServiceRole @@ -151,4 +151,4 @@ def _checkJson(self, json_data, minimal=False): self.assertFalse(json_data.__contains__('service_name')) self.assertFalse(json_data.__contains__('service_key')) self.assertFalse(json_data.__contains__('id_service')) - self.assertFalse(json_data.__contains__('service_access_role_name')) \ No newline at end of file + self.assertFalse(json_data.__contains__('service_access_role_name')) diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServices.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServices.py index dc799a0a..1fedd5d5 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServices.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryServices.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraService import TeraService diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionEvents.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionEvents.py index a9e5f533..5243f0ab 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionEvents.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionEvents.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionTypes.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionTypes.py index bf872532..79e5df99 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionTypes.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessionTypes.py @@ -1,5 +1,5 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraSessionType import TeraSessionType from opentera.db.models.TeraSessionTypeSite import TeraSessionTypeSite diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessions.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessions.py index 567d3675..51eb2d3b 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessions.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySessions.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from datetime import datetime, timedelta from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySiteProjectAccessRoles.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySiteProjectAccessRoles.py index 642b6dab..ef9ccb26 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySiteProjectAccessRoles.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySiteProjectAccessRoles.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraSite import TeraSite @@ -172,4 +172,4 @@ def test_get_endpoint_with_token_auth_and_project_user(self): role = user_access.get_user_project_role(user.id_user, project.id_project) self.assertEqual({'project_role': role['project_role']}, response.json) else: - self.assertEqual({'project_role': None}, response.json) \ No newline at end of file + self.assertEqual({'project_role': None}, response.json) diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySites.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySites.py index 36417561..d9430e66 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySites.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQuerySites.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypeProjects.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypeProjects.py index 3e9a2012..694b566c 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypeProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypeProjects.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraTestTypeProject import TeraTestTypeProject from opentera.db.models.TeraTest import TeraTest from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypes.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypes.py index e7d63169..8bd73db0 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypes.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTestTypes.py @@ -1,5 +1,5 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraTestType import TeraTestType from opentera.db.models.TeraTestTypeSite import TeraTestTypeSite from opentera.db.models.TeraTestTypeProject import TeraTestTypeProject @@ -214,4 +214,4 @@ def _checkJson(self, json_data, minimal=False): if not minimal: self.assertTrue(json_data.__contains__('test_type_description')) self.assertTrue(json_data.__contains__('test_type_service_key')) - self.assertTrue(json_data.__contains__('test_type_service_uuid')) \ No newline at end of file + self.assertTrue(json_data.__contains__('test_type_service_uuid')) diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTests.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTests.py index fdd4e141..c2bac766 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTests.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryTests.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from datetime import datetime diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUserGroups.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUserGroups.py index 2d81f19c..72776da5 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUserGroups.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUserGroups.py @@ -1,4 +1,4 @@ -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup from opentera.db.models.TeraServiceAccess import TeraServiceAccess diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUsers.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUsers.py index 3dd30742..cc19e9ed 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUsers.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceQueryUsers.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceSessionManager.py b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceSessionManager.py index c94bbd65..bb86dd0d 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceSessionManager.py +++ b/teraserver/python/tests/modules/FlaskModule/API/service/test_ServiceSessionManager.py @@ -1,6 +1,6 @@ from typing import List -from BaseServiceAPITest import BaseServiceAPITest +from tests.modules.FlaskModule.API.service.BaseServiceAPITest import BaseServiceAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraSessionType import TeraSessionType diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/__init__.py b/teraserver/python/tests/modules/FlaskModule/API/user/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/_test_UserQueryServiceAccessToken.py b/teraserver/python/tests/modules/FlaskModule/API/user/_test_UserQueryServiceAccessToken.py index fceeb713..ab97b91b 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/_test_UserQueryServiceAccessToken.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/_test_UserQueryServiceAccessToken.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraServiceRole import TeraServiceRole from opentera.db.models.TeraServiceProject import TeraServiceProject diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py index 32ed14a2..e0d92089 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index db8a62de..3f5b94e4 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser import pyotp @@ -171,5 +171,3 @@ def create_user_with_2fa_enabled(self, username='test', password='test') -> Tera user.enable_2fa_otp() TeraUser.insert(user) return user - - diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogout.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogout.py index 2d5c3738..a9c3f3aa 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogout.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogout.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryAssets.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryAssets.py index 8f16ce9b..4bf8cab2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryAssets.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryAssets.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraAsset import TeraAsset @@ -66,7 +66,7 @@ def test_query_tera_server_assets_no_access(self): params=params) self.assertEqual(200, response.status_code) self.assertEqual(len(response.json), 0) - + def test_query_device_assets_as_admin(self): with self._flask_app.app_context(): params = {'id_device': 1, 'with_urls': True} diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceParticipants.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceParticipants.py index cd8d0664..f5f172ae 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceParticipants.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceParticipants.py @@ -1,6 +1,6 @@ from typing import List -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from modules.FlaskModule.FlaskModule import flask_app from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraParticipant import TeraParticipant diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceProjects.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceProjects.py index 799fc18a..65b08115 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDeviceProjects.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraDeviceProject import TeraDeviceProject from opentera.db.models.TeraDeviceSite import TeraDeviceSite diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDisconnect.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDisconnect.py index 4318e492..daa29886 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDisconnect.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryDisconnect.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraParticipant import TeraParticipant diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryForms.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryForms.py index 871afe33..7e0fea2d 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryForms.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryForms.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraParticipantGroup import TeraParticipantGroup from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipantGroup.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipantGroup.py index 00a4df8c..a124790e 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipantGroup.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipantGroup.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraParticipantGroup import TeraParticipantGroup from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipants.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipants.py index d26c9e8a..73ca17a2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipants.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryParticipants.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraProject import TeraProject from opentera.db.models.TeraParticipantGroup import TeraParticipantGroup diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjectAccess.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjectAccess.py index aeaf95d8..45fdaee3 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjectAccess.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjectAccess.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserQueryProjectAccessTest(BaseUserAPITest): @@ -165,7 +165,7 @@ def test_query_specific_user_group_admins_with_sites(self): def test_query_specific_user_group_by_users(self): with self._flask_app.app_context(): - response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', + response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', params={'id_user_group': 1, 'by_users': True}) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) @@ -243,7 +243,7 @@ def test_query_specific_user_group_by_users_with_projects_admins(self): def test_query_specific_user_group_by_users_with_projects_admins_with_sites(self): with self._flask_app.app_context(): params = {'id_user_group': 4, 'by_users': True, 'with_empty': True, 'admins': True, 'with_sites': True} - response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', + response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) @@ -291,7 +291,7 @@ def test_query_specific_user_group_with_projects_admins_with_sites(self): def test_query_specific_project(self): with self._flask_app.app_context(): - response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', + response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', params={'id_project': 1}) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) @@ -471,7 +471,7 @@ def test_query_specific_project_by_users_with_user_groups_admins_with_sites(self with self._flask_app.app_context(): params = {'id_project': 2, 'by_users': True, 'with_empty': True, 'admins': True, 'with_sites': True} - response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', + response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjects.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjects.py index f896dc26..d94beab2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryProjects.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraProject import TeraProject from opentera.db.models.TeraServiceProject import TeraServiceProject from opentera.db.models.TeraUser import TeraUser diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServerSettings.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServerSettings.py index fb2fcb8b..37a8ecf7 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServerSettings.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServerSettings.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraServerSettings import TeraServerSettings diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceAccess.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceAccess.py index fac5fa53..fb88362e 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceAccess.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceAccess.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceAccess import TeraServiceAccess diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceConfigs.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceConfigs.py index 1a64e784..1f328860 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceConfigs.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceConfigs.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserQueryServiceConfigsTest(BaseUserAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceProjects.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceProjects.py index 97337d4d..02e8cf18 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceProjects.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraSessionTypeProject import TeraSessionTypeProject diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceRoles.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceRoles.py index 2cec8e42..a2a0faa8 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceRoles.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceRoles.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraServiceRole import TeraServiceRole diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceSites.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceSites.py index 289e5ef8..6d6d1b3a 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceSites.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServiceSites.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraServiceSite import TeraServiceSite diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServices.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServices.py index b3e56854..e1cc6896 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServices.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryServices.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraServiceSite import TeraServiceSite from opentera.db.models.TeraServiceProject import TeraServiceProject from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionEvents.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionEvents.py index 0bdbfcaa..9913ebb2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionEvents.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionEvents.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserQuerySessionEventsTest(BaseUserAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeProject.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeProject.py index 4e0a9e6a..7cc8bf29 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeProject.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeProject.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraSessionType import TeraSessionType from opentera.db.models.TeraSessionTypeProject import TeraSessionTypeProject from opentera.db.models.TeraSession import TeraSession diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeSites.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeSites.py index 08342b29..bb137957 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeSites.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySessionTypeSites.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraSessionType import TeraSessionType from opentera.db.models.TeraSessionTypeSite import TeraSessionTypeSite from opentera.db.models.TeraSessionTypeProject import TeraSessionTypeProject @@ -81,14 +81,14 @@ def test_query_site_as_admin(self): self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) self.assertEqual(len(response.json), 0) - + params = {'id_site': 2} response = self._get_with_user_http_auth(self.test_client, username='admin', password='admin', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) self.assertEqual(len(response.json), 1) - + for data_item in response.json: self._checkJson(json_data=data_item) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySiteAccess.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySiteAccess.py index 348d4535..5ca1cd86 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySiteAccess.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySiteAccess.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserQuerySiteAccessTest(BaseUserAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySites.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySites.py index 05b5fda3..ae369ac4 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySites.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQuerySites.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraProject import TeraProject from opentera.db.models.TeraParticipant import TeraParticipant diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeProjects.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeProjects.py index f7188888..fdb26d37 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeProjects.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeProjects.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraTestTypeProject import TeraTestTypeProject from opentera.db.models.TeraTest import TeraTest from opentera.db.models.TeraSession import TeraSession @@ -165,21 +165,21 @@ def test_query_list_as_admin(self): def test_query_project_as_user(self): with self._flask_app.app_context(): params = {'id_project': 10} - response = self._get_with_user_http_auth(self.test_client, username='user', password='user', + response = self._get_with_user_http_auth(self.test_client, username='user', password='user', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) self.assertEqual(0, len(response.json)) params = {'id_project': 1} - response = self._get_with_user_http_auth(self.test_client, username='user4', password='user4', + response = self._get_with_user_http_auth(self.test_client, username='user4', password='user4', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) self.assertEqual(0, len(response.json)) params = {'id_project': 1} - response = self._get_with_user_http_auth(self.test_client, username='user', password='user', + response = self._get_with_user_http_auth(self.test_client, username='user', password='user', params=params) self.assertEqual(200, response.status_code) self.assertTrue(response.is_json) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeSites.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeSites.py index f840c2df..61f003dd 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeSites.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryTestTypeSites.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraTestType import TeraTestType from opentera.db.models.TeraTestTypeSite import TeraTestTypeSite from opentera.db.models.TeraTestTypeProject import TeraTestTypeProject diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUserGroups.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUserGroups.py index fe9dc0a7..dcc64eb9 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUserGroups.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUserGroups.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUserGroup import TeraUserGroup from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceAccess import TeraServiceAccess diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryVersions.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryVersions.py index 0ba73a42..d6602b3cc 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryVersions.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryVersions.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.utils.TeraVersions import TeraVersions diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserRefreshToken.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserRefreshToken.py index a12427e9..a0558609 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserRefreshToken.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserRefreshToken.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserRefreshTokenTest(BaseUserAPITest): diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserSessionManager.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserSessionManager.py index 4d099a73..791e0367 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserSessionManager.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserSessionManager.py @@ -1,4 +1,4 @@ -from BaseUserAPITest import BaseUserAPITest +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest class UserSessionManagerTest(BaseUserAPITest): @@ -53,4 +53,4 @@ def test_delete_endpoint_invalid_http_auth(self): def test_delete_endpoint_invalid_token_auth(self): with self._flask_app.app_context(): response = self._delete_with_user_token_auth(self.test_client, token='invalid') - self.assertEqual(405, response.status_code) \ No newline at end of file + self.assertEqual(405, response.status_code) diff --git a/teraserver/python/tests/modules/FlaskModule/__init__.py b/teraserver/python/tests/modules/FlaskModule/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/modules/__init__.py b/teraserver/python/tests/modules/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/opentera/__init__.py b/teraserver/python/tests/opentera/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/opentera/db/__init__.py b/teraserver/python/tests/opentera/db/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/opentera/db/models/__init__.py b/teraserver/python/tests/opentera/db/models/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/opentera/db/models/test_TeraAsset.py b/teraserver/python/tests/opentera/db/models/test_TeraAsset.py index 4928760e..ffac8deb 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraAsset.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraAsset.py @@ -299,22 +299,22 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create new participant - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=1) id_participant = participant.id_participant # Create new device - from test_TeraDevice import TeraDeviceTest + from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest device = TeraDeviceTest.new_test_device() id_device = device.id_device # Create new user - from test_TeraUser import TeraUserTest + from tests.opentera.db.models.test_TeraUser import TeraUserTest user = TeraUserTest.new_test_user(user_name="asset_user") id_user = user.id_user # Create new session - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(participants=[participant], users=[user], devices=[device]) id_session = ses.id_session @@ -409,4 +409,3 @@ def new_test_asset(id_session: int, service_uuid: str, id_device: int | None = N asset.asset_type = 'application/test' TeraAsset.insert(asset) return asset - diff --git a/teraserver/python/tests/opentera/db/models/test_TeraDevice.py b/teraserver/python/tests/opentera/db/models/test_TeraDevice.py index 901ea1fb..1957ea48 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraDevice.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraDevice.py @@ -115,23 +115,23 @@ def test_hard_delete(self): id_device = device.id_device # Assign device to site - from test_TeraDeviceSite import TeraDeviceSiteTest + from tests.opentera.db.models.test_TeraDeviceSite import TeraDeviceSiteTest device_site = TeraDeviceSiteTest.new_test_device_site(id_device=id_device, id_site=1) id_device_site = device_site.id_device_site # Assign device to project - from test_TeraDeviceProject import TeraDeviceProjectTest + from tests.opentera.db.models.test_TeraDeviceProject import TeraDeviceProjectTest device_project = TeraDeviceProjectTest.new_test_device_project(id_device=id_device, id_project=1) id_device_project = device_project.id_device_project # Assign device to participants - from test_TeraDeviceParticipant import TeraDeviceParticipantTest + from tests.opentera.db.models.test_TeraDeviceParticipant import TeraDeviceParticipantTest device_participant = TeraDeviceParticipantTest.new_test_device_participant(id_device=id_device, id_participant=1) id_device_participant = device_participant.id_device_participant # Assign device to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest device_session = TeraSessionTest.new_test_session(id_creator_device=id_device) id_session = device_session.id_session @@ -139,19 +139,19 @@ def test_hard_delete(self): id_session_invitee = device_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_device=id_device) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_device=id_device) id_test = test.id_test # Create service config for device - from test_TeraServiceConfig import TeraServiceConfigTest + from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest device_service_config = TeraServiceConfigTest.new_test_service_config(id_device=id_device, id_service=2) id_service_config = device_service_config.id_service_config @@ -203,23 +203,23 @@ def test_undelete(self): id_device = device.id_device # Assign device to site - from test_TeraDeviceSite import TeraDeviceSiteTest + from tests.opentera.db.models.test_TeraDeviceSite import TeraDeviceSiteTest device_site = TeraDeviceSiteTest.new_test_device_site(id_device=id_device, id_site=1) id_device_site = device_site.id_device_site # Assign device to project - from test_TeraDeviceProject import TeraDeviceProjectTest + from tests.opentera.db.models.test_TeraDeviceProject import TeraDeviceProjectTest device_project = TeraDeviceProjectTest.new_test_device_project(id_device=id_device, id_project=1) id_device_project = device_project.id_device_project # Assign device to participants - from test_TeraDeviceParticipant import TeraDeviceParticipantTest + from tests.opentera.db.models.test_TeraDeviceParticipant import TeraDeviceParticipantTest device_participant = TeraDeviceParticipantTest.new_test_device_participant(id_device=id_device, id_participant=1) id_device_participant = device_participant.id_device_participant # Assign device to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest device_session = TeraSessionTest.new_test_session(id_creator_device=id_device) id_session = device_session.id_session @@ -227,19 +227,19 @@ def test_undelete(self): id_session_invitee = device_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_device=id_device) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_device=id_device) id_test = test.id_test # Create service config for device - from test_TeraServiceConfig import TeraServiceConfigTest + from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest device_service_config = TeraServiceConfigTest.new_test_service_config(id_device=id_device, id_service=2) id_service_config = device_service_config.id_service_config diff --git a/teraserver/python/tests/opentera/db/models/test_TeraParticipant.py b/teraserver/python/tests/opentera/db/models/test_TeraParticipant.py index 20f03b58..3496cf72 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraParticipant.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraParticipant.py @@ -68,7 +68,7 @@ def test_hard_delete(self): id_participant = participant.id_participant # Assign participant to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest part_session = TeraSessionTest.new_test_session(id_creator_participant=id_participant) id_session = part_session.id_session @@ -76,14 +76,14 @@ def test_hard_delete(self): id_session_invitee = part_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_participant=id_participant) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_participant=id_participant) id_test = test.id_test @@ -117,7 +117,7 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create a new project - from test_TeraProject import TeraProjectTest + from tests.opentera.db.models.test_TeraProject import TeraProjectTest project = TeraProjectTest.new_test_project() id_project = project.id_project @@ -127,7 +127,7 @@ def test_undelete(self): id_participant = participant.id_participant # Assign participant to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest part_session = TeraSessionTest.new_test_session(id_creator_participant=id_participant) id_session = part_session.id_session @@ -135,19 +135,19 @@ def test_undelete(self): id_session_invitee = part_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_participant=id_participant) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_participant=id_participant) id_test = test.id_test # ... and service config - from test_TeraServiceConfig import TeraServiceConfigTest + from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest device_service_config = TeraServiceConfigTest.new_test_service_config(id_participant=id_participant, id_service=2) id_service_config = device_service_config.id_service_config @@ -177,7 +177,7 @@ def test_undelete(self): TeraParticipant.undelete(id_participant) # Create and associate participant group - from test_TeraParticipantGroup import TeraParticipantGroupTest + from tests.opentera.db.models.test_TeraParticipantGroup import TeraParticipantGroupTest group = TeraParticipantGroupTest.new_test_group(id_project) id_group = group.id_participant_group diff --git a/teraserver/python/tests/opentera/db/models/test_TeraParticipantGroup.py b/teraserver/python/tests/opentera/db/models/test_TeraParticipantGroup.py index b99384f8..e9ba557f 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraParticipantGroup.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraParticipantGroup.py @@ -40,7 +40,7 @@ def test_hard_delete(self): id_participant_group = group.id_participant_group # Create a new participant in that group - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=1, id_participant_group=id_participant_group) self.assertIsNotNone(participant.id_participant) @@ -66,7 +66,7 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create a new project - from test_TeraProject import TeraProjectTest + from tests.opentera.db.models.test_TeraProject import TeraProjectTest project = TeraProjectTest.new_test_project(id_site=1) id_project = project.id_project @@ -76,7 +76,7 @@ def test_undelete(self): id_participant_group = group.id_participant_group # Create a new participant in that group - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=id_project, id_participant_group=id_participant_group) self.assertIsNotNone(participant.id_participant) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraProject.py b/teraserver/python/tests/opentera/db/models/test_TeraProject.py index 2ad0e968..bca7f206 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraProject.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraProject.py @@ -106,7 +106,7 @@ def test_update_set_inactive(self): # Create participants participants = [] - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest for i in range(3): part = TeraParticipantTest.new_test_participant(id_project=new_project.id_project, enabled=True) participants.append(part) @@ -116,7 +116,7 @@ def test_update_set_inactive(self): # Associate devices devices = [] - from test_TeraDevice import TeraDeviceTest + from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest for i in range(2): device = TeraDeviceTest.new_test_device() devices.append(device) @@ -163,7 +163,7 @@ def test_hard_delete(self): id_project = project.id_project # Create a new participant in that project - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=id_project) self.assertIsNotNone(participant.id_participant) id_participant = participant.id_participant @@ -188,7 +188,7 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create site - from test_TeraSite import TeraSiteTest + from tests.opentera.db.models.test_TeraSite import TeraSiteTest site = TeraSiteTest.new_test_site() id_site = site.id_site @@ -198,7 +198,7 @@ def test_undelete(self): id_project = project.id_project # Create a new participant in that project - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=id_project) self.assertIsNotNone(participant.id_participant) id_participant = participant.id_participant @@ -243,12 +243,12 @@ def test_project_relationships_deletion_and_access(self): id_project = project.id_project # Create participant groups - from test_TeraParticipantGroup import TeraParticipantGroupTest + from tests.opentera.db.models.test_TeraParticipantGroup import TeraParticipantGroupTest group = TeraParticipantGroupTest.new_test_group(id_project=id_project) self.assertIsNotNone(group.id_participant_group) id_participant_group1 = group.id_participant_group - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=1, id_participant_group=id_participant_group1) self.assertIsNotNone(participant.id_participant) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraService.py b/teraserver/python/tests/opentera/db/models/test_TeraService.py index f47d4e6d..a518a66e 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraService.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraService.py @@ -400,7 +400,7 @@ def test_hard_delete(self): id_service = service.id_service # Create a new site association for that service - from test_TeraServiceSite import TeraServiceSiteTest + from tests.opentera.db.models.test_TeraServiceSite import TeraServiceSiteTest site_service = TeraServiceSiteTest.new_test_service_site(id_site=1, id_service=id_service) self.assertIsNotNone(site_service.id_service_site) id_site_service = site_service.id_service_site @@ -430,7 +430,7 @@ def test_undelete(self): id_service = service.id_service # Create service roles - from test_TeraServiceRole import TeraServiceRoleTest + from tests.opentera.db.models.test_TeraServiceRole import TeraServiceRoleTest role = TeraServiceRoleTest.new_test_service_role(id_service=id_service, role_name='admin') id_role_admin = role.id_service_role @@ -438,12 +438,12 @@ def test_undelete(self): id_role_user = role.id_service_role # Create service sites association - from test_TeraServiceSite import TeraServiceSiteTest + from tests.opentera.db.models.test_TeraServiceSite import TeraServiceSiteTest service_site = TeraServiceSiteTest.new_test_service_site(id_site=1, id_service=id_service) id_service_site = service_site.id_service_site # Create service projects association - from test_TeraServiceProject import TeraServiceProjectTest + from tests.opentera.db.models.test_TeraServiceProject import TeraServiceProjectTest service_project = TeraServiceProjectTest.new_test_service_project(id_service=id_service, id_project=1) id_service_project = service_project.id_service_project diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSession.py b/teraserver/python/tests/opentera/db/models/test_TeraSession.py index c57aaa7c..eb38d898 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSession.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSession.py @@ -67,7 +67,7 @@ def test_soft_delete(self): id_session = ses.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_device=1) @@ -115,14 +115,14 @@ def test_hard_delete(self): id_session = ses.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_device=1) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_participant=1) id_test = test.id_test @@ -149,19 +149,19 @@ def test_undelete(self): id_session = ses.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_device=1) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_participant=1) id_test = test.id_test # ... and event - from test_TeraSessionEvent import TeraSessionEventTest + from tests.opentera.db.models.test_TeraSessionEvent import TeraSessionEventTest event = TeraSessionEventTest.new_test_session_event(id_session=id_session, id_event_type=1) id_event = event.id_session_event diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSessionType.py b/teraserver/python/tests/opentera/db/models/test_TeraSessionType.py index 0caf7854..1ef3d73c 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSessionType.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSessionType.py @@ -38,7 +38,7 @@ def test_hard_delete(self): id_session_type = ses_type.id_session_type # Create a new session of that session type - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(id_session_type=id_session_type, id_creator_service=1) id_session = ses.id_session @@ -62,7 +62,7 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create new service - from test_TeraService import TeraServiceTest + from tests.opentera.db.models.test_TeraService import TeraServiceTest service = TeraServiceTest.new_test_service('SessionTypeService') id_service = service.id_service @@ -71,17 +71,17 @@ def test_undelete(self): id_session_type = ses_type.id_session_type # Create a new session of that session type - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(id_session_type=id_session_type, id_creator_service=1) id_session = ses.id_session # Associate session type to site - from test_TeraSessionTypeSite import TeraSessionTypeSiteTest + from tests.opentera.db.models.test_TeraSessionTypeSite import TeraSessionTypeSiteTest ses_site = TeraSessionTypeSiteTest.new_test_session_type_site(id_site=1, id_session_type=id_session_type) id_session_type_site = ses_site.id_session_type_site # ... and project - from test_TeraSessionTypeProject import TeraSessionTypeProjectTest + from tests.opentera.db.models.test_TeraSessionTypeProject import TeraSessionTypeProjectTest ses_proj = TeraSessionTypeProjectTest.new_test_session_type_project(id_project=1, id_session_type=id_session_type) id_session_type_project = ses_proj.id_session_type_project diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index 91fd2763..b6786f11 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -118,17 +118,17 @@ def test_hard_delete(self): site = TeraSiteTest.new_test_site() id_site = site.id_site - from test_TeraProject import TeraProjectTest + from tests.opentera.db.models.test_TeraProject import TeraProjectTest project = TeraProjectTest.new_test_project(id_site=id_site) self.assertIsNotNone(project.id_project) id_project = project.id_project - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=id_project) self.assertIsNotNone(participant.id_participant) id_participant = participant.id_participant - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(id_session_type=1, id_creator_participant=1, participants=[participant]) id_session = ses.id_session @@ -165,31 +165,31 @@ def test_undelete(self): id_site = site.id_site # Associate device - from test_TeraDevice import TeraDeviceTest + from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest device = TeraDeviceTest.new_test_device() id_device = device.id_device - from test_TeraDeviceSite import TeraDeviceSiteTest + from tests.opentera.db.models.test_TeraDeviceSite import TeraDeviceSiteTest device = TeraDeviceSiteTest.new_test_device_site(id_device=id_device, id_site=id_site) id_device_site = device.id_device_site # ... and service - from test_TeraServiceSite import TeraServiceSiteTest + from tests.opentera.db.models.test_TeraServiceSite import TeraServiceSiteTest service_site = TeraServiceSiteTest.new_test_service_site(id_site=id_site, id_service=3) id_service_site = service_site.id_service_site # ... and roles - from test_TeraServiceRole import TeraServiceRoleTest + from tests.opentera.db.models.test_TeraServiceRole import TeraServiceRoleTest role = TeraServiceRoleTest.new_test_service_role(id_service=3, id_site=id_site, role_name='Test') id_role = role.id_service_role # ... and session type - from test_TeraSessionTypeSite import TeraSessionTypeSiteTest + from tests.opentera.db.models.test_TeraSessionTypeSite import TeraSessionTypeSiteTest ses_type = TeraSessionTypeSiteTest.new_test_session_type_site(id_site=id_site, id_session_type=1) id_session_type = ses_type.id_session_type_site # ... and test type - from test_TeraTestTypeSite import TeraTestTypeSiteTest + from tests.opentera.db.models.test_TeraTestTypeSite import TeraTestTypeSiteTest test_type = TeraTestTypeSiteTest.new_test_test_type_site(id_site=id_site, id_test_type=1) id_test_type = test_type.id_test_type_site @@ -221,5 +221,3 @@ def new_test_site(name: str = 'Test Site') -> TeraSite: site.site_name = name TeraSite.insert(site) return site - - diff --git a/teraserver/python/tests/opentera/db/models/test_TeraTest.py b/teraserver/python/tests/opentera/db/models/test_TeraTest.py index 97c3ce65..aa3b87c5 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraTest.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraTest.py @@ -46,22 +46,22 @@ def test_hard_delete(self): def test_undelete(self): with self._flask_app.app_context(): # Create new participant - from test_TeraParticipant import TeraParticipantTest + from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=1) id_participant = participant.id_participant # Create new device - from test_TeraDevice import TeraDeviceTest + from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest device = TeraDeviceTest.new_test_device() id_device = device.id_device # Create new user - from test_TeraUser import TeraUserTest + from tests.opentera.db.models.test_TeraUser import TeraUserTest user = TeraUserTest.new_test_user(user_name='test_testuser') id_user = user.id_user # Create new session - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(participants=[participant], users=[user], devices=[device]) id_session = ses.id_session diff --git a/teraserver/python/tests/opentera/db/models/test_TeraTestType.py b/teraserver/python/tests/opentera/db/models/test_TeraTestType.py index 32fedb4f..345dcd3d 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraTestType.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraTestType.py @@ -35,7 +35,7 @@ def test_hard_delete(self): test_type = TeraTestTypeTest.new_test_test_type() id_test_type = test_type.id_test_type - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=1, id_participant=1, id_test_type=id_test_type) self.assertIsNotNone(test.id_test) id_test = test.id_test @@ -63,18 +63,18 @@ def test_undelete(self): test_type = TeraTestTypeTest.new_test_test_type() id_test_type = test_type.id_test_type - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=1, id_participant=1, id_test_type=id_test_type) self.assertIsNotNone(test.id_test) id_test = test.id_test # Associate with site - from test_TeraTestTypeSite import TeraTestTypeSiteTest + from tests.opentera.db.models.test_TeraTestTypeSite import TeraTestTypeSiteTest tt_site = TeraTestTypeSiteTest.new_test_test_type_site(id_site=1, id_test_type=id_test_type) id_test_type_site = tt_site.id_test_type_site # ... and project - from test_TeraTestTypeProject import TeraTestTypeProjectTest + from tests.opentera.db.models.test_TeraTestTypeProject import TeraTestTypeProjectTest tt_project = TeraTestTypeProjectTest.new_test_test_type_project(id_project=1, id_test_type=id_test_type) id_test_type_project = tt_project.id_test_type_project diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUser.py b/teraserver/python/tests/opentera/db/models/test_TeraUser.py index 4e4e134e..7083a767 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUser.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUser.py @@ -98,7 +98,7 @@ def test_hard_delete(self): id_user = user.id_user # Assign user to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest user_session = TeraSessionTest.new_test_session(id_creator_user=id_user) id_session = user_session.id_session @@ -106,14 +106,14 @@ def test_hard_delete(self): id_session_invitee = user_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_user=id_user) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_user=id_user) id_test = test.id_test @@ -152,12 +152,12 @@ def test_undelete(self): id_user = user.id_user # Assign to user group - from test_TeraUserUserGroup import TeraUserUserGroupTest + from tests.opentera.db.models.test_TeraUserUserGroup import TeraUserUserGroupTest uug = TeraUserUserGroupTest.new_test_user_usergroup(id_user=id_user, id_user_group=1) id_user_user_group = uug.id_user_user_group # Assign user to sessions - from test_TeraSession import TeraSessionTest + from tests.opentera.db.models.test_TeraSession import TeraSessionTest user_session = TeraSessionTest.new_test_session(id_creator_user=id_user) id_session = user_session.id_session @@ -165,19 +165,19 @@ def test_undelete(self): id_session_invitee = user_session.id_session # Attach asset - from test_TeraAsset import TeraAssetTest + from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_user=id_user) id_asset = asset.id_asset # ... and test - from test_TeraTest import TeraTestTest + from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_user=id_user) id_test = test.id_test # ... and service config - from test_TeraServiceConfig import TeraServiceConfigTest + from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest service_conf = TeraServiceConfigTest.new_test_service_config(id_service=1, id_user=id_user) id_service_conf = service_conf.id_service_config diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUserGroup.py b/teraserver/python/tests/opentera/db/models/test_TeraUserGroup.py index 761a2691..98decfe9 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUserGroup.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUserGroup.py @@ -253,7 +253,7 @@ def test_hard_delete(self): self.assertIsNotNone(ug.id_user_group) id_user_group = ug.id_user_group - from test_TeraUser import TeraUserTest + from tests.opentera.db.models.test_TeraUser import TeraUserTest user = TeraUserTest.new_test_user(user_name="user_ug_harddelete", user_groups=[ug]) self.assertIsNotNone(user.id_user) id_user = user.id_user @@ -286,13 +286,13 @@ def test_undelete(self): self.assertIsNotNone(ug.id_user_group) id_user_group = ug.id_user_group - from test_TeraUser import TeraUserTest + from tests.opentera.db.models.test_TeraUser import TeraUserTest user = TeraUserTest.new_test_user(user_name="user_ug_undelete", user_groups=[ug]) self.assertIsNotNone(user.id_user) id_user = user.id_user # Add service access - from test_TeraServiceAccess import TeraServiceAccessTest + from tests.opentera.db.models.test_TeraServiceAccess import TeraServiceAccessTest access = TeraServiceAccessTest.new_test_service_access(id_service_role=1, id_user_group=id_user_group) id_access = access.id_service_access diff --git a/teraserver/python/tests/services/FileTransferService/BaseFileTransferServiceAPITest.py b/teraserver/python/tests/services/FileTransferService/BaseFileTransferServiceAPITest.py index 0eca382f..b43854c4 100644 --- a/teraserver/python/tests/services/FileTransferService/BaseFileTransferServiceAPITest.py +++ b/teraserver/python/tests/services/FileTransferService/BaseFileTransferServiceAPITest.py @@ -8,7 +8,7 @@ import uuid import random from string import digits, ascii_lowercase, ascii_uppercase -from FakeFileTransferService import FakeFileTransferService +from tests.services.FileTransferService.FakeFileTransferService import FakeFileTransferService from opentera.services.ServiceAccessManager import ServiceAccessManager from opentera.db.models.TeraService import TeraService from modules.LoginModule.LoginModule import LoginModule diff --git a/teraserver/python/tests/services/FileTransferService/__init__.py b/teraserver/python/tests/services/FileTransferService/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/services/LoggingService/BaseLoggingServiceAPITest.py b/teraserver/python/tests/services/LoggingService/BaseLoggingServiceAPITest.py index 151e548f..3cb6a5d1 100644 --- a/teraserver/python/tests/services/LoggingService/BaseLoggingServiceAPITest.py +++ b/teraserver/python/tests/services/LoggingService/BaseLoggingServiceAPITest.py @@ -6,7 +6,7 @@ import uuid import random from string import digits, ascii_lowercase, ascii_uppercase -from FakeLoggingService import FakeLoggingService +from tests.services.LoggingService.FakeLoggingService import FakeLoggingService from opentera.services.ServiceAccessManager import ServiceAccessManager from requests.auth import _basic_auth_str diff --git a/teraserver/python/tests/services/LoggingService/__init__.py b/teraserver/python/tests/services/LoggingService/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/teraserver/python/tests/services/LoggingService/test_QueryLogEntries.py b/teraserver/python/tests/services/LoggingService/test_QueryLogEntries.py index 246965a0..be4eca81 100644 --- a/teraserver/python/tests/services/LoggingService/test_QueryLogEntries.py +++ b/teraserver/python/tests/services/LoggingService/test_QueryLogEntries.py @@ -1,4 +1,4 @@ -from BaseLoggingServiceAPITest import BaseLoggingServiceAPITest +from tests.services.LoggingService.BaseLoggingServiceAPITest import BaseLoggingServiceAPITest from services.LoggingService.libloggingservice.db.models.LogEntry import LogEntry from datetime import datetime, timedelta @@ -28,7 +28,7 @@ def test_get_endpoint_with_valid_token_and_admin(self): token = self._generate_fake_user_token(name='FakeUser', superadmin=True, expiration=3600) all_entries = [] - for i in range(50): + for _ in range(50): current_time = datetime.now() entry = self._create_log_entry(current_time, 1, 'test', 'test_message') self.assertIsNotNone(entry) @@ -52,14 +52,14 @@ def test_get_endpoint_with_valid_token_and_admin_with_start_end_dates(self): current_time = datetime.now() + timedelta(hours=1) tomorrow = current_time + timedelta(days=1) - for i in range(50): + for _ in range(50): entry = self._create_log_entry(current_time, 1, 'test', 'test_message') self.assertIsNotNone(entry) LogEntry.insert(entry) self.assertIsNotNone(entry.id_log_entry) all_entries.append(entry) - for i in range(50): + for _ in range(50): entry = self._create_log_entry(tomorrow, 1, 'test', 'test_message') self.assertIsNotNone(entry) LogEntry.insert(entry) @@ -91,7 +91,7 @@ def test_get_endpoint_with_valid_token_and_admin_with_offset(self): all_entries = [] current_time = datetime.now() - for i in range(50): + for _ in range(50): entry = self._create_log_entry(current_time, 1, 'test', 'test_message') self.assertIsNotNone(entry) LogEntry.insert(entry) diff --git a/teraserver/python/tests/services/LoggingService/test_QueryLoginEntries.py b/teraserver/python/tests/services/LoggingService/test_QueryLoginEntries.py index 4cb7bcf5..1e2e2ef6 100644 --- a/teraserver/python/tests/services/LoggingService/test_QueryLoginEntries.py +++ b/teraserver/python/tests/services/LoggingService/test_QueryLoginEntries.py @@ -1,4 +1,4 @@ -from BaseLoggingServiceAPITest import BaseLoggingServiceAPITest +from tests.services.LoggingService.BaseLoggingServiceAPITest import BaseLoggingServiceAPITest from services.LoggingService.libloggingservice.db.models.LoginEntry import LoginEntry from opentera.services.ServiceAccessManager import ServiceAccessManager from opentera.services.TeraUserClient import TeraUserClient diff --git a/teraserver/python/tests/services/__init__.py b/teraserver/python/tests/services/__init__.py new file mode 100644 index 00000000..e69de29b From 21e2d8df548451f9a19affc3bb6230773300a3e9 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Wed, 25 Sep 2024 14:54:12 -0400 Subject: [PATCH 17/67] Refs #253. Updated login view visuals. --- .../modules/FlaskModule/Views/LoginView.py | 4 +- .../static/bootstrap/css/bootstrap.min.css | 9 ++-- .../bootstrap/css/bootstrap.min.css.map | 2 +- .../static/bootstrap/js/bootstrap.min.js | 6 +-- .../static/bootstrap/js/bootstrap.min.js.map | 2 +- teraserver/python/static/css/login_style.css | 22 +++++++++ teraserver/python/static/css/main_style.css | 1 + .../python/static/img/logos/LogoOpenTera.png | Bin 0 -> 78401 bytes teraserver/python/templates/about.html | 31 ++++++------ teraserver/python/templates/disabled_doc.html | 44 +----------------- teraserver/python/templates/login.html | 35 +++++++++----- 11 files changed, 76 insertions(+), 80 deletions(-) create mode 100644 teraserver/python/static/css/login_style.css create mode 100644 teraserver/python/static/img/logos/LogoOpenTera.png diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 7967ec28..b7ba6a5f 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -23,11 +23,13 @@ def get(self): if 'X_EXTERNALPORT' in request.headers: port = request.headers['X_EXTERNALPORT'] + show_logo = 'no_logo' not in request.args + versions = TeraVersions() versions.load_from_db() return render_template('login.html', hostname=hostname, port=port, - server_version=versions.version_string) + server_version=versions.version_string, show_logo=show_logo) def post(self): # Verify the form diff --git a/teraserver/python/static/bootstrap/css/bootstrap.min.css b/teraserver/python/static/bootstrap/css/bootstrap.min.css index 83a71b1f..39934146 100644 --- a/teraserver/python/static/bootstrap/css/bootstrap.min.css +++ b/teraserver/python/static/bootstrap/css/bootstrap.min.css @@ -1,7 +1,6 @@ -/*! - * Bootstrap v4.6.2 (https://getbootstrap.com/) - * Copyright 2011-2022 The Bootstrap Authors - * Copyright 2011-2022 Twitter, Inc. +@charset "UTF-8";/*! + * Bootstrap v5.3.3 (https://getbootstrap.com/) + * Copyright 2011-2024 The Bootstrap Authors * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) - */:root{--blue:#007bff;--indigo:#6610f2;--purple:#6f42c1;--pink:#e83e8c;--red:#dc3545;--orange:#fd7e14;--yellow:#ffc107;--green:#28a745;--teal:#20c997;--cyan:#17a2b8;--white:#fff;--gray:#6c757d;--gray-dark:#343a40;--primary:#007bff;--secondary:#6c757d;--success:#28a745;--info:#17a2b8;--warning:#ffc107;--danger:#dc3545;--light:#f8f9fa;--dark:#343a40;--breakpoint-xs:0;--breakpoint-sm:576px;--breakpoint-md:768px;--breakpoint-lg:992px;--breakpoint-xl:1200px;--font-family-sans-serif:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--font-family-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace}*,::after,::before{box-sizing:border-box}html{font-family:sans-serif;line-height:1.15;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}article,aside,figcaption,figure,footer,header,hgroup,main,nav,section{display:block}body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:1rem;font-weight:400;line-height:1.5;color:#212529;text-align:left;background-color:#fff}[tabindex="-1"]:focus:not(:focus-visible){outline:0!important}hr{box-sizing:content-box;height:0;overflow:visible}h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem}p{margin-top:0;margin-bottom:1rem}abbr[data-original-title],abbr[title]{text-decoration:underline;-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;border-bottom:0;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}small{font-size:80%}sub,sup{position:relative;font-size:75%;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:#007bff;text-decoration:none;background-color:transparent}a:hover{color:#0056b3;text-decoration:underline}a:not([href]):not([class]){color:inherit;text-decoration:none}a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;font-size:1em}pre{margin-top:0;margin-bottom:1rem;overflow:auto;-ms-overflow-style:scrollbar}figure{margin:0 0 1rem}img{vertical-align:middle;border-style:none}svg{overflow:hidden;vertical-align:middle}table{border-collapse:collapse}caption{padding-top:.75rem;padding-bottom:.75rem;color:#6c757d;text-align:left;caption-side:bottom}th{text-align:inherit;text-align:-webkit-match-parent}label{display:inline-block;margin-bottom:.5rem}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,input{overflow:visible}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{padding:0;border-style:none}input[type=checkbox],input[type=radio]{box-sizing:border-box;padding:0}textarea{overflow:auto;resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;max-width:100%;padding:0;margin-bottom:.5rem;font-size:1.5rem;line-height:inherit;color:inherit;white-space:normal}progress{vertical-align:baseline}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{outline-offset:-2px;-webkit-appearance:none}[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}output{display:inline-block}summary{display:list-item;cursor:pointer}template{display:none}[hidden]{display:none!important}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-bottom:.5rem;font-weight:500;line-height:1.2}.h1,h1{font-size:2.5rem}.h2,h2{font-size:2rem}.h3,h3{font-size:1.75rem}.h4,h4{font-size:1.5rem}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:6rem;font-weight:300;line-height:1.2}.display-2{font-size:5.5rem;font-weight:300;line-height:1.2}.display-3{font-size:4.5rem;font-weight:300;line-height:1.2}.display-4{font-size:3.5rem;font-weight:300;line-height:1.2}hr{margin-top:1rem;margin-bottom:1rem;border:0;border-top:1px solid rgba(0,0,0,.1)}.small,small{font-size:.875em;font-weight:400}.mark,mark{padding:.2em;background-color:#fcf8e3}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:90%;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote-footer{display:block;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"\2014\00A0"}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #dee2e6;border-radius:.25rem;max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#6c757d}code{font-size:87.5%;color:#e83e8c;word-wrap:break-word}a>code{color:inherit}kbd{padding:.2rem .4rem;font-size:87.5%;color:#fff;background-color:#212529;border-radius:.2rem}kbd kbd{padding:0;font-size:100%;font-weight:700}pre{display:block;font-size:87.5%;color:#212529}pre code{font-size:inherit;color:inherit;word-break:normal}.pre-scrollable{max-height:340px;overflow-y:scroll}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl{width:100%;padding-right:15px;padding-left:15px;margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}.row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-15px;margin-left:-15px}.no-gutters{margin-right:0;margin-left:0}.no-gutters>.col,.no-gutters>[class*=col-]{padding-right:0;padding-left:0}.col,.col-1,.col-10,.col-11,.col-12,.col-2,.col-3,.col-4,.col-5,.col-6,.col-7,.col-8,.col-9,.col-auto,.col-lg,.col-lg-1,.col-lg-10,.col-lg-11,.col-lg-12,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-auto,.col-md,.col-md-1,.col-md-10,.col-md-11,.col-md-12,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-auto,.col-sm,.col-sm-1,.col-sm-10,.col-sm-11,.col-sm-12,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-auto,.col-xl,.col-xl-1,.col-xl-10,.col-xl-11,.col-xl-12,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-auto{position:relative;width:100%;padding-right:15px;padding-left:15px}.col{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-first{-ms-flex-order:-1;order:-1}.order-last{-ms-flex-order:13;order:13}.order-0{-ms-flex-order:0;order:0}.order-1{-ms-flex-order:1;order:1}.order-2{-ms-flex-order:2;order:2}.order-3{-ms-flex-order:3;order:3}.order-4{-ms-flex-order:4;order:4}.order-5{-ms-flex-order:5;order:5}.order-6{-ms-flex-order:6;order:6}.order-7{-ms-flex-order:7;order:7}.order-8{-ms-flex-order:8;order:8}.order-9{-ms-flex-order:9;order:9}.order-10{-ms-flex-order:10;order:10}.order-11{-ms-flex-order:11;order:11}.order-12{-ms-flex-order:12;order:12}.offset-1{margin-left:8.333333%}.offset-2{margin-left:16.666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.333333%}.offset-5{margin-left:41.666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.333333%}.offset-8{margin-left:66.666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.333333%}.offset-11{margin-left:91.666667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-sm-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-sm-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-sm-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-sm-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-sm-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-sm-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-sm-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-sm-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-sm-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-sm-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-sm-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-sm-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-sm-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-sm-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-sm-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-sm-first{-ms-flex-order:-1;order:-1}.order-sm-last{-ms-flex-order:13;order:13}.order-sm-0{-ms-flex-order:0;order:0}.order-sm-1{-ms-flex-order:1;order:1}.order-sm-2{-ms-flex-order:2;order:2}.order-sm-3{-ms-flex-order:3;order:3}.order-sm-4{-ms-flex-order:4;order:4}.order-sm-5{-ms-flex-order:5;order:5}.order-sm-6{-ms-flex-order:6;order:6}.order-sm-7{-ms-flex-order:7;order:7}.order-sm-8{-ms-flex-order:8;order:8}.order-sm-9{-ms-flex-order:9;order:9}.order-sm-10{-ms-flex-order:10;order:10}.order-sm-11{-ms-flex-order:11;order:11}.order-sm-12{-ms-flex-order:12;order:12}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.333333%}.offset-sm-2{margin-left:16.666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.333333%}.offset-sm-5{margin-left:41.666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.333333%}.offset-sm-8{margin-left:66.666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.333333%}.offset-sm-11{margin-left:91.666667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-md-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-md-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-md-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-md-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-md-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-md-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-md-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-md-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-md-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-md-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-md-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-md-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-md-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-md-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-md-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-md-first{-ms-flex-order:-1;order:-1}.order-md-last{-ms-flex-order:13;order:13}.order-md-0{-ms-flex-order:0;order:0}.order-md-1{-ms-flex-order:1;order:1}.order-md-2{-ms-flex-order:2;order:2}.order-md-3{-ms-flex-order:3;order:3}.order-md-4{-ms-flex-order:4;order:4}.order-md-5{-ms-flex-order:5;order:5}.order-md-6{-ms-flex-order:6;order:6}.order-md-7{-ms-flex-order:7;order:7}.order-md-8{-ms-flex-order:8;order:8}.order-md-9{-ms-flex-order:9;order:9}.order-md-10{-ms-flex-order:10;order:10}.order-md-11{-ms-flex-order:11;order:11}.order-md-12{-ms-flex-order:12;order:12}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.333333%}.offset-md-2{margin-left:16.666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.333333%}.offset-md-5{margin-left:41.666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.333333%}.offset-md-8{margin-left:66.666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.333333%}.offset-md-11{margin-left:91.666667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-lg-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-lg-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-lg-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-lg-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-lg-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-lg-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-lg-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-lg-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-lg-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-lg-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-lg-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-lg-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-lg-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-lg-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-lg-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-lg-first{-ms-flex-order:-1;order:-1}.order-lg-last{-ms-flex-order:13;order:13}.order-lg-0{-ms-flex-order:0;order:0}.order-lg-1{-ms-flex-order:1;order:1}.order-lg-2{-ms-flex-order:2;order:2}.order-lg-3{-ms-flex-order:3;order:3}.order-lg-4{-ms-flex-order:4;order:4}.order-lg-5{-ms-flex-order:5;order:5}.order-lg-6{-ms-flex-order:6;order:6}.order-lg-7{-ms-flex-order:7;order:7}.order-lg-8{-ms-flex-order:8;order:8}.order-lg-9{-ms-flex-order:9;order:9}.order-lg-10{-ms-flex-order:10;order:10}.order-lg-11{-ms-flex-order:11;order:11}.order-lg-12{-ms-flex-order:12;order:12}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.333333%}.offset-lg-2{margin-left:16.666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.333333%}.offset-lg-5{margin-left:41.666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.333333%}.offset-lg-8{margin-left:66.666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.333333%}.offset-lg-11{margin-left:91.666667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;max-width:100%}.row-cols-xl-1>*{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.row-cols-xl-2>*{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.row-cols-xl-3>*{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.row-cols-xl-4>*{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.row-cols-xl-5>*{-ms-flex:0 0 20%;flex:0 0 20%;max-width:20%}.row-cols-xl-6>*{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-auto{-ms-flex:0 0 auto;flex:0 0 auto;width:auto;max-width:100%}.col-xl-1{-ms-flex:0 0 8.333333%;flex:0 0 8.333333%;max-width:8.333333%}.col-xl-2{-ms-flex:0 0 16.666667%;flex:0 0 16.666667%;max-width:16.666667%}.col-xl-3{-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-ms-flex:0 0 33.333333%;flex:0 0 33.333333%;max-width:33.333333%}.col-xl-5{-ms-flex:0 0 41.666667%;flex:0 0 41.666667%;max-width:41.666667%}.col-xl-6{-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-ms-flex:0 0 58.333333%;flex:0 0 58.333333%;max-width:58.333333%}.col-xl-8{-ms-flex:0 0 66.666667%;flex:0 0 66.666667%;max-width:66.666667%}.col-xl-9{-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-ms-flex:0 0 83.333333%;flex:0 0 83.333333%;max-width:83.333333%}.col-xl-11{-ms-flex:0 0 91.666667%;flex:0 0 91.666667%;max-width:91.666667%}.col-xl-12{-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.order-xl-first{-ms-flex-order:-1;order:-1}.order-xl-last{-ms-flex-order:13;order:13}.order-xl-0{-ms-flex-order:0;order:0}.order-xl-1{-ms-flex-order:1;order:1}.order-xl-2{-ms-flex-order:2;order:2}.order-xl-3{-ms-flex-order:3;order:3}.order-xl-4{-ms-flex-order:4;order:4}.order-xl-5{-ms-flex-order:5;order:5}.order-xl-6{-ms-flex-order:6;order:6}.order-xl-7{-ms-flex-order:7;order:7}.order-xl-8{-ms-flex-order:8;order:8}.order-xl-9{-ms-flex-order:9;order:9}.order-xl-10{-ms-flex-order:10;order:10}.order-xl-11{-ms-flex-order:11;order:11}.order-xl-12{-ms-flex-order:12;order:12}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.333333%}.offset-xl-2{margin-left:16.666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.333333%}.offset-xl-5{margin-left:41.666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.333333%}.offset-xl-8{margin-left:66.666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.333333%}.offset-xl-11{margin-left:91.666667%}}.table{width:100%;margin-bottom:1rem;color:#212529}.table td,.table th{padding:.75rem;vertical-align:top;border-top:1px solid #dee2e6}.table thead th{vertical-align:bottom;border-bottom:2px solid #dee2e6}.table tbody+tbody{border-top:2px solid #dee2e6}.table-sm td,.table-sm th{padding:.3rem}.table-bordered{border:1px solid #dee2e6}.table-bordered td,.table-bordered th{border:1px solid #dee2e6}.table-bordered thead td,.table-bordered thead th{border-bottom-width:2px}.table-borderless tbody+tbody,.table-borderless td,.table-borderless th,.table-borderless thead th{border:0}.table-striped tbody tr:nth-of-type(odd){background-color:rgba(0,0,0,.05)}.table-hover tbody tr:hover{color:#212529;background-color:rgba(0,0,0,.075)}.table-primary,.table-primary>td,.table-primary>th{background-color:#b8daff}.table-primary tbody+tbody,.table-primary td,.table-primary th,.table-primary thead th{border-color:#7abaff}.table-hover .table-primary:hover{background-color:#9fcdff}.table-hover .table-primary:hover>td,.table-hover .table-primary:hover>th{background-color:#9fcdff}.table-secondary,.table-secondary>td,.table-secondary>th{background-color:#d6d8db}.table-secondary tbody+tbody,.table-secondary td,.table-secondary th,.table-secondary thead th{border-color:#b3b7bb}.table-hover .table-secondary:hover{background-color:#c8cbcf}.table-hover .table-secondary:hover>td,.table-hover .table-secondary:hover>th{background-color:#c8cbcf}.table-success,.table-success>td,.table-success>th{background-color:#c3e6cb}.table-success tbody+tbody,.table-success td,.table-success th,.table-success thead th{border-color:#8fd19e}.table-hover .table-success:hover{background-color:#b1dfbb}.table-hover .table-success:hover>td,.table-hover .table-success:hover>th{background-color:#b1dfbb}.table-info,.table-info>td,.table-info>th{background-color:#bee5eb}.table-info tbody+tbody,.table-info td,.table-info th,.table-info thead th{border-color:#86cfda}.table-hover .table-info:hover{background-color:#abdde5}.table-hover .table-info:hover>td,.table-hover .table-info:hover>th{background-color:#abdde5}.table-warning,.table-warning>td,.table-warning>th{background-color:#ffeeba}.table-warning tbody+tbody,.table-warning td,.table-warning th,.table-warning thead th{border-color:#ffdf7e}.table-hover .table-warning:hover{background-color:#ffe8a1}.table-hover .table-warning:hover>td,.table-hover .table-warning:hover>th{background-color:#ffe8a1}.table-danger,.table-danger>td,.table-danger>th{background-color:#f5c6cb}.table-danger tbody+tbody,.table-danger td,.table-danger th,.table-danger thead th{border-color:#ed969e}.table-hover .table-danger:hover{background-color:#f1b0b7}.table-hover .table-danger:hover>td,.table-hover .table-danger:hover>th{background-color:#f1b0b7}.table-light,.table-light>td,.table-light>th{background-color:#fdfdfe}.table-light tbody+tbody,.table-light td,.table-light th,.table-light thead th{border-color:#fbfcfc}.table-hover .table-light:hover{background-color:#ececf6}.table-hover .table-light:hover>td,.table-hover .table-light:hover>th{background-color:#ececf6}.table-dark,.table-dark>td,.table-dark>th{background-color:#c6c8ca}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#95999c}.table-hover .table-dark:hover{background-color:#b9bbbe}.table-hover .table-dark:hover>td,.table-hover .table-dark:hover>th{background-color:#b9bbbe}.table-active,.table-active>td,.table-active>th{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover{background-color:rgba(0,0,0,.075)}.table-hover .table-active:hover>td,.table-hover .table-active:hover>th{background-color:rgba(0,0,0,.075)}.table .thead-dark th{color:#fff;background-color:#343a40;border-color:#454d55}.table .thead-light th{color:#495057;background-color:#e9ecef;border-color:#dee2e6}.table-dark{color:#fff;background-color:#343a40}.table-dark td,.table-dark th,.table-dark thead th{border-color:#454d55}.table-dark.table-bordered{border:0}.table-dark.table-striped tbody tr:nth-of-type(odd){background-color:rgba(255,255,255,.05)}.table-dark.table-hover tbody tr:hover{color:#fff;background-color:rgba(255,255,255,.075)}@media (max-width:575.98px){.table-responsive-sm{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-sm>.table-bordered{border:0}}@media (max-width:767.98px){.table-responsive-md{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-md>.table-bordered{border:0}}@media (max-width:991.98px){.table-responsive-lg{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-lg>.table-bordered{border:0}}@media (max-width:1199.98px){.table-responsive-xl{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive-xl>.table-bordered{border:0}}.table-responsive{display:block;width:100%;overflow-x:auto;-webkit-overflow-scrolling:touch}.table-responsive>.table-bordered{border:0}.form-control{display:block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;background-clip:padding-box;border:1px solid #ced4da;border-radius:.25rem;transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control::-ms-expand{background-color:transparent;border:0}.form-control:focus{color:#495057;background-color:#fff;border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.form-control::-webkit-input-placeholder{color:#6c757d;opacity:1}.form-control::-moz-placeholder{color:#6c757d;opacity:1}.form-control:-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::-ms-input-placeholder{color:#6c757d;opacity:1}.form-control::placeholder{color:#6c757d;opacity:1}.form-control:disabled,.form-control[readonly]{background-color:#e9ecef;opacity:1}input[type=date].form-control,input[type=datetime-local].form-control,input[type=month].form-control,input[type=time].form-control{-webkit-appearance:none;-moz-appearance:none;appearance:none}select.form-control:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}select.form-control:focus::-ms-value{color:#495057;background-color:#fff}.form-control-file,.form-control-range{display:block;width:100%}.col-form-label{padding-top:calc(.375rem + 1px);padding-bottom:calc(.375rem + 1px);margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + 1px);padding-bottom:calc(.5rem + 1px);font-size:1.25rem;line-height:1.5}.col-form-label-sm{padding-top:calc(.25rem + 1px);padding-bottom:calc(.25rem + 1px);font-size:.875rem;line-height:1.5}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;font-size:1rem;line-height:1.5;color:#212529;background-color:transparent;border:solid transparent;border-width:1px 0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{height:calc(1.5em + .5rem + 2px);padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.form-control-lg{height:calc(1.5em + 1rem + 2px);padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}select.form-control[multiple],select.form-control[size]{height:auto}textarea.form-control{height:auto}.form-group{margin-bottom:1rem}.form-text{display:block;margin-top:.25rem}.form-row{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-5px;margin-left:-5px}.form-row>.col,.form-row>[class*=col-]{padding-right:5px;padding-left:5px}.form-check{position:relative;display:block;padding-left:1.25rem}.form-check-input{position:absolute;margin-top:.3rem;margin-left:-1.25rem}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{color:#6c757d}.form-check-label{margin-bottom:0}.form-check-inline{display:-ms-inline-flexbox;display:inline-flex;-ms-flex-align:center;align-items:center;padding-left:0;margin-right:.75rem}.form-check-inline .form-check-input{position:static;margin-top:0;margin-right:.3125rem;margin-left:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#28a745}.valid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(40,167,69,.9);border-radius:.25rem}.form-row>.col>.valid-tooltip,.form-row>[class*=col-]>.valid-tooltip{left:5px}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:#28a745;padding-right:calc(1.5em + .75rem)!important;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.was-validated select.form-control:valid,select.form-control.is-valid{padding-right:3rem!important;background-position:right 1.5rem center}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-valid,.was-validated .custom-select:valid{border-color:#28a745;padding-right:calc(.75em + 2.3125rem)!important;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%2328a745' d='M2.3 6.73L.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat}.custom-select.is-valid:focus,.was-validated .custom-select:valid:focus{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:#28a745}.form-check-input.is-valid~.valid-feedback,.form-check-input.is-valid~.valid-tooltip,.was-validated .form-check-input:valid~.valid-feedback,.was-validated .form-check-input:valid~.valid-tooltip{display:block}.custom-control-input.is-valid~.custom-control-label,.was-validated .custom-control-input:valid~.custom-control-label{color:#28a745}.custom-control-input.is-valid~.custom-control-label::before,.was-validated .custom-control-input:valid~.custom-control-label::before{border-color:#28a745}.custom-control-input.is-valid:checked~.custom-control-label::before,.was-validated .custom-control-input:valid:checked~.custom-control-label::before{border-color:#34ce57;background-color:#34ce57}.custom-control-input.is-valid:focus~.custom-control-label::before,.was-validated .custom-control-input:valid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.custom-control-input.is-valid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:valid:focus:not(:checked)~.custom-control-label::before{border-color:#28a745}.custom-file-input.is-valid~.custom-file-label,.was-validated .custom-file-input:valid~.custom-file-label{border-color:#28a745}.custom-file-input.is-valid:focus~.custom-file-label,.was-validated .custom-file-input:valid:focus~.custom-file-label{border-color:#28a745;box-shadow:0 0 0 .2rem rgba(40,167,69,.25)}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:#dc3545}.invalid-tooltip{position:absolute;top:100%;left:0;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;line-height:1.5;color:#fff;background-color:rgba(220,53,69,.9);border-radius:.25rem}.form-row>.col>.invalid-tooltip,.form-row>[class*=col-]>.invalid-tooltip{left:5px}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:#dc3545;padding-right:calc(1.5em + .75rem)!important;background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.was-validated select.form-control:invalid,select.form-control.is-invalid{padding-right:3rem!important;background-position:right 1.5rem center}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.custom-select.is-invalid,.was-validated .custom-select:invalid{border-color:#dc3545;padding-right:calc(.75em + 2.3125rem)!important;background:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat,#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' fill='none' stroke='%23dc3545' viewBox='0 0 12 12'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e") center right 1.75rem/calc(.75em + .375rem) calc(.75em + .375rem) no-repeat}.custom-select.is-invalid:focus,.was-validated .custom-select:invalid:focus{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:#dc3545}.form-check-input.is-invalid~.invalid-feedback,.form-check-input.is-invalid~.invalid-tooltip,.was-validated .form-check-input:invalid~.invalid-feedback,.was-validated .form-check-input:invalid~.invalid-tooltip{display:block}.custom-control-input.is-invalid~.custom-control-label,.was-validated .custom-control-input:invalid~.custom-control-label{color:#dc3545}.custom-control-input.is-invalid~.custom-control-label::before,.was-validated .custom-control-input:invalid~.custom-control-label::before{border-color:#dc3545}.custom-control-input.is-invalid:checked~.custom-control-label::before,.was-validated .custom-control-input:invalid:checked~.custom-control-label::before{border-color:#e4606d;background-color:#e4606d}.custom-control-input.is-invalid:focus~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.custom-control-input.is-invalid:focus:not(:checked)~.custom-control-label::before,.was-validated .custom-control-input:invalid:focus:not(:checked)~.custom-control-label::before{border-color:#dc3545}.custom-file-input.is-invalid~.custom-file-label,.was-validated .custom-file-input:invalid~.custom-file-label{border-color:#dc3545}.custom-file-input.is-invalid:focus~.custom-file-label,.was-validated .custom-file-input:invalid:focus~.custom-file-label{border-color:#dc3545;box-shadow:0 0 0 .2rem rgba(220,53,69,.25)}.form-inline{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center}.form-inline .form-check{width:100%}@media (min-width:576px){.form-inline label{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;margin-bottom:0}.form-inline .form-group{display:-ms-flexbox;display:flex;-ms-flex:0 0 auto;flex:0 0 auto;-ms-flex-flow:row wrap;flex-flow:row wrap;-ms-flex-align:center;align-items:center;margin-bottom:0}.form-inline .form-control{display:inline-block;width:auto;vertical-align:middle}.form-inline .form-control-plaintext{display:inline-block}.form-inline .custom-select,.form-inline .input-group{width:auto}.form-inline .form-check{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:auto;padding-left:0}.form-inline .form-check-input{position:relative;-ms-flex-negative:0;flex-shrink:0;margin-top:0;margin-right:.25rem;margin-left:0}.form-inline .custom-control{-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center}.form-inline .custom-control-label{margin-bottom:0}}.btn{display:inline-block;font-weight:400;color:#212529;text-align:center;vertical-align:middle;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none;background-color:transparent;border:1px solid transparent;padding:.375rem .75rem;font-size:1rem;line-height:1.5;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:#212529;text-decoration:none}.btn.focus,.btn:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.btn.disabled,.btn:disabled{opacity:.65}.btn:not(:disabled):not(.disabled){cursor:pointer}a.btn.disabled,fieldset:disabled a.btn{pointer-events:none}.btn-primary{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:hover{color:#fff;background-color:#0069d9;border-color:#0062cc}.btn-primary.focus,.btn-primary:focus{color:#fff;background-color:#0069d9;border-color:#0062cc;box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-primary.disabled,.btn-primary:disabled{color:#fff;background-color:#007bff;border-color:#007bff}.btn-primary:not(:disabled):not(.disabled).active,.btn-primary:not(:disabled):not(.disabled):active,.show>.btn-primary.dropdown-toggle{color:#fff;background-color:#0062cc;border-color:#005cbf}.btn-primary:not(:disabled):not(.disabled).active:focus,.btn-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(38,143,255,.5)}.btn-secondary{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:hover{color:#fff;background-color:#5a6268;border-color:#545b62}.btn-secondary.focus,.btn-secondary:focus{color:#fff;background-color:#5a6268;border-color:#545b62;box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-secondary.disabled,.btn-secondary:disabled{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-secondary:not(:disabled):not(.disabled).active,.btn-secondary:not(:disabled):not(.disabled):active,.show>.btn-secondary.dropdown-toggle{color:#fff;background-color:#545b62;border-color:#4e555b}.btn-secondary:not(:disabled):not(.disabled).active:focus,.btn-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(130,138,145,.5)}.btn-success{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:hover{color:#fff;background-color:#218838;border-color:#1e7e34}.btn-success.focus,.btn-success:focus{color:#fff;background-color:#218838;border-color:#1e7e34;box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-success.disabled,.btn-success:disabled{color:#fff;background-color:#28a745;border-color:#28a745}.btn-success:not(:disabled):not(.disabled).active,.btn-success:not(:disabled):not(.disabled):active,.show>.btn-success.dropdown-toggle{color:#fff;background-color:#1e7e34;border-color:#1c7430}.btn-success:not(:disabled):not(.disabled).active:focus,.btn-success:not(:disabled):not(.disabled):active:focus,.show>.btn-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(72,180,97,.5)}.btn-info{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:hover{color:#fff;background-color:#138496;border-color:#117a8b}.btn-info.focus,.btn-info:focus{color:#fff;background-color:#138496;border-color:#117a8b;box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-info.disabled,.btn-info:disabled{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-info:not(:disabled):not(.disabled).active,.btn-info:not(:disabled):not(.disabled):active,.show>.btn-info.dropdown-toggle{color:#fff;background-color:#117a8b;border-color:#10707f}.btn-info:not(:disabled):not(.disabled).active:focus,.btn-info:not(:disabled):not(.disabled):active:focus,.show>.btn-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(58,176,195,.5)}.btn-warning{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:hover{color:#212529;background-color:#e0a800;border-color:#d39e00}.btn-warning.focus,.btn-warning:focus{color:#212529;background-color:#e0a800;border-color:#d39e00;box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-warning.disabled,.btn-warning:disabled{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-warning:not(:disabled):not(.disabled).active,.btn-warning:not(:disabled):not(.disabled):active,.show>.btn-warning.dropdown-toggle{color:#212529;background-color:#d39e00;border-color:#c69500}.btn-warning:not(:disabled):not(.disabled).active:focus,.btn-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(222,170,12,.5)}.btn-danger{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:hover{color:#fff;background-color:#c82333;border-color:#bd2130}.btn-danger.focus,.btn-danger:focus{color:#fff;background-color:#c82333;border-color:#bd2130;box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-danger.disabled,.btn-danger:disabled{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-danger:not(:disabled):not(.disabled).active,.btn-danger:not(:disabled):not(.disabled):active,.show>.btn-danger.dropdown-toggle{color:#fff;background-color:#bd2130;border-color:#b21f2d}.btn-danger:not(:disabled):not(.disabled).active:focus,.btn-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(225,83,97,.5)}.btn-light{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:hover{color:#212529;background-color:#e2e6ea;border-color:#dae0e5}.btn-light.focus,.btn-light:focus{color:#212529;background-color:#e2e6ea;border-color:#dae0e5;box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-light.disabled,.btn-light:disabled{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-light:not(:disabled):not(.disabled).active,.btn-light:not(:disabled):not(.disabled):active,.show>.btn-light.dropdown-toggle{color:#212529;background-color:#dae0e5;border-color:#d3d9df}.btn-light:not(:disabled):not(.disabled).active:focus,.btn-light:not(:disabled):not(.disabled):active:focus,.show>.btn-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(216,217,219,.5)}.btn-dark{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:hover{color:#fff;background-color:#23272b;border-color:#1d2124}.btn-dark.focus,.btn-dark:focus{color:#fff;background-color:#23272b;border-color:#1d2124;box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-dark.disabled,.btn-dark:disabled{color:#fff;background-color:#343a40;border-color:#343a40}.btn-dark:not(:disabled):not(.disabled).active,.btn-dark:not(:disabled):not(.disabled):active,.show>.btn-dark.dropdown-toggle{color:#fff;background-color:#1d2124;border-color:#171a1d}.btn-dark:not(:disabled):not(.disabled).active:focus,.btn-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(82,88,93,.5)}.btn-outline-primary{color:#007bff;border-color:#007bff}.btn-outline-primary:hover{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary.focus,.btn-outline-primary:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-primary.disabled,.btn-outline-primary:disabled{color:#007bff;background-color:transparent}.btn-outline-primary:not(:disabled):not(.disabled).active,.btn-outline-primary:not(:disabled):not(.disabled):active,.show>.btn-outline-primary.dropdown-toggle{color:#fff;background-color:#007bff;border-color:#007bff}.btn-outline-primary:not(:disabled):not(.disabled).active:focus,.btn-outline-primary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-primary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.btn-outline-secondary{color:#6c757d;border-color:#6c757d}.btn-outline-secondary:hover{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary.focus,.btn-outline-secondary:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-secondary.disabled,.btn-outline-secondary:disabled{color:#6c757d;background-color:transparent}.btn-outline-secondary:not(:disabled):not(.disabled).active,.btn-outline-secondary:not(:disabled):not(.disabled):active,.show>.btn-outline-secondary.dropdown-toggle{color:#fff;background-color:#6c757d;border-color:#6c757d}.btn-outline-secondary:not(:disabled):not(.disabled).active:focus,.btn-outline-secondary:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-secondary.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.btn-outline-success{color:#28a745;border-color:#28a745}.btn-outline-success:hover{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success.focus,.btn-outline-success:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-success.disabled,.btn-outline-success:disabled{color:#28a745;background-color:transparent}.btn-outline-success:not(:disabled):not(.disabled).active,.btn-outline-success:not(:disabled):not(.disabled):active,.show>.btn-outline-success.dropdown-toggle{color:#fff;background-color:#28a745;border-color:#28a745}.btn-outline-success:not(:disabled):not(.disabled).active:focus,.btn-outline-success:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-success.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.btn-outline-info{color:#17a2b8;border-color:#17a2b8}.btn-outline-info:hover{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info.focus,.btn-outline-info:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-info.disabled,.btn-outline-info:disabled{color:#17a2b8;background-color:transparent}.btn-outline-info:not(:disabled):not(.disabled).active,.btn-outline-info:not(:disabled):not(.disabled):active,.show>.btn-outline-info.dropdown-toggle{color:#fff;background-color:#17a2b8;border-color:#17a2b8}.btn-outline-info:not(:disabled):not(.disabled).active:focus,.btn-outline-info:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-info.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.btn-outline-warning{color:#ffc107;border-color:#ffc107}.btn-outline-warning:hover{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning.focus,.btn-outline-warning:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-warning.disabled,.btn-outline-warning:disabled{color:#ffc107;background-color:transparent}.btn-outline-warning:not(:disabled):not(.disabled).active,.btn-outline-warning:not(:disabled):not(.disabled):active,.show>.btn-outline-warning.dropdown-toggle{color:#212529;background-color:#ffc107;border-color:#ffc107}.btn-outline-warning:not(:disabled):not(.disabled).active:focus,.btn-outline-warning:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-warning.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.btn-outline-danger{color:#dc3545;border-color:#dc3545}.btn-outline-danger:hover{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger.focus,.btn-outline-danger:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-danger.disabled,.btn-outline-danger:disabled{color:#dc3545;background-color:transparent}.btn-outline-danger:not(:disabled):not(.disabled).active,.btn-outline-danger:not(:disabled):not(.disabled):active,.show>.btn-outline-danger.dropdown-toggle{color:#fff;background-color:#dc3545;border-color:#dc3545}.btn-outline-danger:not(:disabled):not(.disabled).active:focus,.btn-outline-danger:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-danger.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.btn-outline-light{color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:hover{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light.focus,.btn-outline-light:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-light.disabled,.btn-outline-light:disabled{color:#f8f9fa;background-color:transparent}.btn-outline-light:not(:disabled):not(.disabled).active,.btn-outline-light:not(:disabled):not(.disabled):active,.show>.btn-outline-light.dropdown-toggle{color:#212529;background-color:#f8f9fa;border-color:#f8f9fa}.btn-outline-light:not(:disabled):not(.disabled).active:focus,.btn-outline-light:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-light.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.btn-outline-dark{color:#343a40;border-color:#343a40}.btn-outline-dark:hover{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark.focus,.btn-outline-dark:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-outline-dark.disabled,.btn-outline-dark:disabled{color:#343a40;background-color:transparent}.btn-outline-dark:not(:disabled):not(.disabled).active,.btn-outline-dark:not(:disabled):not(.disabled):active,.show>.btn-outline-dark.dropdown-toggle{color:#fff;background-color:#343a40;border-color:#343a40}.btn-outline-dark:not(:disabled):not(.disabled).active:focus,.btn-outline-dark:not(:disabled):not(.disabled):active:focus,.show>.btn-outline-dark.dropdown-toggle:focus{box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.btn-link{font-weight:400;color:#007bff;text-decoration:none}.btn-link:hover{color:#0056b3;text-decoration:underline}.btn-link.focus,.btn-link:focus{text-decoration:underline}.btn-link.disabled,.btn-link:disabled{color:#6c757d;pointer-events:none}.btn-group-lg>.btn,.btn-lg{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.btn-group-sm>.btn,.btn-sm{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.btn-block{display:block;width:100%}.btn-block+.btn-block{margin-top:.5rem}input[type=button].btn-block,input[type=reset].btn-block,input[type=submit].btn-block{width:100%}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{position:relative;height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.width{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.width{transition:none}}.dropdown,.dropleft,.dropright,.dropup{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{position:absolute;top:100%;left:0;z-index:1000;display:none;float:left;min-width:10rem;padding:.5rem 0;margin:.125rem 0 0;font-size:1rem;color:#212529;text-align:left;list-style:none;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.15);border-radius:.25rem}.dropdown-menu-left{right:auto;left:0}.dropdown-menu-right{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-left{right:auto;left:0}.dropdown-menu-sm-right{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-left{right:auto;left:0}.dropdown-menu-md-right{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-left{right:auto;left:0}.dropdown-menu-lg-right{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-left{right:auto;left:0}.dropdown-menu-xl-right{right:0;left:auto}}.dropup .dropdown-menu{top:auto;bottom:100%;margin-top:0;margin-bottom:.125rem}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-menu{top:0;right:auto;left:100%;margin-top:0;margin-left:.125rem}.dropright .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropright .dropdown-toggle:empty::after{margin-left:0}.dropright .dropdown-toggle::after{vertical-align:0}.dropleft .dropdown-menu{top:0;right:100%;left:auto;margin-top:0;margin-right:.125rem}.dropleft .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropleft .dropdown-toggle::after{display:none}.dropleft .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropleft .dropdown-toggle:empty::after{margin-left:0}.dropleft .dropdown-toggle::before{vertical-align:0}.dropdown-menu[x-placement^=bottom],.dropdown-menu[x-placement^=left],.dropdown-menu[x-placement^=right],.dropdown-menu[x-placement^=top]{right:auto;bottom:auto}.dropdown-divider{height:0;margin:.5rem 0;overflow:hidden;border-top:1px solid #e9ecef}.dropdown-item{display:block;width:100%;padding:.25rem 1.5rem;clear:both;font-weight:400;color:#212529;text-align:inherit;white-space:nowrap;background-color:transparent;border:0}.dropdown-item:focus,.dropdown-item:hover{color:#16181b;text-decoration:none;background-color:#e9ecef}.dropdown-item.active,.dropdown-item:active{color:#fff;text-decoration:none;background-color:#007bff}.dropdown-item.disabled,.dropdown-item:disabled{color:#adb5bd;pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:.5rem 1.5rem;margin-bottom:0;font-size:.875rem;color:#6c757d;white-space:nowrap}.dropdown-item-text{display:block;padding:.25rem 1.5rem;color:#212529}.btn-group,.btn-group-vertical{position:relative;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;-ms-flex:1 1 auto;flex:1 1 auto}.btn-group-vertical>.btn:hover,.btn-group>.btn:hover{z-index:1}.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus{z-index:1}.btn-toolbar{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-pack:start;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group>.btn-group:not(:first-child),.btn-group>.btn:not(:first-child){margin-left:-1px}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropright .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropleft .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{-ms-flex-direction:column;flex-direction:column;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:center;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:-1px}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn:not(:first-child){border-top-left-radius:0;border-top-right-radius:0}.btn-group-toggle>.btn,.btn-group-toggle>.btn-group>.btn{margin-bottom:0}.btn-group-toggle>.btn input[type=checkbox],.btn-group-toggle>.btn input[type=radio],.btn-group-toggle>.btn-group>.btn input[type=checkbox],.btn-group-toggle>.btn-group>.btn input[type=radio]{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.input-group{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:stretch;align-items:stretch;width:100%}.input-group>.custom-file,.input-group>.custom-select,.input-group>.form-control,.input-group>.form-control-plaintext{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;width:1%;min-width:0;margin-bottom:0}.input-group>.custom-file+.custom-file,.input-group>.custom-file+.custom-select,.input-group>.custom-file+.form-control,.input-group>.custom-select+.custom-file,.input-group>.custom-select+.custom-select,.input-group>.custom-select+.form-control,.input-group>.form-control+.custom-file,.input-group>.form-control+.custom-select,.input-group>.form-control+.form-control,.input-group>.form-control-plaintext+.custom-file,.input-group>.form-control-plaintext+.custom-select,.input-group>.form-control-plaintext+.form-control{margin-left:-1px}.input-group>.custom-file .custom-file-input:focus~.custom-file-label,.input-group>.custom-select:focus,.input-group>.form-control:focus{z-index:3}.input-group>.custom-file .custom-file-input:focus{z-index:4}.input-group>.custom-select:not(:first-child),.input-group>.form-control:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.custom-file{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center}.input-group>.custom-file:not(:last-child) .custom-file-label,.input-group>.custom-file:not(:last-child) .custom-file-label::after{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.custom-file:not(:first-child) .custom-file-label{border-top-left-radius:0;border-bottom-left-radius:0}.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label,.input-group:not(.has-validation)>.custom-file:not(:last-child) .custom-file-label::after,.input-group:not(.has-validation)>.custom-select:not(:last-child),.input-group:not(.has-validation)>.form-control:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label,.input-group.has-validation>.custom-file:nth-last-child(n+3) .custom-file-label::after,.input-group.has-validation>.custom-select:nth-last-child(n+3),.input-group.has-validation>.form-control:nth-last-child(n+3){border-top-right-radius:0;border-bottom-right-radius:0}.input-group-append,.input-group-prepend{display:-ms-flexbox;display:flex}.input-group-append .btn,.input-group-prepend .btn{position:relative;z-index:2}.input-group-append .btn:focus,.input-group-prepend .btn:focus{z-index:3}.input-group-append .btn+.btn,.input-group-append .btn+.input-group-text,.input-group-append .input-group-text+.btn,.input-group-append .input-group-text+.input-group-text,.input-group-prepend .btn+.btn,.input-group-prepend .btn+.input-group-text,.input-group-prepend .input-group-text+.btn,.input-group-prepend .input-group-text+.input-group-text{margin-left:-1px}.input-group-prepend{margin-right:-1px}.input-group-append{margin-left:-1px}.input-group-text{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.375rem .75rem;margin-bottom:0;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;text-align:center;white-space:nowrap;background-color:#e9ecef;border:1px solid #ced4da;border-radius:.25rem}.input-group-text input[type=checkbox],.input-group-text input[type=radio]{margin-top:0}.input-group-lg>.custom-select,.input-group-lg>.form-control:not(textarea){height:calc(1.5em + 1rem + 2px)}.input-group-lg>.custom-select,.input-group-lg>.form-control,.input-group-lg>.input-group-append>.btn,.input-group-lg>.input-group-append>.input-group-text,.input-group-lg>.input-group-prepend>.btn,.input-group-lg>.input-group-prepend>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;line-height:1.5;border-radius:.3rem}.input-group-sm>.custom-select,.input-group-sm>.form-control:not(textarea){height:calc(1.5em + .5rem + 2px)}.input-group-sm>.custom-select,.input-group-sm>.form-control,.input-group-sm>.input-group-append>.btn,.input-group-sm>.input-group-append>.input-group-text,.input-group-sm>.input-group-prepend>.btn,.input-group-sm>.input-group-prepend>.input-group-text{padding:.25rem .5rem;font-size:.875rem;line-height:1.5;border-radius:.2rem}.input-group-lg>.custom-select,.input-group-sm>.custom-select{padding-right:1.75rem}.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.btn,.input-group.has-validation>.input-group-append:nth-last-child(n+3)>.input-group-text,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.btn,.input-group:not(.has-validation)>.input-group-append:not(:last-child)>.input-group-text,.input-group>.input-group-append:last-child>.btn:not(:last-child):not(.dropdown-toggle),.input-group>.input-group-append:last-child>.input-group-text:not(:last-child),.input-group>.input-group-prepend>.btn,.input-group>.input-group-prepend>.input-group-text{border-top-right-radius:0;border-bottom-right-radius:0}.input-group>.input-group-append>.btn,.input-group>.input-group-append>.input-group-text,.input-group>.input-group-prepend:first-child>.btn:not(:first-child),.input-group>.input-group-prepend:first-child>.input-group-text:not(:first-child),.input-group>.input-group-prepend:not(:first-child)>.btn,.input-group>.input-group-prepend:not(:first-child)>.input-group-text{border-top-left-radius:0;border-bottom-left-radius:0}.custom-control{position:relative;z-index:1;display:block;min-height:1.5rem;padding-left:1.5rem;-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.custom-control-inline{display:-ms-inline-flexbox;display:inline-flex;margin-right:1rem}.custom-control-input{position:absolute;left:0;z-index:-1;width:1rem;height:1.25rem;opacity:0}.custom-control-input:checked~.custom-control-label::before{color:#fff;border-color:#007bff;background-color:#007bff}.custom-control-input:focus~.custom-control-label::before{box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-control-input:focus:not(:checked)~.custom-control-label::before{border-color:#80bdff}.custom-control-input:not(:disabled):active~.custom-control-label::before{color:#fff;background-color:#b3d7ff;border-color:#b3d7ff}.custom-control-input:disabled~.custom-control-label,.custom-control-input[disabled]~.custom-control-label{color:#6c757d}.custom-control-input:disabled~.custom-control-label::before,.custom-control-input[disabled]~.custom-control-label::before{background-color:#e9ecef}.custom-control-label{position:relative;margin-bottom:0;vertical-align:top}.custom-control-label::before{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;pointer-events:none;content:"";background-color:#fff;border:1px solid #adb5bd}.custom-control-label::after{position:absolute;top:.25rem;left:-1.5rem;display:block;width:1rem;height:1rem;content:"";background:50%/50% 50% no-repeat}.custom-checkbox .custom-control-label::before{border-radius:.25rem}.custom-checkbox .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::before{border-color:#007bff;background-color:#007bff}.custom-checkbox .custom-control-input:indeterminate~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e")}.custom-checkbox .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-checkbox .custom-control-input:disabled:indeterminate~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-radio .custom-control-label::before{border-radius:50%}.custom-radio .custom-control-input:checked~.custom-control-label::after{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.custom-radio .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-switch{padding-left:2.25rem}.custom-switch .custom-control-label::before{left:-2.25rem;width:1.75rem;pointer-events:all;border-radius:.5rem}.custom-switch .custom-control-label::after{top:calc(.25rem + 2px);left:calc(-2.25rem + 2px);width:calc(1rem - 4px);height:calc(1rem - 4px);background-color:#adb5bd;border-radius:.5rem;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:transform .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out,-webkit-transform .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-switch .custom-control-label::after{transition:none}}.custom-switch .custom-control-input:checked~.custom-control-label::after{background-color:#fff;-webkit-transform:translateX(.75rem);transform:translateX(.75rem)}.custom-switch .custom-control-input:disabled:checked~.custom-control-label::before{background-color:rgba(0,123,255,.5)}.custom-select{display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);padding:.375rem 1.75rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:#495057;vertical-align:middle;background:#fff url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='5' viewBox='0 0 4 5'%3e%3cpath fill='%23343a40' d='M2 0L0 2h4zm0 5L0 3h4z'/%3e%3c/svg%3e") right .75rem center/8px 10px no-repeat;border:1px solid #ced4da;border-radius:.25rem;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-select:focus{border-color:#80bdff;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-select:focus::-ms-value{color:#495057;background-color:#fff}.custom-select[multiple],.custom-select[size]:not([size="1"]){height:auto;padding-right:.75rem;background-image:none}.custom-select:disabled{color:#6c757d;background-color:#e9ecef}.custom-select::-ms-expand{display:none}.custom-select:-moz-focusring{color:transparent;text-shadow:0 0 0 #495057}.custom-select-sm{height:calc(1.5em + .5rem + 2px);padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem}.custom-select-lg{height:calc(1.5em + 1rem + 2px);padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem}.custom-file{position:relative;display:inline-block;width:100%;height:calc(1.5em + .75rem + 2px);margin-bottom:0}.custom-file-input{position:relative;z-index:2;width:100%;height:calc(1.5em + .75rem + 2px);margin:0;overflow:hidden;opacity:0}.custom-file-input:focus~.custom-file-label{border-color:#80bdff;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.custom-file-input:disabled~.custom-file-label,.custom-file-input[disabled]~.custom-file-label{background-color:#e9ecef}.custom-file-input:lang(en)~.custom-file-label::after{content:"Browse"}.custom-file-input~.custom-file-label[data-browse]::after{content:attr(data-browse)}.custom-file-label{position:absolute;top:0;right:0;left:0;z-index:1;height:calc(1.5em + .75rem + 2px);padding:.375rem .75rem;overflow:hidden;font-weight:400;line-height:1.5;color:#495057;background-color:#fff;border:1px solid #ced4da;border-radius:.25rem}.custom-file-label::after{position:absolute;top:0;right:0;bottom:0;z-index:3;display:block;height:calc(1.5em + .75rem);padding:.375rem .75rem;line-height:1.5;color:#495057;content:"Browse";background-color:#e9ecef;border-left:inherit;border-radius:0 .25rem .25rem 0}.custom-range{width:100%;height:1.4rem;padding:0;background-color:transparent;-webkit-appearance:none;-moz-appearance:none;appearance:none}.custom-range:focus{outline:0}.custom-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range:focus::-ms-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .2rem rgba(0,123,255,.25)}.custom-range::-moz-focus-outer{border:0}.custom-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;background-color:#007bff;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-webkit-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.custom-range::-webkit-slider-thumb:active{background-color:#b3d7ff}.custom-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-moz-range-thumb{width:1rem;height:1rem;background-color:#007bff;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;-moz-appearance:none;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-moz-range-thumb{-moz-transition:none;transition:none}}.custom-range::-moz-range-thumb:active{background-color:#b3d7ff}.custom-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:#dee2e6;border-color:transparent;border-radius:1rem}.custom-range::-ms-thumb{width:1rem;height:1rem;margin-top:0;margin-right:.2rem;margin-left:.2rem;background-color:#007bff;border:0;border-radius:1rem;-ms-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;appearance:none}@media (prefers-reduced-motion:reduce){.custom-range::-ms-thumb{-ms-transition:none;transition:none}}.custom-range::-ms-thumb:active{background-color:#b3d7ff}.custom-range::-ms-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:transparent;border-color:transparent;border-width:.5rem}.custom-range::-ms-fill-lower{background-color:#dee2e6;border-radius:1rem}.custom-range::-ms-fill-upper{margin-right:15px;background-color:#dee2e6;border-radius:1rem}.custom-range:disabled::-webkit-slider-thumb{background-color:#adb5bd}.custom-range:disabled::-webkit-slider-runnable-track{cursor:default}.custom-range:disabled::-moz-range-thumb{background-color:#adb5bd}.custom-range:disabled::-moz-range-track{cursor:default}.custom-range:disabled::-ms-thumb{background-color:#adb5bd}.custom-control-label::before,.custom-file-label,.custom-select{transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.custom-control-label::before,.custom-file-label,.custom-select{transition:none}}.nav{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:.5rem 1rem}.nav-link:focus,.nav-link:hover{text-decoration:none}.nav-link.disabled{color:#6c757d;pointer-events:none;cursor:default}.nav-tabs{border-bottom:1px solid #dee2e6}.nav-tabs .nav-link{margin-bottom:-1px;background-color:transparent;border:1px solid transparent;border-top-left-radius:.25rem;border-top-right-radius:.25rem}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:#e9ecef #e9ecef #dee2e6}.nav-tabs .nav-link.disabled{color:#6c757d;background-color:transparent;border-color:transparent}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:#495057;background-color:#fff;border-color:#dee2e6 #dee2e6 #fff}.nav-tabs .dropdown-menu{margin-top:-1px;border-top-left-radius:0;border-top-right-radius:0}.nav-pills .nav-link{background:0 0;border:0;border-radius:.25rem}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:#fff;background-color:#007bff}.nav-fill .nav-item,.nav-fill>.nav-link{-ms-flex:1 1 auto;flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{-ms-flex-preferred-size:0;flex-basis:0;-ms-flex-positive:1;flex-grow:1;text-align:center}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{position:relative;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between;padding:.5rem 1rem}.navbar .container,.navbar .container-fluid,.navbar .container-lg,.navbar .container-md,.navbar .container-sm,.navbar .container-xl{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:justify;justify-content:space-between}.navbar-brand{display:inline-block;padding-top:.3125rem;padding-bottom:.3125rem;margin-right:1rem;font-size:1.25rem;line-height:inherit;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{text-decoration:none}.navbar-nav{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link{padding-right:0;padding-left:0}.navbar-nav .dropdown-menu{position:static;float:none}.navbar-text{display:inline-block;padding-top:.5rem;padding-bottom:.5rem}.navbar-collapse{-ms-flex-preferred-size:100%;flex-basis:100%;-ms-flex-positive:1;flex-grow:1;-ms-flex-align:center;align-items:center}.navbar-toggler{padding:.25rem .75rem;font-size:1.25rem;line-height:1;background-color:transparent;border:1px solid transparent;border-radius:.25rem}.navbar-toggler:focus,.navbar-toggler:hover{text-decoration:none}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;content:"";background:50%/100% 100% no-repeat}.navbar-nav-scroll{max-height:75vh;overflow-y:auto}@media (max-width:575.98px){.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{padding-right:0;padding-left:0}}@media (min-width:576px){.navbar-expand-sm{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-sm .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-sm>.container,.navbar-expand-sm>.container-fluid,.navbar-expand-sm>.container-lg,.navbar-expand-sm>.container-md,.navbar-expand-sm>.container-sm,.navbar-expand-sm>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}}@media (max-width:767.98px){.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{padding-right:0;padding-left:0}}@media (min-width:768px){.navbar-expand-md{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-md .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-md>.container,.navbar-expand-md>.container-fluid,.navbar-expand-md>.container-lg,.navbar-expand-md>.container-md,.navbar-expand-md>.container-sm,.navbar-expand-md>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}}@media (max-width:991.98px){.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{padding-right:0;padding-left:0}}@media (min-width:992px){.navbar-expand-lg{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-lg .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-lg>.container,.navbar-expand-lg>.container-fluid,.navbar-expand-lg>.container-lg,.navbar-expand-lg>.container-md,.navbar-expand-lg>.container-sm,.navbar-expand-lg>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}}@media (max-width:1199.98px){.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{padding-right:0;padding-left:0}}@media (min-width:1200px){.navbar-expand-xl{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand-xl .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand-xl>.container,.navbar-expand-xl>.container-fluid,.navbar-expand-xl>.container-lg,.navbar-expand-xl>.container-md,.navbar-expand-xl>.container-sm,.navbar-expand-xl>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}}.navbar-expand{-ms-flex-flow:row nowrap;flex-flow:row nowrap;-ms-flex-pack:start;justify-content:flex-start}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{padding-right:0;padding-left:0}.navbar-expand .navbar-nav{-ms-flex-direction:row;flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:.5rem;padding-left:.5rem}.navbar-expand>.container,.navbar-expand>.container-fluid,.navbar-expand>.container-lg,.navbar-expand>.container-md,.navbar-expand>.container-sm,.navbar-expand>.container-xl{-ms-flex-wrap:nowrap;flex-wrap:nowrap}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:-ms-flexbox!important;display:flex!important;-ms-flex-preferred-size:auto;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-light .navbar-brand{color:rgba(0,0,0,.9)}.navbar-light .navbar-brand:focus,.navbar-light .navbar-brand:hover{color:rgba(0,0,0,.9)}.navbar-light .navbar-nav .nav-link{color:rgba(0,0,0,.5)}.navbar-light .navbar-nav .nav-link:focus,.navbar-light .navbar-nav .nav-link:hover{color:rgba(0,0,0,.7)}.navbar-light .navbar-nav .nav-link.disabled{color:rgba(0,0,0,.3)}.navbar-light .navbar-nav .active>.nav-link,.navbar-light .navbar-nav .nav-link.active,.navbar-light .navbar-nav .nav-link.show,.navbar-light .navbar-nav .show>.nav-link{color:rgba(0,0,0,.9)}.navbar-light .navbar-toggler{color:rgba(0,0,0,.5);border-color:rgba(0,0,0,.1)}.navbar-light .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%280, 0, 0, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-light .navbar-text{color:rgba(0,0,0,.5)}.navbar-light .navbar-text a{color:rgba(0,0,0,.9)}.navbar-light .navbar-text a:focus,.navbar-light .navbar-text a:hover{color:rgba(0,0,0,.9)}.navbar-dark .navbar-brand{color:#fff}.navbar-dark .navbar-brand:focus,.navbar-dark .navbar-brand:hover{color:#fff}.navbar-dark .navbar-nav .nav-link{color:rgba(255,255,255,.5)}.navbar-dark .navbar-nav .nav-link:focus,.navbar-dark .navbar-nav .nav-link:hover{color:rgba(255,255,255,.75)}.navbar-dark .navbar-nav .nav-link.disabled{color:rgba(255,255,255,.25)}.navbar-dark .navbar-nav .active>.nav-link,.navbar-dark .navbar-nav .nav-link.active,.navbar-dark .navbar-nav .nav-link.show,.navbar-dark .navbar-nav .show>.nav-link{color:#fff}.navbar-dark .navbar-toggler{color:rgba(255,255,255,.5);border-color:rgba(255,255,255,.1)}.navbar-dark .navbar-toggler-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.5%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.navbar-dark .navbar-text{color:rgba(255,255,255,.5)}.navbar-dark .navbar-text a{color:#fff}.navbar-dark .navbar-text a:focus,.navbar-dark .navbar-text a:hover{color:#fff}.card{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;min-width:0;word-wrap:break-word;background-color:#fff;background-clip:border-box;border:1px solid rgba(0,0,0,.125);border-radius:.25rem}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{-ms-flex:1 1 auto;flex:1 1 auto;min-height:1px;padding:1.25rem}.card-title{margin-bottom:.75rem}.card-subtitle{margin-top:-.375rem;margin-bottom:0}.card-text:last-child{margin-bottom:0}.card-link:hover{text-decoration:none}.card-link+.card-link{margin-left:1.25rem}.card-header{padding:.75rem 1.25rem;margin-bottom:0;background-color:rgba(0,0,0,.03);border-bottom:1px solid rgba(0,0,0,.125)}.card-header:first-child{border-radius:calc(.25rem - 1px) calc(.25rem - 1px) 0 0}.card-footer{padding:.75rem 1.25rem;background-color:rgba(0,0,0,.03);border-top:1px solid rgba(0,0,0,.125)}.card-footer:last-child{border-radius:0 0 calc(.25rem - 1px) calc(.25rem - 1px)}.card-header-tabs{margin-right:-.625rem;margin-bottom:-.75rem;margin-left:-.625rem;border-bottom:0}.card-header-pills{margin-right:-.625rem;margin-left:-.625rem}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:1.25rem;border-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom,.card-img-top{-ms-flex-negative:0;flex-shrink:0;width:100%}.card-img,.card-img-top{border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.card-img,.card-img-bottom{border-bottom-right-radius:calc(.25rem - 1px);border-bottom-left-radius:calc(.25rem - 1px)}.card-deck .card{margin-bottom:15px}@media (min-width:576px){.card-deck{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap;margin-right:-15px;margin-left:-15px}.card-deck .card{-ms-flex:1 0 0%;flex:1 0 0%;margin-right:15px;margin-bottom:0;margin-left:15px}}.card-group>.card{margin-bottom:15px}@media (min-width:576px){.card-group{display:-ms-flexbox;display:flex;-ms-flex-flow:row wrap;flex-flow:row wrap}.card-group>.card{-ms-flex:1 0 0%;flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.card-columns .card{margin-bottom:.75rem}@media (min-width:576px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:1.25rem;-moz-column-gap:1.25rem;column-gap:1.25rem;orphans:1;widows:1}.card-columns .card{display:inline-block;width:100%}}.accordion{overflow-anchor:none}.accordion>.card{overflow:hidden}.accordion>.card:not(:last-of-type){border-bottom:0;border-bottom-right-radius:0;border-bottom-left-radius:0}.accordion>.card:not(:first-of-type){border-top-left-radius:0;border-top-right-radius:0}.accordion>.card>.card-header{border-radius:0;margin-bottom:-1px}.breadcrumb{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;padding:.75rem 1rem;margin-bottom:1rem;list-style:none;background-color:#e9ecef;border-radius:.25rem}.breadcrumb-item+.breadcrumb-item{padding-left:.5rem}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:.5rem;color:#6c757d;content:"/"}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:underline}.breadcrumb-item+.breadcrumb-item:hover::before{text-decoration:none}.breadcrumb-item.active{color:#6c757d}.pagination{display:-ms-flexbox;display:flex;padding-left:0;list-style:none;border-radius:.25rem}.page-link{position:relative;display:block;padding:.5rem .75rem;margin-left:-1px;line-height:1.25;color:#007bff;background-color:#fff;border:1px solid #dee2e6}.page-link:hover{z-index:2;color:#0056b3;text-decoration:none;background-color:#e9ecef;border-color:#dee2e6}.page-link:focus{z-index:3;outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.25)}.page-item:first-child .page-link{margin-left:0;border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.page-item:last-child .page-link{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.page-item.active .page-link{z-index:3;color:#fff;background-color:#007bff;border-color:#007bff}.page-item.disabled .page-link{color:#6c757d;pointer-events:none;cursor:auto;background-color:#fff;border-color:#dee2e6}.pagination-lg .page-link{padding:.75rem 1.5rem;font-size:1.25rem;line-height:1.5}.pagination-lg .page-item:first-child .page-link{border-top-left-radius:.3rem;border-bottom-left-radius:.3rem}.pagination-lg .page-item:last-child .page-link{border-top-right-radius:.3rem;border-bottom-right-radius:.3rem}.pagination-sm .page-link{padding:.25rem .5rem;font-size:.875rem;line-height:1.5}.pagination-sm .page-item:first-child .page-link{border-top-left-radius:.2rem;border-bottom-left-radius:.2rem}.pagination-sm .page-item:last-child .page-link{border-top-right-radius:.2rem;border-bottom-right-radius:.2rem}.badge{display:inline-block;padding:.25em .4em;font-size:75%;font-weight:700;line-height:1;text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:.25rem;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.badge{transition:none}}a.badge:focus,a.badge:hover{text-decoration:none}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.badge-pill{padding-right:.6em;padding-left:.6em;border-radius:10rem}.badge-primary{color:#fff;background-color:#007bff}a.badge-primary:focus,a.badge-primary:hover{color:#fff;background-color:#0062cc}a.badge-primary.focus,a.badge-primary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(0,123,255,.5)}.badge-secondary{color:#fff;background-color:#6c757d}a.badge-secondary:focus,a.badge-secondary:hover{color:#fff;background-color:#545b62}a.badge-secondary.focus,a.badge-secondary:focus{outline:0;box-shadow:0 0 0 .2rem rgba(108,117,125,.5)}.badge-success{color:#fff;background-color:#28a745}a.badge-success:focus,a.badge-success:hover{color:#fff;background-color:#1e7e34}a.badge-success.focus,a.badge-success:focus{outline:0;box-shadow:0 0 0 .2rem rgba(40,167,69,.5)}.badge-info{color:#fff;background-color:#17a2b8}a.badge-info:focus,a.badge-info:hover{color:#fff;background-color:#117a8b}a.badge-info.focus,a.badge-info:focus{outline:0;box-shadow:0 0 0 .2rem rgba(23,162,184,.5)}.badge-warning{color:#212529;background-color:#ffc107}a.badge-warning:focus,a.badge-warning:hover{color:#212529;background-color:#d39e00}a.badge-warning.focus,a.badge-warning:focus{outline:0;box-shadow:0 0 0 .2rem rgba(255,193,7,.5)}.badge-danger{color:#fff;background-color:#dc3545}a.badge-danger:focus,a.badge-danger:hover{color:#fff;background-color:#bd2130}a.badge-danger.focus,a.badge-danger:focus{outline:0;box-shadow:0 0 0 .2rem rgba(220,53,69,.5)}.badge-light{color:#212529;background-color:#f8f9fa}a.badge-light:focus,a.badge-light:hover{color:#212529;background-color:#dae0e5}a.badge-light.focus,a.badge-light:focus{outline:0;box-shadow:0 0 0 .2rem rgba(248,249,250,.5)}.badge-dark{color:#fff;background-color:#343a40}a.badge-dark:focus,a.badge-dark:hover{color:#fff;background-color:#1d2124}a.badge-dark.focus,a.badge-dark:focus{outline:0;box-shadow:0 0 0 .2rem rgba(52,58,64,.5)}.jumbotron{padding:2rem 1rem;margin-bottom:2rem;background-color:#e9ecef;border-radius:.3rem}@media (min-width:576px){.jumbotron{padding:4rem 2rem}}.jumbotron-fluid{padding-right:0;padding-left:0;border-radius:0}.alert{position:relative;padding:.75rem 1.25rem;margin-bottom:1rem;border:1px solid transparent;border-radius:.25rem}.alert-heading{color:inherit}.alert-link{font-weight:700}.alert-dismissible{padding-right:4rem}.alert-dismissible .close{position:absolute;top:0;right:0;z-index:2;padding:.75rem 1.25rem;color:inherit}.alert-primary{color:#004085;background-color:#cce5ff;border-color:#b8daff}.alert-primary hr{border-top-color:#9fcdff}.alert-primary .alert-link{color:#002752}.alert-secondary{color:#383d41;background-color:#e2e3e5;border-color:#d6d8db}.alert-secondary hr{border-top-color:#c8cbcf}.alert-secondary .alert-link{color:#202326}.alert-success{color:#155724;background-color:#d4edda;border-color:#c3e6cb}.alert-success hr{border-top-color:#b1dfbb}.alert-success .alert-link{color:#0b2e13}.alert-info{color:#0c5460;background-color:#d1ecf1;border-color:#bee5eb}.alert-info hr{border-top-color:#abdde5}.alert-info .alert-link{color:#062c33}.alert-warning{color:#856404;background-color:#fff3cd;border-color:#ffeeba}.alert-warning hr{border-top-color:#ffe8a1}.alert-warning .alert-link{color:#533f03}.alert-danger{color:#721c24;background-color:#f8d7da;border-color:#f5c6cb}.alert-danger hr{border-top-color:#f1b0b7}.alert-danger .alert-link{color:#491217}.alert-light{color:#818182;background-color:#fefefe;border-color:#fdfdfe}.alert-light hr{border-top-color:#ececf6}.alert-light .alert-link{color:#686868}.alert-dark{color:#1b1e21;background-color:#d6d8d9;border-color:#c6c8ca}.alert-dark hr{border-top-color:#b9bbbe}.alert-dark .alert-link{color:#040505}@-webkit-keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}@keyframes progress-bar-stripes{from{background-position:1rem 0}to{background-position:0 0}}.progress{display:-ms-flexbox;display:flex;height:1rem;overflow:hidden;line-height:0;font-size:.75rem;background-color:#e9ecef;border-radius:.25rem}.progress-bar{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;overflow:hidden;color:#fff;text-align:center;white-space:nowrap;background-color:#007bff;transition:width .6s ease}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:1rem 1rem}.progress-bar-animated{-webkit-animation:1s linear infinite progress-bar-stripes;animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{-webkit-animation:none;animation:none}}.media{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start}.media-body{-ms-flex:1;flex:1}.list-group{display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:.25rem}.list-group-item-action{width:100%;color:#495057;text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:#495057;text-decoration:none;background-color:#f8f9fa}.list-group-item-action:active{color:#212529;background-color:#e9ecef}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;background-color:#fff;border:1px solid rgba(0,0,0,.125)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:#6c757d;pointer-events:none;background-color:#fff}.list-group-item.active{z-index:2;color:#fff;background-color:#007bff;border-color:#007bff}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:-1px;border-top-width:1px}.list-group-horizontal{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}@media (min-width:576px){.list-group-horizontal-sm{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:768px){.list-group-horizontal-md{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:992px){.list-group-horizontal-lg{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}@media (min-width:1200px){.list-group-horizontal-xl{-ms-flex-direction:row;flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child{border-bottom-left-radius:.25rem;border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child{border-top-right-radius:.25rem;border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:1px;border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:-1px;border-left-width:1px}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 1px}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{color:#004085;background-color:#b8daff}.list-group-item-primary.list-group-item-action:focus,.list-group-item-primary.list-group-item-action:hover{color:#004085;background-color:#9fcdff}.list-group-item-primary.list-group-item-action.active{color:#fff;background-color:#004085;border-color:#004085}.list-group-item-secondary{color:#383d41;background-color:#d6d8db}.list-group-item-secondary.list-group-item-action:focus,.list-group-item-secondary.list-group-item-action:hover{color:#383d41;background-color:#c8cbcf}.list-group-item-secondary.list-group-item-action.active{color:#fff;background-color:#383d41;border-color:#383d41}.list-group-item-success{color:#155724;background-color:#c3e6cb}.list-group-item-success.list-group-item-action:focus,.list-group-item-success.list-group-item-action:hover{color:#155724;background-color:#b1dfbb}.list-group-item-success.list-group-item-action.active{color:#fff;background-color:#155724;border-color:#155724}.list-group-item-info{color:#0c5460;background-color:#bee5eb}.list-group-item-info.list-group-item-action:focus,.list-group-item-info.list-group-item-action:hover{color:#0c5460;background-color:#abdde5}.list-group-item-info.list-group-item-action.active{color:#fff;background-color:#0c5460;border-color:#0c5460}.list-group-item-warning{color:#856404;background-color:#ffeeba}.list-group-item-warning.list-group-item-action:focus,.list-group-item-warning.list-group-item-action:hover{color:#856404;background-color:#ffe8a1}.list-group-item-warning.list-group-item-action.active{color:#fff;background-color:#856404;border-color:#856404}.list-group-item-danger{color:#721c24;background-color:#f5c6cb}.list-group-item-danger.list-group-item-action:focus,.list-group-item-danger.list-group-item-action:hover{color:#721c24;background-color:#f1b0b7}.list-group-item-danger.list-group-item-action.active{color:#fff;background-color:#721c24;border-color:#721c24}.list-group-item-light{color:#818182;background-color:#fdfdfe}.list-group-item-light.list-group-item-action:focus,.list-group-item-light.list-group-item-action:hover{color:#818182;background-color:#ececf6}.list-group-item-light.list-group-item-action.active{color:#fff;background-color:#818182;border-color:#818182}.list-group-item-dark{color:#1b1e21;background-color:#c6c8ca}.list-group-item-dark.list-group-item-action:focus,.list-group-item-dark.list-group-item-action:hover{color:#1b1e21;background-color:#b9bbbe}.list-group-item-dark.list-group-item-action.active{color:#fff;background-color:#1b1e21;border-color:#1b1e21}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.5}.close:hover{color:#000;text-decoration:none}.close:not(:disabled):not(.disabled):focus,.close:not(:disabled):not(.disabled):hover{opacity:.75}button.close{padding:0;background-color:transparent;border:0}a.close.disabled{pointer-events:none}.toast{-ms-flex-preferred-size:350px;flex-basis:350px;max-width:350px;font-size:.875rem;background-color:rgba(255,255,255,.85);background-clip:padding-box;border:1px solid rgba(0,0,0,.1);box-shadow:0 .25rem .75rem rgba(0,0,0,.1);opacity:0;border-radius:.25rem}.toast:not(:last-child){margin-bottom:.75rem}.toast.showing{opacity:1}.toast.show{display:block;opacity:1}.toast.hide{display:none}.toast-header{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;padding:.25rem .75rem;color:#6c757d;background-color:rgba(255,255,255,.85);background-clip:padding-box;border-bottom:1px solid rgba(0,0,0,.05);border-top-left-radius:calc(.25rem - 1px);border-top-right-radius:calc(.25rem - 1px)}.toast-body{padding:.75rem}.modal-open{overflow:hidden}.modal-open .modal{overflow-x:hidden;overflow-y:auto}.modal{position:fixed;top:0;left:0;z-index:1050;display:none;width:100%;height:100%;overflow:hidden;outline:0}.modal-dialog{position:relative;width:auto;margin:.5rem;pointer-events:none}.modal.fade .modal-dialog{transition:-webkit-transform .3s ease-out;transition:transform .3s ease-out;transition:transform .3s ease-out,-webkit-transform .3s ease-out;-webkit-transform:translate(0,-50px);transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{-webkit-transform:none;transform:none}.modal.modal-static .modal-dialog{-webkit-transform:scale(1.02);transform:scale(1.02)}.modal-dialog-scrollable{display:-ms-flexbox;display:flex;max-height:calc(100% - 1rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 1rem);overflow:hidden}.modal-dialog-scrollable .modal-footer,.modal-dialog-scrollable .modal-header{-ms-flex-negative:0;flex-shrink:0}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;min-height:calc(100% - 1rem)}.modal-dialog-centered::before{display:block;height:calc(100vh - 1rem);height:-webkit-min-content;height:-moz-min-content;height:min-content;content:""}.modal-dialog-centered.modal-dialog-scrollable{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:center;justify-content:center;height:100%}.modal-dialog-centered.modal-dialog-scrollable .modal-content{max-height:none}.modal-dialog-centered.modal-dialog-scrollable::before{content:none}.modal-content{position:relative;display:-ms-flexbox;display:flex;-ms-flex-direction:column;flex-direction:column;width:100%;pointer-events:auto;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem;outline:0}.modal-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:.5}.modal-header{display:-ms-flexbox;display:flex;-ms-flex-align:start;align-items:flex-start;-ms-flex-pack:justify;justify-content:space-between;padding:1rem 1rem;border-bottom:1px solid #dee2e6;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.modal-header .close{padding:1rem 1rem;margin:-1rem -1rem -1rem auto}.modal-title{margin-bottom:0;line-height:1.5}.modal-body{position:relative;-ms-flex:1 1 auto;flex:1 1 auto;padding:1rem}.modal-footer{display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;-ms-flex-align:center;align-items:center;-ms-flex-pack:end;justify-content:flex-end;padding:.75rem;border-top:1px solid #dee2e6;border-bottom-right-radius:calc(.3rem - 1px);border-bottom-left-radius:calc(.3rem - 1px)}.modal-footer>*{margin:.25rem}.modal-scrollbar-measure{position:absolute;top:-9999px;width:50px;height:50px;overflow:scroll}@media (min-width:576px){.modal-dialog{max-width:500px;margin:1.75rem auto}.modal-dialog-scrollable{max-height:calc(100% - 3.5rem)}.modal-dialog-scrollable .modal-content{max-height:calc(100vh - 3.5rem)}.modal-dialog-centered{min-height:calc(100% - 3.5rem)}.modal-dialog-centered::before{height:calc(100vh - 3.5rem);height:-webkit-min-content;height:-moz-min-content;height:min-content}.modal-sm{max-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{max-width:800px}}@media (min-width:1200px){.modal-xl{max-width:1140px}}.tooltip{position:absolute;z-index:1070;display:block;margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;opacity:0}.tooltip.show{opacity:.9}.tooltip .arrow{position:absolute;display:block;width:.8rem;height:.4rem}.tooltip .arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[x-placement^=top],.bs-tooltip-top{padding:.4rem 0}.bs-tooltip-auto[x-placement^=top] .arrow,.bs-tooltip-top .arrow{bottom:0}.bs-tooltip-auto[x-placement^=top] .arrow::before,.bs-tooltip-top .arrow::before{top:0;border-width:.4rem .4rem 0;border-top-color:#000}.bs-tooltip-auto[x-placement^=right],.bs-tooltip-right{padding:0 .4rem}.bs-tooltip-auto[x-placement^=right] .arrow,.bs-tooltip-right .arrow{left:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=right] .arrow::before,.bs-tooltip-right .arrow::before{right:0;border-width:.4rem .4rem .4rem 0;border-right-color:#000}.bs-tooltip-auto[x-placement^=bottom],.bs-tooltip-bottom{padding:.4rem 0}.bs-tooltip-auto[x-placement^=bottom] .arrow,.bs-tooltip-bottom .arrow{top:0}.bs-tooltip-auto[x-placement^=bottom] .arrow::before,.bs-tooltip-bottom .arrow::before{bottom:0;border-width:0 .4rem .4rem;border-bottom-color:#000}.bs-tooltip-auto[x-placement^=left],.bs-tooltip-left{padding:0 .4rem}.bs-tooltip-auto[x-placement^=left] .arrow,.bs-tooltip-left .arrow{right:0;width:.4rem;height:.8rem}.bs-tooltip-auto[x-placement^=left] .arrow::before,.bs-tooltip-left .arrow::before{left:0;border-width:.4rem 0 .4rem .4rem;border-left-color:#000}.tooltip-inner{max-width:200px;padding:.25rem .5rem;color:#fff;text-align:center;background-color:#000;border-radius:.25rem}.popover{position:absolute;top:0;left:0;z-index:1060;display:block;max-width:276px;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:.875rem;word-wrap:break-word;background-color:#fff;background-clip:padding-box;border:1px solid rgba(0,0,0,.2);border-radius:.3rem}.popover .arrow{position:absolute;display:block;width:1rem;height:.5rem;margin:0 .3rem}.popover .arrow::after,.popover .arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid}.bs-popover-auto[x-placement^=top],.bs-popover-top{margin-bottom:.5rem}.bs-popover-auto[x-placement^=top]>.arrow,.bs-popover-top>.arrow{bottom:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=top]>.arrow::before,.bs-popover-top>.arrow::before{bottom:0;border-width:.5rem .5rem 0;border-top-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=top]>.arrow::after,.bs-popover-top>.arrow::after{bottom:1px;border-width:.5rem .5rem 0;border-top-color:#fff}.bs-popover-auto[x-placement^=right],.bs-popover-right{margin-left:.5rem}.bs-popover-auto[x-placement^=right]>.arrow,.bs-popover-right>.arrow{left:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=right]>.arrow::before,.bs-popover-right>.arrow::before{left:0;border-width:.5rem .5rem .5rem 0;border-right-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=right]>.arrow::after,.bs-popover-right>.arrow::after{left:1px;border-width:.5rem .5rem .5rem 0;border-right-color:#fff}.bs-popover-auto[x-placement^=bottom],.bs-popover-bottom{margin-top:.5rem}.bs-popover-auto[x-placement^=bottom]>.arrow,.bs-popover-bottom>.arrow{top:calc(-.5rem - 1px)}.bs-popover-auto[x-placement^=bottom]>.arrow::before,.bs-popover-bottom>.arrow::before{top:0;border-width:0 .5rem .5rem .5rem;border-bottom-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=bottom]>.arrow::after,.bs-popover-bottom>.arrow::after{top:1px;border-width:0 .5rem .5rem .5rem;border-bottom-color:#fff}.bs-popover-auto[x-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:1rem;margin-left:-.5rem;content:"";border-bottom:1px solid #f7f7f7}.bs-popover-auto[x-placement^=left],.bs-popover-left{margin-right:.5rem}.bs-popover-auto[x-placement^=left]>.arrow,.bs-popover-left>.arrow{right:calc(-.5rem - 1px);width:.5rem;height:1rem;margin:.3rem 0}.bs-popover-auto[x-placement^=left]>.arrow::before,.bs-popover-left>.arrow::before{right:0;border-width:.5rem 0 .5rem .5rem;border-left-color:rgba(0,0,0,.25)}.bs-popover-auto[x-placement^=left]>.arrow::after,.bs-popover-left>.arrow::after{right:1px;border-width:.5rem 0 .5rem .5rem;border-left-color:#fff}.popover-header{padding:.5rem .75rem;margin-bottom:0;font-size:1rem;background-color:#f7f7f7;border-bottom:1px solid #ebebeb;border-top-left-radius:calc(.3rem - 1px);border-top-right-radius:calc(.3rem - 1px)}.popover-header:empty{display:none}.popover-body{padding:.5rem .75rem;color:#212529}.carousel{position:relative}.carousel.pointer-event{-ms-touch-action:pan-y;touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:-webkit-transform .6s ease-in-out;transition:transform .6s ease-in-out;transition:transform .6s ease-in-out,-webkit-transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-right,.carousel-item-next:not(.carousel-item-left){-webkit-transform:translateX(100%);transform:translateX(100%)}.active.carousel-item-left,.carousel-item-prev:not(.carousel-item-right){-webkit-transform:translateX(-100%);transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;-webkit-transform:none;transform:none}.carousel-fade .carousel-item-next.carousel-item-left,.carousel-fade .carousel-item-prev.carousel-item-right,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-left,.carousel-fade .active.carousel-item-right{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:-ms-flexbox;display:flex;-ms-flex-align:center;align-items:center;-ms-flex-pack:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:20px;height:20px;background:50%/100% 100% no-repeat}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M5.25 0l-4 4 4 4 1.5-1.5L4.25 4l2.5-2.5L5.25 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='%23fff' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath d='M2.75 0l-1.5 1.5L3.75 4l-2.5 2.5L2.75 8l4-4-4-4z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:15;display:-ms-flexbox;display:flex;-ms-flex-pack:center;justify-content:center;padding-left:0;margin-right:15%;margin-left:15%;list-style:none}.carousel-indicators li{box-sizing:content-box;-ms-flex:0 1 auto;flex:0 1 auto;width:30px;height:3px;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators li{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:20px;left:15%;z-index:10;padding-top:20px;padding-bottom:20px;color:#fff;text-align:center}@-webkit-keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}@keyframes spinner-border{to{-webkit-transform:rotate(360deg);transform:rotate(360deg)}}.spinner-border{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;border:.25em solid currentcolor;border-right-color:transparent;border-radius:50%;-webkit-animation:.75s linear infinite spinner-border;animation:.75s linear infinite spinner-border}.spinner-border-sm{width:1rem;height:1rem;border-width:.2em}@-webkit-keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}@keyframes spinner-grow{0%{-webkit-transform:scale(0);transform:scale(0)}50%{opacity:1;-webkit-transform:none;transform:none}}.spinner-grow{display:inline-block;width:2rem;height:2rem;vertical-align:-.125em;background-color:currentcolor;border-radius:50%;opacity:0;-webkit-animation:.75s linear infinite spinner-grow;animation:.75s linear infinite spinner-grow}.spinner-grow-sm{width:1rem;height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{-webkit-animation-duration:1.5s;animation-duration:1.5s}}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-primary{background-color:#007bff!important}a.bg-primary:focus,a.bg-primary:hover,button.bg-primary:focus,button.bg-primary:hover{background-color:#0062cc!important}.bg-secondary{background-color:#6c757d!important}a.bg-secondary:focus,a.bg-secondary:hover,button.bg-secondary:focus,button.bg-secondary:hover{background-color:#545b62!important}.bg-success{background-color:#28a745!important}a.bg-success:focus,a.bg-success:hover,button.bg-success:focus,button.bg-success:hover{background-color:#1e7e34!important}.bg-info{background-color:#17a2b8!important}a.bg-info:focus,a.bg-info:hover,button.bg-info:focus,button.bg-info:hover{background-color:#117a8b!important}.bg-warning{background-color:#ffc107!important}a.bg-warning:focus,a.bg-warning:hover,button.bg-warning:focus,button.bg-warning:hover{background-color:#d39e00!important}.bg-danger{background-color:#dc3545!important}a.bg-danger:focus,a.bg-danger:hover,button.bg-danger:focus,button.bg-danger:hover{background-color:#bd2130!important}.bg-light{background-color:#f8f9fa!important}a.bg-light:focus,a.bg-light:hover,button.bg-light:focus,button.bg-light:hover{background-color:#dae0e5!important}.bg-dark{background-color:#343a40!important}a.bg-dark:focus,a.bg-dark:hover,button.bg-dark:focus,button.bg-dark:hover{background-color:#1d2124!important}.bg-white{background-color:#fff!important}.bg-transparent{background-color:transparent!important}.border{border:1px solid #dee2e6!important}.border-top{border-top:1px solid #dee2e6!important}.border-right{border-right:1px solid #dee2e6!important}.border-bottom{border-bottom:1px solid #dee2e6!important}.border-left{border-left:1px solid #dee2e6!important}.border-0{border:0!important}.border-top-0{border-top:0!important}.border-right-0{border-right:0!important}.border-bottom-0{border-bottom:0!important}.border-left-0{border-left:0!important}.border-primary{border-color:#007bff!important}.border-secondary{border-color:#6c757d!important}.border-success{border-color:#28a745!important}.border-info{border-color:#17a2b8!important}.border-warning{border-color:#ffc107!important}.border-danger{border-color:#dc3545!important}.border-light{border-color:#f8f9fa!important}.border-dark{border-color:#343a40!important}.border-white{border-color:#fff!important}.rounded-sm{border-radius:.2rem!important}.rounded{border-radius:.25rem!important}.rounded-top{border-top-left-radius:.25rem!important;border-top-right-radius:.25rem!important}.rounded-right{border-top-right-radius:.25rem!important;border-bottom-right-radius:.25rem!important}.rounded-bottom{border-bottom-right-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-left{border-top-left-radius:.25rem!important;border-bottom-left-radius:.25rem!important}.rounded-lg{border-radius:.3rem!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:50rem!important}.rounded-0{border-radius:0!important}.clearfix::after{display:block;clear:both;content:""}.d-none{display:none!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:-ms-flexbox!important;display:flex!important}.d-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}@media (min-width:576px){.d-sm-none{display:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:-ms-flexbox!important;display:flex!important}.d-sm-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:768px){.d-md-none{display:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:-ms-flexbox!important;display:flex!important}.d-md-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:992px){.d-lg-none{display:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:-ms-flexbox!important;display:flex!important}.d-lg-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media (min-width:1200px){.d-xl-none{display:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:-ms-flexbox!important;display:flex!important}.d-xl-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}@media print{.d-print-none{display:none!important}.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:-ms-flexbox!important;display:flex!important}.d-print-inline-flex{display:-ms-inline-flexbox!important;display:inline-flex!important}}.embed-responsive{position:relative;display:block;width:100%;padding:0;overflow:hidden}.embed-responsive::before{display:block;content:""}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9::before{padding-top:42.857143%}.embed-responsive-16by9::before{padding-top:56.25%}.embed-responsive-4by3::before{padding-top:75%}.embed-responsive-1by1::before{padding-top:100%}.flex-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-center{-ms-flex-align:center!important;align-items:center!important}.align-items-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}@media (min-width:576px){.flex-sm-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-sm-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-sm-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-sm-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-sm-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-sm-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-sm-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-sm-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-sm-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-sm-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-sm-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-sm-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-sm-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-sm-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-sm-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-sm-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-sm-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-sm-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-sm-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-sm-center{-ms-flex-align:center!important;align-items:center!important}.align-items-sm-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-sm-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-sm-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-sm-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-sm-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-sm-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-sm-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-sm-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-sm-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-sm-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-sm-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-sm-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-sm-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-sm-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:768px){.flex-md-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-md-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-md-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-md-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-md-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-md-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-md-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-md-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-md-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-md-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-md-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-md-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-md-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-md-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-md-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-md-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-md-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-md-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-md-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-md-center{-ms-flex-align:center!important;align-items:center!important}.align-items-md-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-md-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-md-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-md-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-md-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-md-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-md-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-md-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-md-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-md-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-md-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-md-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-md-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-md-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:992px){.flex-lg-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-lg-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-lg-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-lg-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-lg-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-lg-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-lg-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-lg-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-lg-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-lg-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-lg-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-lg-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-lg-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-lg-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-lg-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-lg-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-lg-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-lg-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-lg-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-lg-center{-ms-flex-align:center!important;align-items:center!important}.align-items-lg-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-lg-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-lg-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-lg-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-lg-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-lg-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-lg-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-lg-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-lg-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-lg-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-lg-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-lg-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-lg-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-lg-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}@media (min-width:1200px){.flex-xl-row{-ms-flex-direction:row!important;flex-direction:row!important}.flex-xl-column{-ms-flex-direction:column!important;flex-direction:column!important}.flex-xl-row-reverse{-ms-flex-direction:row-reverse!important;flex-direction:row-reverse!important}.flex-xl-column-reverse{-ms-flex-direction:column-reverse!important;flex-direction:column-reverse!important}.flex-xl-wrap{-ms-flex-wrap:wrap!important;flex-wrap:wrap!important}.flex-xl-nowrap{-ms-flex-wrap:nowrap!important;flex-wrap:nowrap!important}.flex-xl-wrap-reverse{-ms-flex-wrap:wrap-reverse!important;flex-wrap:wrap-reverse!important}.flex-xl-fill{-ms-flex:1 1 auto!important;flex:1 1 auto!important}.flex-xl-grow-0{-ms-flex-positive:0!important;flex-grow:0!important}.flex-xl-grow-1{-ms-flex-positive:1!important;flex-grow:1!important}.flex-xl-shrink-0{-ms-flex-negative:0!important;flex-shrink:0!important}.flex-xl-shrink-1{-ms-flex-negative:1!important;flex-shrink:1!important}.justify-content-xl-start{-ms-flex-pack:start!important;justify-content:flex-start!important}.justify-content-xl-end{-ms-flex-pack:end!important;justify-content:flex-end!important}.justify-content-xl-center{-ms-flex-pack:center!important;justify-content:center!important}.justify-content-xl-between{-ms-flex-pack:justify!important;justify-content:space-between!important}.justify-content-xl-around{-ms-flex-pack:distribute!important;justify-content:space-around!important}.align-items-xl-start{-ms-flex-align:start!important;align-items:flex-start!important}.align-items-xl-end{-ms-flex-align:end!important;align-items:flex-end!important}.align-items-xl-center{-ms-flex-align:center!important;align-items:center!important}.align-items-xl-baseline{-ms-flex-align:baseline!important;align-items:baseline!important}.align-items-xl-stretch{-ms-flex-align:stretch!important;align-items:stretch!important}.align-content-xl-start{-ms-flex-line-pack:start!important;align-content:flex-start!important}.align-content-xl-end{-ms-flex-line-pack:end!important;align-content:flex-end!important}.align-content-xl-center{-ms-flex-line-pack:center!important;align-content:center!important}.align-content-xl-between{-ms-flex-line-pack:justify!important;align-content:space-between!important}.align-content-xl-around{-ms-flex-line-pack:distribute!important;align-content:space-around!important}.align-content-xl-stretch{-ms-flex-line-pack:stretch!important;align-content:stretch!important}.align-self-xl-auto{-ms-flex-item-align:auto!important;align-self:auto!important}.align-self-xl-start{-ms-flex-item-align:start!important;align-self:flex-start!important}.align-self-xl-end{-ms-flex-item-align:end!important;align-self:flex-end!important}.align-self-xl-center{-ms-flex-item-align:center!important;align-self:center!important}.align-self-xl-baseline{-ms-flex-item-align:baseline!important;align-self:baseline!important}.align-self-xl-stretch{-ms-flex-item-align:stretch!important;align-self:stretch!important}}.float-left{float:left!important}.float-right{float:right!important}.float-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;-ms-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;-ms-user-select:none!important;user-select:none!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}@supports ((position:-webkit-sticky) or (position:sticky)){.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;overflow:visible;clip:auto;white-space:normal}.shadow-sm{box-shadow:0 .125rem .25rem rgba(0,0,0,.075)!important}.shadow{box-shadow:0 .5rem 1rem rgba(0,0,0,.15)!important}.shadow-lg{box-shadow:0 1rem 3rem rgba(0,0,0,.175)!important}.shadow-none{box-shadow:none!important}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mw-100{max-width:100%!important}.mh-100{max-height:100%!important}.min-vw-100{min-width:100vw!important}.min-vh-100{min-height:100vh!important}.vw-100{width:100vw!important}.vh-100{height:100vh!important}.m-0{margin:0!important}.mt-0,.my-0{margin-top:0!important}.mr-0,.mx-0{margin-right:0!important}.mb-0,.my-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.m-1{margin:.25rem!important}.mt-1,.my-1{margin-top:.25rem!important}.mr-1,.mx-1{margin-right:.25rem!important}.mb-1,.my-1{margin-bottom:.25rem!important}.ml-1,.mx-1{margin-left:.25rem!important}.m-2{margin:.5rem!important}.mt-2,.my-2{margin-top:.5rem!important}.mr-2,.mx-2{margin-right:.5rem!important}.mb-2,.my-2{margin-bottom:.5rem!important}.ml-2,.mx-2{margin-left:.5rem!important}.m-3{margin:1rem!important}.mt-3,.my-3{margin-top:1rem!important}.mr-3,.mx-3{margin-right:1rem!important}.mb-3,.my-3{margin-bottom:1rem!important}.ml-3,.mx-3{margin-left:1rem!important}.m-4{margin:1.5rem!important}.mt-4,.my-4{margin-top:1.5rem!important}.mr-4,.mx-4{margin-right:1.5rem!important}.mb-4,.my-4{margin-bottom:1.5rem!important}.ml-4,.mx-4{margin-left:1.5rem!important}.m-5{margin:3rem!important}.mt-5,.my-5{margin-top:3rem!important}.mr-5,.mx-5{margin-right:3rem!important}.mb-5,.my-5{margin-bottom:3rem!important}.ml-5,.mx-5{margin-left:3rem!important}.p-0{padding:0!important}.pt-0,.py-0{padding-top:0!important}.pr-0,.px-0{padding-right:0!important}.pb-0,.py-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.p-1{padding:.25rem!important}.pt-1,.py-1{padding-top:.25rem!important}.pr-1,.px-1{padding-right:.25rem!important}.pb-1,.py-1{padding-bottom:.25rem!important}.pl-1,.px-1{padding-left:.25rem!important}.p-2{padding:.5rem!important}.pt-2,.py-2{padding-top:.5rem!important}.pr-2,.px-2{padding-right:.5rem!important}.pb-2,.py-2{padding-bottom:.5rem!important}.pl-2,.px-2{padding-left:.5rem!important}.p-3{padding:1rem!important}.pt-3,.py-3{padding-top:1rem!important}.pr-3,.px-3{padding-right:1rem!important}.pb-3,.py-3{padding-bottom:1rem!important}.pl-3,.px-3{padding-left:1rem!important}.p-4{padding:1.5rem!important}.pt-4,.py-4{padding-top:1.5rem!important}.pr-4,.px-4{padding-right:1.5rem!important}.pb-4,.py-4{padding-bottom:1.5rem!important}.pl-4,.px-4{padding-left:1.5rem!important}.p-5{padding:3rem!important}.pt-5,.py-5{padding-top:3rem!important}.pr-5,.px-5{padding-right:3rem!important}.pb-5,.py-5{padding-bottom:3rem!important}.pl-5,.px-5{padding-left:3rem!important}.m-n1{margin:-.25rem!important}.mt-n1,.my-n1{margin-top:-.25rem!important}.mr-n1,.mx-n1{margin-right:-.25rem!important}.mb-n1,.my-n1{margin-bottom:-.25rem!important}.ml-n1,.mx-n1{margin-left:-.25rem!important}.m-n2{margin:-.5rem!important}.mt-n2,.my-n2{margin-top:-.5rem!important}.mr-n2,.mx-n2{margin-right:-.5rem!important}.mb-n2,.my-n2{margin-bottom:-.5rem!important}.ml-n2,.mx-n2{margin-left:-.5rem!important}.m-n3{margin:-1rem!important}.mt-n3,.my-n3{margin-top:-1rem!important}.mr-n3,.mx-n3{margin-right:-1rem!important}.mb-n3,.my-n3{margin-bottom:-1rem!important}.ml-n3,.mx-n3{margin-left:-1rem!important}.m-n4{margin:-1.5rem!important}.mt-n4,.my-n4{margin-top:-1.5rem!important}.mr-n4,.mx-n4{margin-right:-1.5rem!important}.mb-n4,.my-n4{margin-bottom:-1.5rem!important}.ml-n4,.mx-n4{margin-left:-1.5rem!important}.m-n5{margin:-3rem!important}.mt-n5,.my-n5{margin-top:-3rem!important}.mr-n5,.mx-n5{margin-right:-3rem!important}.mb-n5,.my-n5{margin-bottom:-3rem!important}.ml-n5,.mx-n5{margin-left:-3rem!important}.m-auto{margin:auto!important}.mt-auto,.my-auto{margin-top:auto!important}.mr-auto,.mx-auto{margin-right:auto!important}.mb-auto,.my-auto{margin-bottom:auto!important}.ml-auto,.mx-auto{margin-left:auto!important}@media (min-width:576px){.m-sm-0{margin:0!important}.mt-sm-0,.my-sm-0{margin-top:0!important}.mr-sm-0,.mx-sm-0{margin-right:0!important}.mb-sm-0,.my-sm-0{margin-bottom:0!important}.ml-sm-0,.mx-sm-0{margin-left:0!important}.m-sm-1{margin:.25rem!important}.mt-sm-1,.my-sm-1{margin-top:.25rem!important}.mr-sm-1,.mx-sm-1{margin-right:.25rem!important}.mb-sm-1,.my-sm-1{margin-bottom:.25rem!important}.ml-sm-1,.mx-sm-1{margin-left:.25rem!important}.m-sm-2{margin:.5rem!important}.mt-sm-2,.my-sm-2{margin-top:.5rem!important}.mr-sm-2,.mx-sm-2{margin-right:.5rem!important}.mb-sm-2,.my-sm-2{margin-bottom:.5rem!important}.ml-sm-2,.mx-sm-2{margin-left:.5rem!important}.m-sm-3{margin:1rem!important}.mt-sm-3,.my-sm-3{margin-top:1rem!important}.mr-sm-3,.mx-sm-3{margin-right:1rem!important}.mb-sm-3,.my-sm-3{margin-bottom:1rem!important}.ml-sm-3,.mx-sm-3{margin-left:1rem!important}.m-sm-4{margin:1.5rem!important}.mt-sm-4,.my-sm-4{margin-top:1.5rem!important}.mr-sm-4,.mx-sm-4{margin-right:1.5rem!important}.mb-sm-4,.my-sm-4{margin-bottom:1.5rem!important}.ml-sm-4,.mx-sm-4{margin-left:1.5rem!important}.m-sm-5{margin:3rem!important}.mt-sm-5,.my-sm-5{margin-top:3rem!important}.mr-sm-5,.mx-sm-5{margin-right:3rem!important}.mb-sm-5,.my-sm-5{margin-bottom:3rem!important}.ml-sm-5,.mx-sm-5{margin-left:3rem!important}.p-sm-0{padding:0!important}.pt-sm-0,.py-sm-0{padding-top:0!important}.pr-sm-0,.px-sm-0{padding-right:0!important}.pb-sm-0,.py-sm-0{padding-bottom:0!important}.pl-sm-0,.px-sm-0{padding-left:0!important}.p-sm-1{padding:.25rem!important}.pt-sm-1,.py-sm-1{padding-top:.25rem!important}.pr-sm-1,.px-sm-1{padding-right:.25rem!important}.pb-sm-1,.py-sm-1{padding-bottom:.25rem!important}.pl-sm-1,.px-sm-1{padding-left:.25rem!important}.p-sm-2{padding:.5rem!important}.pt-sm-2,.py-sm-2{padding-top:.5rem!important}.pr-sm-2,.px-sm-2{padding-right:.5rem!important}.pb-sm-2,.py-sm-2{padding-bottom:.5rem!important}.pl-sm-2,.px-sm-2{padding-left:.5rem!important}.p-sm-3{padding:1rem!important}.pt-sm-3,.py-sm-3{padding-top:1rem!important}.pr-sm-3,.px-sm-3{padding-right:1rem!important}.pb-sm-3,.py-sm-3{padding-bottom:1rem!important}.pl-sm-3,.px-sm-3{padding-left:1rem!important}.p-sm-4{padding:1.5rem!important}.pt-sm-4,.py-sm-4{padding-top:1.5rem!important}.pr-sm-4,.px-sm-4{padding-right:1.5rem!important}.pb-sm-4,.py-sm-4{padding-bottom:1.5rem!important}.pl-sm-4,.px-sm-4{padding-left:1.5rem!important}.p-sm-5{padding:3rem!important}.pt-sm-5,.py-sm-5{padding-top:3rem!important}.pr-sm-5,.px-sm-5{padding-right:3rem!important}.pb-sm-5,.py-sm-5{padding-bottom:3rem!important}.pl-sm-5,.px-sm-5{padding-left:3rem!important}.m-sm-n1{margin:-.25rem!important}.mt-sm-n1,.my-sm-n1{margin-top:-.25rem!important}.mr-sm-n1,.mx-sm-n1{margin-right:-.25rem!important}.mb-sm-n1,.my-sm-n1{margin-bottom:-.25rem!important}.ml-sm-n1,.mx-sm-n1{margin-left:-.25rem!important}.m-sm-n2{margin:-.5rem!important}.mt-sm-n2,.my-sm-n2{margin-top:-.5rem!important}.mr-sm-n2,.mx-sm-n2{margin-right:-.5rem!important}.mb-sm-n2,.my-sm-n2{margin-bottom:-.5rem!important}.ml-sm-n2,.mx-sm-n2{margin-left:-.5rem!important}.m-sm-n3{margin:-1rem!important}.mt-sm-n3,.my-sm-n3{margin-top:-1rem!important}.mr-sm-n3,.mx-sm-n3{margin-right:-1rem!important}.mb-sm-n3,.my-sm-n3{margin-bottom:-1rem!important}.ml-sm-n3,.mx-sm-n3{margin-left:-1rem!important}.m-sm-n4{margin:-1.5rem!important}.mt-sm-n4,.my-sm-n4{margin-top:-1.5rem!important}.mr-sm-n4,.mx-sm-n4{margin-right:-1.5rem!important}.mb-sm-n4,.my-sm-n4{margin-bottom:-1.5rem!important}.ml-sm-n4,.mx-sm-n4{margin-left:-1.5rem!important}.m-sm-n5{margin:-3rem!important}.mt-sm-n5,.my-sm-n5{margin-top:-3rem!important}.mr-sm-n5,.mx-sm-n5{margin-right:-3rem!important}.mb-sm-n5,.my-sm-n5{margin-bottom:-3rem!important}.ml-sm-n5,.mx-sm-n5{margin-left:-3rem!important}.m-sm-auto{margin:auto!important}.mt-sm-auto,.my-sm-auto{margin-top:auto!important}.mr-sm-auto,.mx-sm-auto{margin-right:auto!important}.mb-sm-auto,.my-sm-auto{margin-bottom:auto!important}.ml-sm-auto,.mx-sm-auto{margin-left:auto!important}}@media (min-width:768px){.m-md-0{margin:0!important}.mt-md-0,.my-md-0{margin-top:0!important}.mr-md-0,.mx-md-0{margin-right:0!important}.mb-md-0,.my-md-0{margin-bottom:0!important}.ml-md-0,.mx-md-0{margin-left:0!important}.m-md-1{margin:.25rem!important}.mt-md-1,.my-md-1{margin-top:.25rem!important}.mr-md-1,.mx-md-1{margin-right:.25rem!important}.mb-md-1,.my-md-1{margin-bottom:.25rem!important}.ml-md-1,.mx-md-1{margin-left:.25rem!important}.m-md-2{margin:.5rem!important}.mt-md-2,.my-md-2{margin-top:.5rem!important}.mr-md-2,.mx-md-2{margin-right:.5rem!important}.mb-md-2,.my-md-2{margin-bottom:.5rem!important}.ml-md-2,.mx-md-2{margin-left:.5rem!important}.m-md-3{margin:1rem!important}.mt-md-3,.my-md-3{margin-top:1rem!important}.mr-md-3,.mx-md-3{margin-right:1rem!important}.mb-md-3,.my-md-3{margin-bottom:1rem!important}.ml-md-3,.mx-md-3{margin-left:1rem!important}.m-md-4{margin:1.5rem!important}.mt-md-4,.my-md-4{margin-top:1.5rem!important}.mr-md-4,.mx-md-4{margin-right:1.5rem!important}.mb-md-4,.my-md-4{margin-bottom:1.5rem!important}.ml-md-4,.mx-md-4{margin-left:1.5rem!important}.m-md-5{margin:3rem!important}.mt-md-5,.my-md-5{margin-top:3rem!important}.mr-md-5,.mx-md-5{margin-right:3rem!important}.mb-md-5,.my-md-5{margin-bottom:3rem!important}.ml-md-5,.mx-md-5{margin-left:3rem!important}.p-md-0{padding:0!important}.pt-md-0,.py-md-0{padding-top:0!important}.pr-md-0,.px-md-0{padding-right:0!important}.pb-md-0,.py-md-0{padding-bottom:0!important}.pl-md-0,.px-md-0{padding-left:0!important}.p-md-1{padding:.25rem!important}.pt-md-1,.py-md-1{padding-top:.25rem!important}.pr-md-1,.px-md-1{padding-right:.25rem!important}.pb-md-1,.py-md-1{padding-bottom:.25rem!important}.pl-md-1,.px-md-1{padding-left:.25rem!important}.p-md-2{padding:.5rem!important}.pt-md-2,.py-md-2{padding-top:.5rem!important}.pr-md-2,.px-md-2{padding-right:.5rem!important}.pb-md-2,.py-md-2{padding-bottom:.5rem!important}.pl-md-2,.px-md-2{padding-left:.5rem!important}.p-md-3{padding:1rem!important}.pt-md-3,.py-md-3{padding-top:1rem!important}.pr-md-3,.px-md-3{padding-right:1rem!important}.pb-md-3,.py-md-3{padding-bottom:1rem!important}.pl-md-3,.px-md-3{padding-left:1rem!important}.p-md-4{padding:1.5rem!important}.pt-md-4,.py-md-4{padding-top:1.5rem!important}.pr-md-4,.px-md-4{padding-right:1.5rem!important}.pb-md-4,.py-md-4{padding-bottom:1.5rem!important}.pl-md-4,.px-md-4{padding-left:1.5rem!important}.p-md-5{padding:3rem!important}.pt-md-5,.py-md-5{padding-top:3rem!important}.pr-md-5,.px-md-5{padding-right:3rem!important}.pb-md-5,.py-md-5{padding-bottom:3rem!important}.pl-md-5,.px-md-5{padding-left:3rem!important}.m-md-n1{margin:-.25rem!important}.mt-md-n1,.my-md-n1{margin-top:-.25rem!important}.mr-md-n1,.mx-md-n1{margin-right:-.25rem!important}.mb-md-n1,.my-md-n1{margin-bottom:-.25rem!important}.ml-md-n1,.mx-md-n1{margin-left:-.25rem!important}.m-md-n2{margin:-.5rem!important}.mt-md-n2,.my-md-n2{margin-top:-.5rem!important}.mr-md-n2,.mx-md-n2{margin-right:-.5rem!important}.mb-md-n2,.my-md-n2{margin-bottom:-.5rem!important}.ml-md-n2,.mx-md-n2{margin-left:-.5rem!important}.m-md-n3{margin:-1rem!important}.mt-md-n3,.my-md-n3{margin-top:-1rem!important}.mr-md-n3,.mx-md-n3{margin-right:-1rem!important}.mb-md-n3,.my-md-n3{margin-bottom:-1rem!important}.ml-md-n3,.mx-md-n3{margin-left:-1rem!important}.m-md-n4{margin:-1.5rem!important}.mt-md-n4,.my-md-n4{margin-top:-1.5rem!important}.mr-md-n4,.mx-md-n4{margin-right:-1.5rem!important}.mb-md-n4,.my-md-n4{margin-bottom:-1.5rem!important}.ml-md-n4,.mx-md-n4{margin-left:-1.5rem!important}.m-md-n5{margin:-3rem!important}.mt-md-n5,.my-md-n5{margin-top:-3rem!important}.mr-md-n5,.mx-md-n5{margin-right:-3rem!important}.mb-md-n5,.my-md-n5{margin-bottom:-3rem!important}.ml-md-n5,.mx-md-n5{margin-left:-3rem!important}.m-md-auto{margin:auto!important}.mt-md-auto,.my-md-auto{margin-top:auto!important}.mr-md-auto,.mx-md-auto{margin-right:auto!important}.mb-md-auto,.my-md-auto{margin-bottom:auto!important}.ml-md-auto,.mx-md-auto{margin-left:auto!important}}@media (min-width:992px){.m-lg-0{margin:0!important}.mt-lg-0,.my-lg-0{margin-top:0!important}.mr-lg-0,.mx-lg-0{margin-right:0!important}.mb-lg-0,.my-lg-0{margin-bottom:0!important}.ml-lg-0,.mx-lg-0{margin-left:0!important}.m-lg-1{margin:.25rem!important}.mt-lg-1,.my-lg-1{margin-top:.25rem!important}.mr-lg-1,.mx-lg-1{margin-right:.25rem!important}.mb-lg-1,.my-lg-1{margin-bottom:.25rem!important}.ml-lg-1,.mx-lg-1{margin-left:.25rem!important}.m-lg-2{margin:.5rem!important}.mt-lg-2,.my-lg-2{margin-top:.5rem!important}.mr-lg-2,.mx-lg-2{margin-right:.5rem!important}.mb-lg-2,.my-lg-2{margin-bottom:.5rem!important}.ml-lg-2,.mx-lg-2{margin-left:.5rem!important}.m-lg-3{margin:1rem!important}.mt-lg-3,.my-lg-3{margin-top:1rem!important}.mr-lg-3,.mx-lg-3{margin-right:1rem!important}.mb-lg-3,.my-lg-3{margin-bottom:1rem!important}.ml-lg-3,.mx-lg-3{margin-left:1rem!important}.m-lg-4{margin:1.5rem!important}.mt-lg-4,.my-lg-4{margin-top:1.5rem!important}.mr-lg-4,.mx-lg-4{margin-right:1.5rem!important}.mb-lg-4,.my-lg-4{margin-bottom:1.5rem!important}.ml-lg-4,.mx-lg-4{margin-left:1.5rem!important}.m-lg-5{margin:3rem!important}.mt-lg-5,.my-lg-5{margin-top:3rem!important}.mr-lg-5,.mx-lg-5{margin-right:3rem!important}.mb-lg-5,.my-lg-5{margin-bottom:3rem!important}.ml-lg-5,.mx-lg-5{margin-left:3rem!important}.p-lg-0{padding:0!important}.pt-lg-0,.py-lg-0{padding-top:0!important}.pr-lg-0,.px-lg-0{padding-right:0!important}.pb-lg-0,.py-lg-0{padding-bottom:0!important}.pl-lg-0,.px-lg-0{padding-left:0!important}.p-lg-1{padding:.25rem!important}.pt-lg-1,.py-lg-1{padding-top:.25rem!important}.pr-lg-1,.px-lg-1{padding-right:.25rem!important}.pb-lg-1,.py-lg-1{padding-bottom:.25rem!important}.pl-lg-1,.px-lg-1{padding-left:.25rem!important}.p-lg-2{padding:.5rem!important}.pt-lg-2,.py-lg-2{padding-top:.5rem!important}.pr-lg-2,.px-lg-2{padding-right:.5rem!important}.pb-lg-2,.py-lg-2{padding-bottom:.5rem!important}.pl-lg-2,.px-lg-2{padding-left:.5rem!important}.p-lg-3{padding:1rem!important}.pt-lg-3,.py-lg-3{padding-top:1rem!important}.pr-lg-3,.px-lg-3{padding-right:1rem!important}.pb-lg-3,.py-lg-3{padding-bottom:1rem!important}.pl-lg-3,.px-lg-3{padding-left:1rem!important}.p-lg-4{padding:1.5rem!important}.pt-lg-4,.py-lg-4{padding-top:1.5rem!important}.pr-lg-4,.px-lg-4{padding-right:1.5rem!important}.pb-lg-4,.py-lg-4{padding-bottom:1.5rem!important}.pl-lg-4,.px-lg-4{padding-left:1.5rem!important}.p-lg-5{padding:3rem!important}.pt-lg-5,.py-lg-5{padding-top:3rem!important}.pr-lg-5,.px-lg-5{padding-right:3rem!important}.pb-lg-5,.py-lg-5{padding-bottom:3rem!important}.pl-lg-5,.px-lg-5{padding-left:3rem!important}.m-lg-n1{margin:-.25rem!important}.mt-lg-n1,.my-lg-n1{margin-top:-.25rem!important}.mr-lg-n1,.mx-lg-n1{margin-right:-.25rem!important}.mb-lg-n1,.my-lg-n1{margin-bottom:-.25rem!important}.ml-lg-n1,.mx-lg-n1{margin-left:-.25rem!important}.m-lg-n2{margin:-.5rem!important}.mt-lg-n2,.my-lg-n2{margin-top:-.5rem!important}.mr-lg-n2,.mx-lg-n2{margin-right:-.5rem!important}.mb-lg-n2,.my-lg-n2{margin-bottom:-.5rem!important}.ml-lg-n2,.mx-lg-n2{margin-left:-.5rem!important}.m-lg-n3{margin:-1rem!important}.mt-lg-n3,.my-lg-n3{margin-top:-1rem!important}.mr-lg-n3,.mx-lg-n3{margin-right:-1rem!important}.mb-lg-n3,.my-lg-n3{margin-bottom:-1rem!important}.ml-lg-n3,.mx-lg-n3{margin-left:-1rem!important}.m-lg-n4{margin:-1.5rem!important}.mt-lg-n4,.my-lg-n4{margin-top:-1.5rem!important}.mr-lg-n4,.mx-lg-n4{margin-right:-1.5rem!important}.mb-lg-n4,.my-lg-n4{margin-bottom:-1.5rem!important}.ml-lg-n4,.mx-lg-n4{margin-left:-1.5rem!important}.m-lg-n5{margin:-3rem!important}.mt-lg-n5,.my-lg-n5{margin-top:-3rem!important}.mr-lg-n5,.mx-lg-n5{margin-right:-3rem!important}.mb-lg-n5,.my-lg-n5{margin-bottom:-3rem!important}.ml-lg-n5,.mx-lg-n5{margin-left:-3rem!important}.m-lg-auto{margin:auto!important}.mt-lg-auto,.my-lg-auto{margin-top:auto!important}.mr-lg-auto,.mx-lg-auto{margin-right:auto!important}.mb-lg-auto,.my-lg-auto{margin-bottom:auto!important}.ml-lg-auto,.mx-lg-auto{margin-left:auto!important}}@media (min-width:1200px){.m-xl-0{margin:0!important}.mt-xl-0,.my-xl-0{margin-top:0!important}.mr-xl-0,.mx-xl-0{margin-right:0!important}.mb-xl-0,.my-xl-0{margin-bottom:0!important}.ml-xl-0,.mx-xl-0{margin-left:0!important}.m-xl-1{margin:.25rem!important}.mt-xl-1,.my-xl-1{margin-top:.25rem!important}.mr-xl-1,.mx-xl-1{margin-right:.25rem!important}.mb-xl-1,.my-xl-1{margin-bottom:.25rem!important}.ml-xl-1,.mx-xl-1{margin-left:.25rem!important}.m-xl-2{margin:.5rem!important}.mt-xl-2,.my-xl-2{margin-top:.5rem!important}.mr-xl-2,.mx-xl-2{margin-right:.5rem!important}.mb-xl-2,.my-xl-2{margin-bottom:.5rem!important}.ml-xl-2,.mx-xl-2{margin-left:.5rem!important}.m-xl-3{margin:1rem!important}.mt-xl-3,.my-xl-3{margin-top:1rem!important}.mr-xl-3,.mx-xl-3{margin-right:1rem!important}.mb-xl-3,.my-xl-3{margin-bottom:1rem!important}.ml-xl-3,.mx-xl-3{margin-left:1rem!important}.m-xl-4{margin:1.5rem!important}.mt-xl-4,.my-xl-4{margin-top:1.5rem!important}.mr-xl-4,.mx-xl-4{margin-right:1.5rem!important}.mb-xl-4,.my-xl-4{margin-bottom:1.5rem!important}.ml-xl-4,.mx-xl-4{margin-left:1.5rem!important}.m-xl-5{margin:3rem!important}.mt-xl-5,.my-xl-5{margin-top:3rem!important}.mr-xl-5,.mx-xl-5{margin-right:3rem!important}.mb-xl-5,.my-xl-5{margin-bottom:3rem!important}.ml-xl-5,.mx-xl-5{margin-left:3rem!important}.p-xl-0{padding:0!important}.pt-xl-0,.py-xl-0{padding-top:0!important}.pr-xl-0,.px-xl-0{padding-right:0!important}.pb-xl-0,.py-xl-0{padding-bottom:0!important}.pl-xl-0,.px-xl-0{padding-left:0!important}.p-xl-1{padding:.25rem!important}.pt-xl-1,.py-xl-1{padding-top:.25rem!important}.pr-xl-1,.px-xl-1{padding-right:.25rem!important}.pb-xl-1,.py-xl-1{padding-bottom:.25rem!important}.pl-xl-1,.px-xl-1{padding-left:.25rem!important}.p-xl-2{padding:.5rem!important}.pt-xl-2,.py-xl-2{padding-top:.5rem!important}.pr-xl-2,.px-xl-2{padding-right:.5rem!important}.pb-xl-2,.py-xl-2{padding-bottom:.5rem!important}.pl-xl-2,.px-xl-2{padding-left:.5rem!important}.p-xl-3{padding:1rem!important}.pt-xl-3,.py-xl-3{padding-top:1rem!important}.pr-xl-3,.px-xl-3{padding-right:1rem!important}.pb-xl-3,.py-xl-3{padding-bottom:1rem!important}.pl-xl-3,.px-xl-3{padding-left:1rem!important}.p-xl-4{padding:1.5rem!important}.pt-xl-4,.py-xl-4{padding-top:1.5rem!important}.pr-xl-4,.px-xl-4{padding-right:1.5rem!important}.pb-xl-4,.py-xl-4{padding-bottom:1.5rem!important}.pl-xl-4,.px-xl-4{padding-left:1.5rem!important}.p-xl-5{padding:3rem!important}.pt-xl-5,.py-xl-5{padding-top:3rem!important}.pr-xl-5,.px-xl-5{padding-right:3rem!important}.pb-xl-5,.py-xl-5{padding-bottom:3rem!important}.pl-xl-5,.px-xl-5{padding-left:3rem!important}.m-xl-n1{margin:-.25rem!important}.mt-xl-n1,.my-xl-n1{margin-top:-.25rem!important}.mr-xl-n1,.mx-xl-n1{margin-right:-.25rem!important}.mb-xl-n1,.my-xl-n1{margin-bottom:-.25rem!important}.ml-xl-n1,.mx-xl-n1{margin-left:-.25rem!important}.m-xl-n2{margin:-.5rem!important}.mt-xl-n2,.my-xl-n2{margin-top:-.5rem!important}.mr-xl-n2,.mx-xl-n2{margin-right:-.5rem!important}.mb-xl-n2,.my-xl-n2{margin-bottom:-.5rem!important}.ml-xl-n2,.mx-xl-n2{margin-left:-.5rem!important}.m-xl-n3{margin:-1rem!important}.mt-xl-n3,.my-xl-n3{margin-top:-1rem!important}.mr-xl-n3,.mx-xl-n3{margin-right:-1rem!important}.mb-xl-n3,.my-xl-n3{margin-bottom:-1rem!important}.ml-xl-n3,.mx-xl-n3{margin-left:-1rem!important}.m-xl-n4{margin:-1.5rem!important}.mt-xl-n4,.my-xl-n4{margin-top:-1.5rem!important}.mr-xl-n4,.mx-xl-n4{margin-right:-1.5rem!important}.mb-xl-n4,.my-xl-n4{margin-bottom:-1.5rem!important}.ml-xl-n4,.mx-xl-n4{margin-left:-1.5rem!important}.m-xl-n5{margin:-3rem!important}.mt-xl-n5,.my-xl-n5{margin-top:-3rem!important}.mr-xl-n5,.mx-xl-n5{margin-right:-3rem!important}.mb-xl-n5,.my-xl-n5{margin-bottom:-3rem!important}.ml-xl-n5,.mx-xl-n5{margin-left:-3rem!important}.m-xl-auto{margin:auto!important}.mt-xl-auto,.my-xl-auto{margin-top:auto!important}.mr-xl-auto,.mx-xl-auto{margin-right:auto!important}.mb-xl-auto,.my-xl-auto{margin-bottom:auto!important}.ml-xl-auto,.mx-xl-auto{margin-left:auto!important}}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;pointer-events:auto;content:"";background-color:rgba(0,0,0,0)}.text-monospace{font-family:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace!important}.text-justify{text-align:justify!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-left{text-align:left!important}.text-right{text-align:right!important}.text-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-light{font-weight:300!important}.font-weight-lighter{font-weight:lighter!important}.font-weight-normal{font-weight:400!important}.font-weight-bold{font-weight:700!important}.font-weight-bolder{font-weight:bolder!important}.font-italic{font-style:italic!important}.text-white{color:#fff!important}.text-primary{color:#007bff!important}a.text-primary:focus,a.text-primary:hover{color:#0056b3!important}.text-secondary{color:#6c757d!important}a.text-secondary:focus,a.text-secondary:hover{color:#494f54!important}.text-success{color:#28a745!important}a.text-success:focus,a.text-success:hover{color:#19692c!important}.text-info{color:#17a2b8!important}a.text-info:focus,a.text-info:hover{color:#0f6674!important}.text-warning{color:#ffc107!important}a.text-warning:focus,a.text-warning:hover{color:#ba8b00!important}.text-danger{color:#dc3545!important}a.text-danger:focus,a.text-danger:hover{color:#a71d2a!important}.text-light{color:#f8f9fa!important}a.text-light:focus,a.text-light:hover{color:#cbd3da!important}.text-dark{color:#343a40!important}a.text-dark:focus,a.text-dark:hover{color:#121416!important}.text-body{color:#212529!important}.text-muted{color:#6c757d!important}.text-black-50{color:rgba(0,0,0,.5)!important}.text-white-50{color:rgba(255,255,255,.5)!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.text-decoration-none{text-decoration:none!important}.text-break{word-break:break-word!important;word-wrap:break-word!important}.text-reset{color:inherit!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}@media print{*,::after,::before{text-shadow:none!important;box-shadow:none!important}a:not(.btn){text-decoration:underline}abbr[title]::after{content:" (" attr(title) ")"}pre{white-space:pre-wrap!important}blockquote,pre{border:1px solid #adb5bd;page-break-inside:avoid}img,tr{page-break-inside:avoid}h2,h3,p{orphans:3;widows:3}h2,h3{page-break-after:avoid}@page{size:a3}body{min-width:992px!important}.container{min-width:992px!important}.navbar{display:none}.badge{border:1px solid #000}.table{border-collapse:collapse!important}.table td,.table th{background-color:#fff!important}.table-bordered td,.table-bordered th{border:1px solid #dee2e6!important}.table-dark{color:inherit}.table-dark tbody+tbody,.table-dark td,.table-dark th,.table-dark thead th{border-color:#dee2e6}.table .thead-dark th{color:inherit;border-color:#dee2e6}} + */:root,[data-bs-theme=light]{--bs-blue:#0d6efd;--bs-indigo:#6610f2;--bs-purple:#6f42c1;--bs-pink:#d63384;--bs-red:#dc3545;--bs-orange:#fd7e14;--bs-yellow:#ffc107;--bs-green:#198754;--bs-teal:#20c997;--bs-cyan:#0dcaf0;--bs-black:#000;--bs-white:#fff;--bs-gray:#6c757d;--bs-gray-dark:#343a40;--bs-gray-100:#f8f9fa;--bs-gray-200:#e9ecef;--bs-gray-300:#dee2e6;--bs-gray-400:#ced4da;--bs-gray-500:#adb5bd;--bs-gray-600:#6c757d;--bs-gray-700:#495057;--bs-gray-800:#343a40;--bs-gray-900:#212529;--bs-primary:#0d6efd;--bs-secondary:#6c757d;--bs-success:#198754;--bs-info:#0dcaf0;--bs-warning:#ffc107;--bs-danger:#dc3545;--bs-light:#f8f9fa;--bs-dark:#212529;--bs-primary-rgb:13,110,253;--bs-secondary-rgb:108,117,125;--bs-success-rgb:25,135,84;--bs-info-rgb:13,202,240;--bs-warning-rgb:255,193,7;--bs-danger-rgb:220,53,69;--bs-light-rgb:248,249,250;--bs-dark-rgb:33,37,41;--bs-primary-text-emphasis:#052c65;--bs-secondary-text-emphasis:#2b2f32;--bs-success-text-emphasis:#0a3622;--bs-info-text-emphasis:#055160;--bs-warning-text-emphasis:#664d03;--bs-danger-text-emphasis:#58151c;--bs-light-text-emphasis:#495057;--bs-dark-text-emphasis:#495057;--bs-primary-bg-subtle:#cfe2ff;--bs-secondary-bg-subtle:#e2e3e5;--bs-success-bg-subtle:#d1e7dd;--bs-info-bg-subtle:#cff4fc;--bs-warning-bg-subtle:#fff3cd;--bs-danger-bg-subtle:#f8d7da;--bs-light-bg-subtle:#fcfcfd;--bs-dark-bg-subtle:#ced4da;--bs-primary-border-subtle:#9ec5fe;--bs-secondary-border-subtle:#c4c8cb;--bs-success-border-subtle:#a3cfbb;--bs-info-border-subtle:#9eeaf9;--bs-warning-border-subtle:#ffe69c;--bs-danger-border-subtle:#f1aeb5;--bs-light-border-subtle:#e9ecef;--bs-dark-border-subtle:#adb5bd;--bs-white-rgb:255,255,255;--bs-black-rgb:0,0,0;--bs-font-sans-serif:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue","Noto Sans","Liberation Sans",Arial,sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";--bs-font-monospace:SFMono-Regular,Menlo,Monaco,Consolas,"Liberation Mono","Courier New",monospace;--bs-gradient:linear-gradient(180deg, rgba(255, 255, 255, 0.15), rgba(255, 255, 255, 0));--bs-body-font-family:var(--bs-font-sans-serif);--bs-body-font-size:1rem;--bs-body-font-weight:400;--bs-body-line-height:1.5;--bs-body-color:#212529;--bs-body-color-rgb:33,37,41;--bs-body-bg:#fff;--bs-body-bg-rgb:255,255,255;--bs-emphasis-color:#000;--bs-emphasis-color-rgb:0,0,0;--bs-secondary-color:rgba(33, 37, 41, 0.75);--bs-secondary-color-rgb:33,37,41;--bs-secondary-bg:#e9ecef;--bs-secondary-bg-rgb:233,236,239;--bs-tertiary-color:rgba(33, 37, 41, 0.5);--bs-tertiary-color-rgb:33,37,41;--bs-tertiary-bg:#f8f9fa;--bs-tertiary-bg-rgb:248,249,250;--bs-heading-color:inherit;--bs-link-color:#0d6efd;--bs-link-color-rgb:13,110,253;--bs-link-decoration:underline;--bs-link-hover-color:#0a58ca;--bs-link-hover-color-rgb:10,88,202;--bs-code-color:#d63384;--bs-highlight-color:#212529;--bs-highlight-bg:#fff3cd;--bs-border-width:1px;--bs-border-style:solid;--bs-border-color:#dee2e6;--bs-border-color-translucent:rgba(0, 0, 0, 0.175);--bs-border-radius:0.375rem;--bs-border-radius-sm:0.25rem;--bs-border-radius-lg:0.5rem;--bs-border-radius-xl:1rem;--bs-border-radius-xxl:2rem;--bs-border-radius-2xl:var(--bs-border-radius-xxl);--bs-border-radius-pill:50rem;--bs-box-shadow:0 0.5rem 1rem rgba(0, 0, 0, 0.15);--bs-box-shadow-sm:0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);--bs-box-shadow-lg:0 1rem 3rem rgba(0, 0, 0, 0.175);--bs-box-shadow-inset:inset 0 1px 2px rgba(0, 0, 0, 0.075);--bs-focus-ring-width:0.25rem;--bs-focus-ring-opacity:0.25;--bs-focus-ring-color:rgba(13, 110, 253, 0.25);--bs-form-valid-color:#198754;--bs-form-valid-border-color:#198754;--bs-form-invalid-color:#dc3545;--bs-form-invalid-border-color:#dc3545}[data-bs-theme=dark]{color-scheme:dark;--bs-body-color:#dee2e6;--bs-body-color-rgb:222,226,230;--bs-body-bg:#212529;--bs-body-bg-rgb:33,37,41;--bs-emphasis-color:#fff;--bs-emphasis-color-rgb:255,255,255;--bs-secondary-color:rgba(222, 226, 230, 0.75);--bs-secondary-color-rgb:222,226,230;--bs-secondary-bg:#343a40;--bs-secondary-bg-rgb:52,58,64;--bs-tertiary-color:rgba(222, 226, 230, 0.5);--bs-tertiary-color-rgb:222,226,230;--bs-tertiary-bg:#2b3035;--bs-tertiary-bg-rgb:43,48,53;--bs-primary-text-emphasis:#6ea8fe;--bs-secondary-text-emphasis:#a7acb1;--bs-success-text-emphasis:#75b798;--bs-info-text-emphasis:#6edff6;--bs-warning-text-emphasis:#ffda6a;--bs-danger-text-emphasis:#ea868f;--bs-light-text-emphasis:#f8f9fa;--bs-dark-text-emphasis:#dee2e6;--bs-primary-bg-subtle:#031633;--bs-secondary-bg-subtle:#161719;--bs-success-bg-subtle:#051b11;--bs-info-bg-subtle:#032830;--bs-warning-bg-subtle:#332701;--bs-danger-bg-subtle:#2c0b0e;--bs-light-bg-subtle:#343a40;--bs-dark-bg-subtle:#1a1d20;--bs-primary-border-subtle:#084298;--bs-secondary-border-subtle:#41464b;--bs-success-border-subtle:#0f5132;--bs-info-border-subtle:#087990;--bs-warning-border-subtle:#997404;--bs-danger-border-subtle:#842029;--bs-light-border-subtle:#495057;--bs-dark-border-subtle:#343a40;--bs-heading-color:inherit;--bs-link-color:#6ea8fe;--bs-link-hover-color:#8bb9fe;--bs-link-color-rgb:110,168,254;--bs-link-hover-color-rgb:139,185,254;--bs-code-color:#e685b5;--bs-highlight-color:#dee2e6;--bs-highlight-bg:#664d03;--bs-border-color:#495057;--bs-border-color-translucent:rgba(255, 255, 255, 0.15);--bs-form-valid-color:#75b798;--bs-form-valid-border-color:#75b798;--bs-form-invalid-color:#ea868f;--bs-form-invalid-border-color:#ea868f}*,::after,::before{box-sizing:border-box}@media (prefers-reduced-motion:no-preference){:root{scroll-behavior:smooth}}body{margin:0;font-family:var(--bs-body-font-family);font-size:var(--bs-body-font-size);font-weight:var(--bs-body-font-weight);line-height:var(--bs-body-line-height);color:var(--bs-body-color);text-align:var(--bs-body-text-align);background-color:var(--bs-body-bg);-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}hr{margin:1rem 0;color:inherit;border:0;border-top:var(--bs-border-width) solid;opacity:.25}.h1,.h2,.h3,.h4,.h5,.h6,h1,h2,h3,h4,h5,h6{margin-top:0;margin-bottom:.5rem;font-weight:500;line-height:1.2;color:var(--bs-heading-color)}.h1,h1{font-size:calc(1.375rem + 1.5vw)}@media (min-width:1200px){.h1,h1{font-size:2.5rem}}.h2,h2{font-size:calc(1.325rem + .9vw)}@media (min-width:1200px){.h2,h2{font-size:2rem}}.h3,h3{font-size:calc(1.3rem + .6vw)}@media (min-width:1200px){.h3,h3{font-size:1.75rem}}.h4,h4{font-size:calc(1.275rem + .3vw)}@media (min-width:1200px){.h4,h4{font-size:1.5rem}}.h5,h5{font-size:1.25rem}.h6,h6{font-size:1rem}p{margin-top:0;margin-bottom:1rem}abbr[title]{-webkit-text-decoration:underline dotted;text-decoration:underline dotted;cursor:help;-webkit-text-decoration-skip-ink:none;text-decoration-skip-ink:none}address{margin-bottom:1rem;font-style:normal;line-height:inherit}ol,ul{padding-left:2rem}dl,ol,ul{margin-top:0;margin-bottom:1rem}ol ol,ol ul,ul ol,ul ul{margin-bottom:0}dt{font-weight:700}dd{margin-bottom:.5rem;margin-left:0}blockquote{margin:0 0 1rem}b,strong{font-weight:bolder}.small,small{font-size:.875em}.mark,mark{padding:.1875em;color:var(--bs-highlight-color);background-color:var(--bs-highlight-bg)}sub,sup{position:relative;font-size:.75em;line-height:0;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}a{color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,1));text-decoration:underline}a:hover{--bs-link-color-rgb:var(--bs-link-hover-color-rgb)}a:not([href]):not([class]),a:not([href]):not([class]):hover{color:inherit;text-decoration:none}code,kbd,pre,samp{font-family:var(--bs-font-monospace);font-size:1em}pre{display:block;margin-top:0;margin-bottom:1rem;overflow:auto;font-size:.875em}pre code{font-size:inherit;color:inherit;word-break:normal}code{font-size:.875em;color:var(--bs-code-color);word-wrap:break-word}a>code{color:inherit}kbd{padding:.1875rem .375rem;font-size:.875em;color:var(--bs-body-bg);background-color:var(--bs-body-color);border-radius:.25rem}kbd kbd{padding:0;font-size:1em}figure{margin:0 0 1rem}img,svg{vertical-align:middle}table{caption-side:bottom;border-collapse:collapse}caption{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-secondary-color);text-align:left}th{text-align:inherit;text-align:-webkit-match-parent}tbody,td,tfoot,th,thead,tr{border-color:inherit;border-style:solid;border-width:0}label{display:inline-block}button{border-radius:0}button:focus:not(:focus-visible){outline:0}button,input,optgroup,select,textarea{margin:0;font-family:inherit;font-size:inherit;line-height:inherit}button,select{text-transform:none}[role=button]{cursor:pointer}select{word-wrap:normal}select:disabled{opacity:1}[list]:not([type=date]):not([type=datetime-local]):not([type=month]):not([type=week]):not([type=time])::-webkit-calendar-picker-indicator{display:none!important}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button}[type=button]:not(:disabled),[type=reset]:not(:disabled),[type=submit]:not(:disabled),button:not(:disabled){cursor:pointer}::-moz-focus-inner{padding:0;border-style:none}textarea{resize:vertical}fieldset{min-width:0;padding:0;margin:0;border:0}legend{float:left;width:100%;padding:0;margin-bottom:.5rem;font-size:calc(1.275rem + .3vw);line-height:inherit}@media (min-width:1200px){legend{font-size:1.5rem}}legend+*{clear:left}::-webkit-datetime-edit-day-field,::-webkit-datetime-edit-fields-wrapper,::-webkit-datetime-edit-hour-field,::-webkit-datetime-edit-minute,::-webkit-datetime-edit-month-field,::-webkit-datetime-edit-text,::-webkit-datetime-edit-year-field{padding:0}::-webkit-inner-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-color-swatch-wrapper{padding:0}::-webkit-file-upload-button{font:inherit;-webkit-appearance:button}::file-selector-button{font:inherit;-webkit-appearance:button}output{display:inline-block}iframe{border:0}summary{display:list-item;cursor:pointer}progress{vertical-align:baseline}[hidden]{display:none!important}.lead{font-size:1.25rem;font-weight:300}.display-1{font-size:calc(1.625rem + 4.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-1{font-size:5rem}}.display-2{font-size:calc(1.575rem + 3.9vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-2{font-size:4.5rem}}.display-3{font-size:calc(1.525rem + 3.3vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-3{font-size:4rem}}.display-4{font-size:calc(1.475rem + 2.7vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-4{font-size:3.5rem}}.display-5{font-size:calc(1.425rem + 2.1vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-5{font-size:3rem}}.display-6{font-size:calc(1.375rem + 1.5vw);font-weight:300;line-height:1.2}@media (min-width:1200px){.display-6{font-size:2.5rem}}.list-unstyled{padding-left:0;list-style:none}.list-inline{padding-left:0;list-style:none}.list-inline-item{display:inline-block}.list-inline-item:not(:last-child){margin-right:.5rem}.initialism{font-size:.875em;text-transform:uppercase}.blockquote{margin-bottom:1rem;font-size:1.25rem}.blockquote>:last-child{margin-bottom:0}.blockquote-footer{margin-top:-1rem;margin-bottom:1rem;font-size:.875em;color:#6c757d}.blockquote-footer::before{content:"— "}.img-fluid{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:var(--bs-body-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);max-width:100%;height:auto}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:.875em;color:var(--bs-secondary-color)}.container,.container-fluid,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{--bs-gutter-x:1.5rem;--bs-gutter-y:0;width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-right:auto;margin-left:auto}@media (min-width:576px){.container,.container-sm{max-width:540px}}@media (min-width:768px){.container,.container-md,.container-sm{max-width:720px}}@media (min-width:992px){.container,.container-lg,.container-md,.container-sm{max-width:960px}}@media (min-width:1200px){.container,.container-lg,.container-md,.container-sm,.container-xl{max-width:1140px}}@media (min-width:1400px){.container,.container-lg,.container-md,.container-sm,.container-xl,.container-xxl{max-width:1320px}}:root{--bs-breakpoint-xs:0;--bs-breakpoint-sm:576px;--bs-breakpoint-md:768px;--bs-breakpoint-lg:992px;--bs-breakpoint-xl:1200px;--bs-breakpoint-xxl:1400px}.row{--bs-gutter-x:1.5rem;--bs-gutter-y:0;display:flex;flex-wrap:wrap;margin-top:calc(-1 * var(--bs-gutter-y));margin-right:calc(-.5 * var(--bs-gutter-x));margin-left:calc(-.5 * var(--bs-gutter-x))}.row>*{flex-shrink:0;width:100%;max-width:100%;padding-right:calc(var(--bs-gutter-x) * .5);padding-left:calc(var(--bs-gutter-x) * .5);margin-top:var(--bs-gutter-y)}.col{flex:1 0 0%}.row-cols-auto>*{flex:0 0 auto;width:auto}.row-cols-1>*{flex:0 0 auto;width:100%}.row-cols-2>*{flex:0 0 auto;width:50%}.row-cols-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-4>*{flex:0 0 auto;width:25%}.row-cols-5>*{flex:0 0 auto;width:20%}.row-cols-6>*{flex:0 0 auto;width:16.66666667%}.col-auto{flex:0 0 auto;width:auto}.col-1{flex:0 0 auto;width:8.33333333%}.col-2{flex:0 0 auto;width:16.66666667%}.col-3{flex:0 0 auto;width:25%}.col-4{flex:0 0 auto;width:33.33333333%}.col-5{flex:0 0 auto;width:41.66666667%}.col-6{flex:0 0 auto;width:50%}.col-7{flex:0 0 auto;width:58.33333333%}.col-8{flex:0 0 auto;width:66.66666667%}.col-9{flex:0 0 auto;width:75%}.col-10{flex:0 0 auto;width:83.33333333%}.col-11{flex:0 0 auto;width:91.66666667%}.col-12{flex:0 0 auto;width:100%}.offset-1{margin-left:8.33333333%}.offset-2{margin-left:16.66666667%}.offset-3{margin-left:25%}.offset-4{margin-left:33.33333333%}.offset-5{margin-left:41.66666667%}.offset-6{margin-left:50%}.offset-7{margin-left:58.33333333%}.offset-8{margin-left:66.66666667%}.offset-9{margin-left:75%}.offset-10{margin-left:83.33333333%}.offset-11{margin-left:91.66666667%}.g-0,.gx-0{--bs-gutter-x:0}.g-0,.gy-0{--bs-gutter-y:0}.g-1,.gx-1{--bs-gutter-x:0.25rem}.g-1,.gy-1{--bs-gutter-y:0.25rem}.g-2,.gx-2{--bs-gutter-x:0.5rem}.g-2,.gy-2{--bs-gutter-y:0.5rem}.g-3,.gx-3{--bs-gutter-x:1rem}.g-3,.gy-3{--bs-gutter-y:1rem}.g-4,.gx-4{--bs-gutter-x:1.5rem}.g-4,.gy-4{--bs-gutter-y:1.5rem}.g-5,.gx-5{--bs-gutter-x:3rem}.g-5,.gy-5{--bs-gutter-y:3rem}@media (min-width:576px){.col-sm{flex:1 0 0%}.row-cols-sm-auto>*{flex:0 0 auto;width:auto}.row-cols-sm-1>*{flex:0 0 auto;width:100%}.row-cols-sm-2>*{flex:0 0 auto;width:50%}.row-cols-sm-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-sm-4>*{flex:0 0 auto;width:25%}.row-cols-sm-5>*{flex:0 0 auto;width:20%}.row-cols-sm-6>*{flex:0 0 auto;width:16.66666667%}.col-sm-auto{flex:0 0 auto;width:auto}.col-sm-1{flex:0 0 auto;width:8.33333333%}.col-sm-2{flex:0 0 auto;width:16.66666667%}.col-sm-3{flex:0 0 auto;width:25%}.col-sm-4{flex:0 0 auto;width:33.33333333%}.col-sm-5{flex:0 0 auto;width:41.66666667%}.col-sm-6{flex:0 0 auto;width:50%}.col-sm-7{flex:0 0 auto;width:58.33333333%}.col-sm-8{flex:0 0 auto;width:66.66666667%}.col-sm-9{flex:0 0 auto;width:75%}.col-sm-10{flex:0 0 auto;width:83.33333333%}.col-sm-11{flex:0 0 auto;width:91.66666667%}.col-sm-12{flex:0 0 auto;width:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333333%}.offset-sm-2{margin-left:16.66666667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333333%}.offset-sm-5{margin-left:41.66666667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333333%}.offset-sm-8{margin-left:66.66666667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333333%}.offset-sm-11{margin-left:91.66666667%}.g-sm-0,.gx-sm-0{--bs-gutter-x:0}.g-sm-0,.gy-sm-0{--bs-gutter-y:0}.g-sm-1,.gx-sm-1{--bs-gutter-x:0.25rem}.g-sm-1,.gy-sm-1{--bs-gutter-y:0.25rem}.g-sm-2,.gx-sm-2{--bs-gutter-x:0.5rem}.g-sm-2,.gy-sm-2{--bs-gutter-y:0.5rem}.g-sm-3,.gx-sm-3{--bs-gutter-x:1rem}.g-sm-3,.gy-sm-3{--bs-gutter-y:1rem}.g-sm-4,.gx-sm-4{--bs-gutter-x:1.5rem}.g-sm-4,.gy-sm-4{--bs-gutter-y:1.5rem}.g-sm-5,.gx-sm-5{--bs-gutter-x:3rem}.g-sm-5,.gy-sm-5{--bs-gutter-y:3rem}}@media (min-width:768px){.col-md{flex:1 0 0%}.row-cols-md-auto>*{flex:0 0 auto;width:auto}.row-cols-md-1>*{flex:0 0 auto;width:100%}.row-cols-md-2>*{flex:0 0 auto;width:50%}.row-cols-md-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-md-4>*{flex:0 0 auto;width:25%}.row-cols-md-5>*{flex:0 0 auto;width:20%}.row-cols-md-6>*{flex:0 0 auto;width:16.66666667%}.col-md-auto{flex:0 0 auto;width:auto}.col-md-1{flex:0 0 auto;width:8.33333333%}.col-md-2{flex:0 0 auto;width:16.66666667%}.col-md-3{flex:0 0 auto;width:25%}.col-md-4{flex:0 0 auto;width:33.33333333%}.col-md-5{flex:0 0 auto;width:41.66666667%}.col-md-6{flex:0 0 auto;width:50%}.col-md-7{flex:0 0 auto;width:58.33333333%}.col-md-8{flex:0 0 auto;width:66.66666667%}.col-md-9{flex:0 0 auto;width:75%}.col-md-10{flex:0 0 auto;width:83.33333333%}.col-md-11{flex:0 0 auto;width:91.66666667%}.col-md-12{flex:0 0 auto;width:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333333%}.offset-md-2{margin-left:16.66666667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333333%}.offset-md-5{margin-left:41.66666667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333333%}.offset-md-8{margin-left:66.66666667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333333%}.offset-md-11{margin-left:91.66666667%}.g-md-0,.gx-md-0{--bs-gutter-x:0}.g-md-0,.gy-md-0{--bs-gutter-y:0}.g-md-1,.gx-md-1{--bs-gutter-x:0.25rem}.g-md-1,.gy-md-1{--bs-gutter-y:0.25rem}.g-md-2,.gx-md-2{--bs-gutter-x:0.5rem}.g-md-2,.gy-md-2{--bs-gutter-y:0.5rem}.g-md-3,.gx-md-3{--bs-gutter-x:1rem}.g-md-3,.gy-md-3{--bs-gutter-y:1rem}.g-md-4,.gx-md-4{--bs-gutter-x:1.5rem}.g-md-4,.gy-md-4{--bs-gutter-y:1.5rem}.g-md-5,.gx-md-5{--bs-gutter-x:3rem}.g-md-5,.gy-md-5{--bs-gutter-y:3rem}}@media (min-width:992px){.col-lg{flex:1 0 0%}.row-cols-lg-auto>*{flex:0 0 auto;width:auto}.row-cols-lg-1>*{flex:0 0 auto;width:100%}.row-cols-lg-2>*{flex:0 0 auto;width:50%}.row-cols-lg-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-lg-4>*{flex:0 0 auto;width:25%}.row-cols-lg-5>*{flex:0 0 auto;width:20%}.row-cols-lg-6>*{flex:0 0 auto;width:16.66666667%}.col-lg-auto{flex:0 0 auto;width:auto}.col-lg-1{flex:0 0 auto;width:8.33333333%}.col-lg-2{flex:0 0 auto;width:16.66666667%}.col-lg-3{flex:0 0 auto;width:25%}.col-lg-4{flex:0 0 auto;width:33.33333333%}.col-lg-5{flex:0 0 auto;width:41.66666667%}.col-lg-6{flex:0 0 auto;width:50%}.col-lg-7{flex:0 0 auto;width:58.33333333%}.col-lg-8{flex:0 0 auto;width:66.66666667%}.col-lg-9{flex:0 0 auto;width:75%}.col-lg-10{flex:0 0 auto;width:83.33333333%}.col-lg-11{flex:0 0 auto;width:91.66666667%}.col-lg-12{flex:0 0 auto;width:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333333%}.offset-lg-2{margin-left:16.66666667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333333%}.offset-lg-5{margin-left:41.66666667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333333%}.offset-lg-8{margin-left:66.66666667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333333%}.offset-lg-11{margin-left:91.66666667%}.g-lg-0,.gx-lg-0{--bs-gutter-x:0}.g-lg-0,.gy-lg-0{--bs-gutter-y:0}.g-lg-1,.gx-lg-1{--bs-gutter-x:0.25rem}.g-lg-1,.gy-lg-1{--bs-gutter-y:0.25rem}.g-lg-2,.gx-lg-2{--bs-gutter-x:0.5rem}.g-lg-2,.gy-lg-2{--bs-gutter-y:0.5rem}.g-lg-3,.gx-lg-3{--bs-gutter-x:1rem}.g-lg-3,.gy-lg-3{--bs-gutter-y:1rem}.g-lg-4,.gx-lg-4{--bs-gutter-x:1.5rem}.g-lg-4,.gy-lg-4{--bs-gutter-y:1.5rem}.g-lg-5,.gx-lg-5{--bs-gutter-x:3rem}.g-lg-5,.gy-lg-5{--bs-gutter-y:3rem}}@media (min-width:1200px){.col-xl{flex:1 0 0%}.row-cols-xl-auto>*{flex:0 0 auto;width:auto}.row-cols-xl-1>*{flex:0 0 auto;width:100%}.row-cols-xl-2>*{flex:0 0 auto;width:50%}.row-cols-xl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xl-4>*{flex:0 0 auto;width:25%}.row-cols-xl-5>*{flex:0 0 auto;width:20%}.row-cols-xl-6>*{flex:0 0 auto;width:16.66666667%}.col-xl-auto{flex:0 0 auto;width:auto}.col-xl-1{flex:0 0 auto;width:8.33333333%}.col-xl-2{flex:0 0 auto;width:16.66666667%}.col-xl-3{flex:0 0 auto;width:25%}.col-xl-4{flex:0 0 auto;width:33.33333333%}.col-xl-5{flex:0 0 auto;width:41.66666667%}.col-xl-6{flex:0 0 auto;width:50%}.col-xl-7{flex:0 0 auto;width:58.33333333%}.col-xl-8{flex:0 0 auto;width:66.66666667%}.col-xl-9{flex:0 0 auto;width:75%}.col-xl-10{flex:0 0 auto;width:83.33333333%}.col-xl-11{flex:0 0 auto;width:91.66666667%}.col-xl-12{flex:0 0 auto;width:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333333%}.offset-xl-2{margin-left:16.66666667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333333%}.offset-xl-5{margin-left:41.66666667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333333%}.offset-xl-8{margin-left:66.66666667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333333%}.offset-xl-11{margin-left:91.66666667%}.g-xl-0,.gx-xl-0{--bs-gutter-x:0}.g-xl-0,.gy-xl-0{--bs-gutter-y:0}.g-xl-1,.gx-xl-1{--bs-gutter-x:0.25rem}.g-xl-1,.gy-xl-1{--bs-gutter-y:0.25rem}.g-xl-2,.gx-xl-2{--bs-gutter-x:0.5rem}.g-xl-2,.gy-xl-2{--bs-gutter-y:0.5rem}.g-xl-3,.gx-xl-3{--bs-gutter-x:1rem}.g-xl-3,.gy-xl-3{--bs-gutter-y:1rem}.g-xl-4,.gx-xl-4{--bs-gutter-x:1.5rem}.g-xl-4,.gy-xl-4{--bs-gutter-y:1.5rem}.g-xl-5,.gx-xl-5{--bs-gutter-x:3rem}.g-xl-5,.gy-xl-5{--bs-gutter-y:3rem}}@media (min-width:1400px){.col-xxl{flex:1 0 0%}.row-cols-xxl-auto>*{flex:0 0 auto;width:auto}.row-cols-xxl-1>*{flex:0 0 auto;width:100%}.row-cols-xxl-2>*{flex:0 0 auto;width:50%}.row-cols-xxl-3>*{flex:0 0 auto;width:33.33333333%}.row-cols-xxl-4>*{flex:0 0 auto;width:25%}.row-cols-xxl-5>*{flex:0 0 auto;width:20%}.row-cols-xxl-6>*{flex:0 0 auto;width:16.66666667%}.col-xxl-auto{flex:0 0 auto;width:auto}.col-xxl-1{flex:0 0 auto;width:8.33333333%}.col-xxl-2{flex:0 0 auto;width:16.66666667%}.col-xxl-3{flex:0 0 auto;width:25%}.col-xxl-4{flex:0 0 auto;width:33.33333333%}.col-xxl-5{flex:0 0 auto;width:41.66666667%}.col-xxl-6{flex:0 0 auto;width:50%}.col-xxl-7{flex:0 0 auto;width:58.33333333%}.col-xxl-8{flex:0 0 auto;width:66.66666667%}.col-xxl-9{flex:0 0 auto;width:75%}.col-xxl-10{flex:0 0 auto;width:83.33333333%}.col-xxl-11{flex:0 0 auto;width:91.66666667%}.col-xxl-12{flex:0 0 auto;width:100%}.offset-xxl-0{margin-left:0}.offset-xxl-1{margin-left:8.33333333%}.offset-xxl-2{margin-left:16.66666667%}.offset-xxl-3{margin-left:25%}.offset-xxl-4{margin-left:33.33333333%}.offset-xxl-5{margin-left:41.66666667%}.offset-xxl-6{margin-left:50%}.offset-xxl-7{margin-left:58.33333333%}.offset-xxl-8{margin-left:66.66666667%}.offset-xxl-9{margin-left:75%}.offset-xxl-10{margin-left:83.33333333%}.offset-xxl-11{margin-left:91.66666667%}.g-xxl-0,.gx-xxl-0{--bs-gutter-x:0}.g-xxl-0,.gy-xxl-0{--bs-gutter-y:0}.g-xxl-1,.gx-xxl-1{--bs-gutter-x:0.25rem}.g-xxl-1,.gy-xxl-1{--bs-gutter-y:0.25rem}.g-xxl-2,.gx-xxl-2{--bs-gutter-x:0.5rem}.g-xxl-2,.gy-xxl-2{--bs-gutter-y:0.5rem}.g-xxl-3,.gx-xxl-3{--bs-gutter-x:1rem}.g-xxl-3,.gy-xxl-3{--bs-gutter-y:1rem}.g-xxl-4,.gx-xxl-4{--bs-gutter-x:1.5rem}.g-xxl-4,.gy-xxl-4{--bs-gutter-y:1.5rem}.g-xxl-5,.gx-xxl-5{--bs-gutter-x:3rem}.g-xxl-5,.gy-xxl-5{--bs-gutter-y:3rem}}.table{--bs-table-color-type:initial;--bs-table-bg-type:initial;--bs-table-color-state:initial;--bs-table-bg-state:initial;--bs-table-color:var(--bs-emphasis-color);--bs-table-bg:var(--bs-body-bg);--bs-table-border-color:var(--bs-border-color);--bs-table-accent-bg:transparent;--bs-table-striped-color:var(--bs-emphasis-color);--bs-table-striped-bg:rgba(var(--bs-emphasis-color-rgb), 0.05);--bs-table-active-color:var(--bs-emphasis-color);--bs-table-active-bg:rgba(var(--bs-emphasis-color-rgb), 0.1);--bs-table-hover-color:var(--bs-emphasis-color);--bs-table-hover-bg:rgba(var(--bs-emphasis-color-rgb), 0.075);width:100%;margin-bottom:1rem;vertical-align:top;border-color:var(--bs-table-border-color)}.table>:not(caption)>*>*{padding:.5rem .5rem;color:var(--bs-table-color-state,var(--bs-table-color-type,var(--bs-table-color)));background-color:var(--bs-table-bg);border-bottom-width:var(--bs-border-width);box-shadow:inset 0 0 0 9999px var(--bs-table-bg-state,var(--bs-table-bg-type,var(--bs-table-accent-bg)))}.table>tbody{vertical-align:inherit}.table>thead{vertical-align:bottom}.table-group-divider{border-top:calc(var(--bs-border-width) * 2) solid currentcolor}.caption-top{caption-side:top}.table-sm>:not(caption)>*>*{padding:.25rem .25rem}.table-bordered>:not(caption)>*{border-width:var(--bs-border-width) 0}.table-bordered>:not(caption)>*>*{border-width:0 var(--bs-border-width)}.table-borderless>:not(caption)>*>*{border-bottom-width:0}.table-borderless>:not(:first-child){border-top-width:0}.table-striped>tbody>tr:nth-of-type(odd)>*{--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-striped-columns>:not(caption)>tr>:nth-child(2n){--bs-table-color-type:var(--bs-table-striped-color);--bs-table-bg-type:var(--bs-table-striped-bg)}.table-active{--bs-table-color-state:var(--bs-table-active-color);--bs-table-bg-state:var(--bs-table-active-bg)}.table-hover>tbody>tr:hover>*{--bs-table-color-state:var(--bs-table-hover-color);--bs-table-bg-state:var(--bs-table-hover-bg)}.table-primary{--bs-table-color:#000;--bs-table-bg:#cfe2ff;--bs-table-border-color:#a6b5cc;--bs-table-striped-bg:#c5d7f2;--bs-table-striped-color:#000;--bs-table-active-bg:#bacbe6;--bs-table-active-color:#000;--bs-table-hover-bg:#bfd1ec;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-secondary{--bs-table-color:#000;--bs-table-bg:#e2e3e5;--bs-table-border-color:#b5b6b7;--bs-table-striped-bg:#d7d8da;--bs-table-striped-color:#000;--bs-table-active-bg:#cbccce;--bs-table-active-color:#000;--bs-table-hover-bg:#d1d2d4;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-success{--bs-table-color:#000;--bs-table-bg:#d1e7dd;--bs-table-border-color:#a7b9b1;--bs-table-striped-bg:#c7dbd2;--bs-table-striped-color:#000;--bs-table-active-bg:#bcd0c7;--bs-table-active-color:#000;--bs-table-hover-bg:#c1d6cc;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-info{--bs-table-color:#000;--bs-table-bg:#cff4fc;--bs-table-border-color:#a6c3ca;--bs-table-striped-bg:#c5e8ef;--bs-table-striped-color:#000;--bs-table-active-bg:#badce3;--bs-table-active-color:#000;--bs-table-hover-bg:#bfe2e9;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-warning{--bs-table-color:#000;--bs-table-bg:#fff3cd;--bs-table-border-color:#ccc2a4;--bs-table-striped-bg:#f2e7c3;--bs-table-striped-color:#000;--bs-table-active-bg:#e6dbb9;--bs-table-active-color:#000;--bs-table-hover-bg:#ece1be;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-danger{--bs-table-color:#000;--bs-table-bg:#f8d7da;--bs-table-border-color:#c6acae;--bs-table-striped-bg:#eccccf;--bs-table-striped-color:#000;--bs-table-active-bg:#dfc2c4;--bs-table-active-color:#000;--bs-table-hover-bg:#e5c7ca;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-light{--bs-table-color:#000;--bs-table-bg:#f8f9fa;--bs-table-border-color:#c6c7c8;--bs-table-striped-bg:#ecedee;--bs-table-striped-color:#000;--bs-table-active-bg:#dfe0e1;--bs-table-active-color:#000;--bs-table-hover-bg:#e5e6e7;--bs-table-hover-color:#000;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-dark{--bs-table-color:#fff;--bs-table-bg:#212529;--bs-table-border-color:#4d5154;--bs-table-striped-bg:#2c3034;--bs-table-striped-color:#fff;--bs-table-active-bg:#373b3e;--bs-table-active-color:#fff;--bs-table-hover-bg:#323539;--bs-table-hover-color:#fff;color:var(--bs-table-color);border-color:var(--bs-table-border-color)}.table-responsive{overflow-x:auto;-webkit-overflow-scrolling:touch}@media (max-width:575.98px){.table-responsive-sm{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:767.98px){.table-responsive-md{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:991.98px){.table-responsive-lg{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1199.98px){.table-responsive-xl{overflow-x:auto;-webkit-overflow-scrolling:touch}}@media (max-width:1399.98px){.table-responsive-xxl{overflow-x:auto;-webkit-overflow-scrolling:touch}}.form-label{margin-bottom:.5rem}.col-form-label{padding-top:calc(.375rem + var(--bs-border-width));padding-bottom:calc(.375rem + var(--bs-border-width));margin-bottom:0;font-size:inherit;line-height:1.5}.col-form-label-lg{padding-top:calc(.5rem + var(--bs-border-width));padding-bottom:calc(.5rem + var(--bs-border-width));font-size:1.25rem}.col-form-label-sm{padding-top:calc(.25rem + var(--bs-border-width));padding-bottom:calc(.25rem + var(--bs-border-width));font-size:.875rem}.form-text{margin-top:.25rem;font-size:.875em;color:var(--bs-secondary-color)}.form-control{display:block;width:100%;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-clip:padding-box;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control{transition:none}}.form-control[type=file]{overflow:hidden}.form-control[type=file]:not(:disabled):not([readonly]){cursor:pointer}.form-control:focus{color:var(--bs-body-color);background-color:var(--bs-body-bg);border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-control::-webkit-date-and-time-value{min-width:85px;height:1.5em;margin:0}.form-control::-webkit-datetime-edit{display:block;padding:0}.form-control::-moz-placeholder{color:var(--bs-secondary-color);opacity:1}.form-control::placeholder{color:var(--bs-secondary-color);opacity:1}.form-control:disabled{background-color:var(--bs-secondary-bg);opacity:1}.form-control::-webkit-file-upload-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;-webkit-transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}.form-control::file-selector-button{padding:.375rem .75rem;margin:-.375rem -.75rem;-webkit-margin-end:.75rem;margin-inline-end:.75rem;color:var(--bs-body-color);background-color:var(--bs-tertiary-bg);pointer-events:none;border-color:inherit;border-style:solid;border-width:0;border-inline-end-width:var(--bs-border-width);border-radius:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-control::-webkit-file-upload-button{-webkit-transition:none;transition:none}.form-control::file-selector-button{transition:none}}.form-control:hover:not(:disabled):not([readonly])::-webkit-file-upload-button{background-color:var(--bs-secondary-bg)}.form-control:hover:not(:disabled):not([readonly])::file-selector-button{background-color:var(--bs-secondary-bg)}.form-control-plaintext{display:block;width:100%;padding:.375rem 0;margin-bottom:0;line-height:1.5;color:var(--bs-body-color);background-color:transparent;border:solid transparent;border-width:var(--bs-border-width) 0}.form-control-plaintext:focus{outline:0}.form-control-plaintext.form-control-lg,.form-control-plaintext.form-control-sm{padding-right:0;padding-left:0}.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2));padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-control-sm::-webkit-file-upload-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-sm::file-selector-button{padding:.25rem .5rem;margin:-.25rem -.5rem;-webkit-margin-end:.5rem;margin-inline-end:.5rem}.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2));padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.form-control-lg::-webkit-file-upload-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}.form-control-lg::file-selector-button{padding:.5rem 1rem;margin:-.5rem -1rem;-webkit-margin-end:1rem;margin-inline-end:1rem}textarea.form-control{min-height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2))}textarea.form-control-sm{min-height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}textarea.form-control-lg{min-height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-control-color{width:3rem;height:calc(1.5em + .75rem + calc(var(--bs-border-width) * 2));padding:.375rem}.form-control-color:not(:disabled):not([readonly]){cursor:pointer}.form-control-color::-moz-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color::-webkit-color-swatch{border:0!important;border-radius:var(--bs-border-radius)}.form-control-color.form-control-sm{height:calc(1.5em + .5rem + calc(var(--bs-border-width) * 2))}.form-control-color.form-control-lg{height:calc(1.5em + 1rem + calc(var(--bs-border-width) * 2))}.form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23343a40' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e");display:block;width:100%;padding:.375rem 2.25rem .375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-body-bg);background-image:var(--bs-form-select-bg-img),var(--bs-form-select-bg-icon,none);background-repeat:no-repeat;background-position:right .75rem center;background-size:16px 12px;border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius);transition:border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-select{transition:none}}.form-select:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-select[multiple],.form-select[size]:not([size="1"]){padding-right:.75rem;background-image:none}.form-select:disabled{background-color:var(--bs-secondary-bg)}.form-select:-moz-focusring{color:transparent;text-shadow:0 0 0 var(--bs-body-color)}.form-select-sm{padding-top:.25rem;padding-bottom:.25rem;padding-left:.5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.form-select-lg{padding-top:.5rem;padding-bottom:.5rem;padding-left:1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}[data-bs-theme=dark] .form-select{--bs-form-select-bg-img:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23dee2e6' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e")}.form-check{display:block;min-height:1.5rem;padding-left:1.5em;margin-bottom:.125rem}.form-check .form-check-input{float:left;margin-left:-1.5em}.form-check-reverse{padding-right:1.5em;padding-left:0;text-align:right}.form-check-reverse .form-check-input{float:right;margin-right:-1.5em;margin-left:0}.form-check-input{--bs-form-check-bg:var(--bs-body-bg);flex-shrink:0;width:1em;height:1em;margin-top:.25em;vertical-align:top;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:var(--bs-form-check-bg);background-image:var(--bs-form-check-bg-image);background-repeat:no-repeat;background-position:center;background-size:contain;border:var(--bs-border-width) solid var(--bs-border-color);-webkit-print-color-adjust:exact;color-adjust:exact;print-color-adjust:exact}.form-check-input[type=checkbox]{border-radius:.25em}.form-check-input[type=radio]{border-radius:50%}.form-check-input:active{filter:brightness(90%)}.form-check-input:focus{border-color:#86b7fe;outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.form-check-input:checked{background-color:#0d6efd;border-color:#0d6efd}.form-check-input:checked[type=checkbox]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='m6 10 3 3 6-6'/%3e%3c/svg%3e")}.form-check-input:checked[type=radio]{--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='%23fff'/%3e%3c/svg%3e")}.form-check-input[type=checkbox]:indeterminate{background-color:#0d6efd;border-color:#0d6efd;--bs-form-check-bg-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='%23fff' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10h8'/%3e%3c/svg%3e")}.form-check-input:disabled{pointer-events:none;filter:none;opacity:.5}.form-check-input:disabled~.form-check-label,.form-check-input[disabled]~.form-check-label{cursor:default;opacity:.5}.form-switch{padding-left:2.5em}.form-switch .form-check-input{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%280, 0, 0, 0.25%29'/%3e%3c/svg%3e");width:2em;margin-left:-2.5em;background-image:var(--bs-form-switch-bg);background-position:left center;border-radius:2em;transition:background-position .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-switch .form-check-input{transition:none}}.form-switch .form-check-input:focus{--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%2386b7fe'/%3e%3c/svg%3e")}.form-switch .form-check-input:checked{background-position:right center;--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23fff'/%3e%3c/svg%3e")}.form-switch.form-check-reverse{padding-right:2.5em;padding-left:0}.form-switch.form-check-reverse .form-check-input{margin-right:-2.5em;margin-left:0}.form-check-inline{display:inline-block;margin-right:1rem}.btn-check{position:absolute;clip:rect(0,0,0,0);pointer-events:none}.btn-check:disabled+.btn,.btn-check[disabled]+.btn{pointer-events:none;filter:none;opacity:.65}[data-bs-theme=dark] .form-switch .form-check-input:not(:checked):not(:focus){--bs-form-switch-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='rgba%28255, 255, 255, 0.25%29'/%3e%3c/svg%3e")}.form-range{width:100%;height:1.5rem;padding:0;-webkit-appearance:none;-moz-appearance:none;appearance:none;background-color:transparent}.form-range:focus{outline:0}.form-range:focus::-webkit-slider-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range:focus::-moz-range-thumb{box-shadow:0 0 0 1px #fff,0 0 0 .25rem rgba(13,110,253,.25)}.form-range::-moz-focus-outer{border:0}.form-range::-webkit-slider-thumb{width:1rem;height:1rem;margin-top:-.25rem;-webkit-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-webkit-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-webkit-slider-thumb{-webkit-transition:none;transition:none}}.form-range::-webkit-slider-thumb:active{background-color:#b6d4fe}.form-range::-webkit-slider-runnable-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range::-moz-range-thumb{width:1rem;height:1rem;-moz-appearance:none;appearance:none;background-color:#0d6efd;border:0;border-radius:1rem;-moz-transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out;transition:background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.form-range::-moz-range-thumb{-moz-transition:none;transition:none}}.form-range::-moz-range-thumb:active{background-color:#b6d4fe}.form-range::-moz-range-track{width:100%;height:.5rem;color:transparent;cursor:pointer;background-color:var(--bs-secondary-bg);border-color:transparent;border-radius:1rem}.form-range:disabled{pointer-events:none}.form-range:disabled::-webkit-slider-thumb{background-color:var(--bs-secondary-color)}.form-range:disabled::-moz-range-thumb{background-color:var(--bs-secondary-color)}.form-floating{position:relative}.form-floating>.form-control,.form-floating>.form-control-plaintext,.form-floating>.form-select{height:calc(3.5rem + calc(var(--bs-border-width) * 2));min-height:calc(3.5rem + calc(var(--bs-border-width) * 2));line-height:1.25}.form-floating>label{position:absolute;top:0;left:0;z-index:2;height:100%;padding:1rem .75rem;overflow:hidden;text-align:start;text-overflow:ellipsis;white-space:nowrap;pointer-events:none;border:var(--bs-border-width) solid transparent;transform-origin:0 0;transition:opacity .1s ease-in-out,transform .1s ease-in-out}@media (prefers-reduced-motion:reduce){.form-floating>label{transition:none}}.form-floating>.form-control,.form-floating>.form-control-plaintext{padding:1rem .75rem}.form-floating>.form-control-plaintext::-moz-placeholder,.form-floating>.form-control::-moz-placeholder{color:transparent}.form-floating>.form-control-plaintext::placeholder,.form-floating>.form-control::placeholder{color:transparent}.form-floating>.form-control-plaintext:not(:-moz-placeholder-shown),.form-floating>.form-control:not(:-moz-placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:focus,.form-floating>.form-control-plaintext:not(:placeholder-shown),.form-floating>.form-control:focus,.form-floating>.form-control:not(:placeholder-shown){padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control-plaintext:-webkit-autofill,.form-floating>.form-control:-webkit-autofill{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-select{padding-top:1.625rem;padding-bottom:.625rem}.form-floating>.form-control:not(:-moz-placeholder-shown)~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label,.form-floating>.form-control:focus~label,.form-floating>.form-control:not(:placeholder-shown)~label,.form-floating>.form-select~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control:not(:-moz-placeholder-shown)~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control-plaintext~label::after,.form-floating>.form-control:focus~label::after,.form-floating>.form-control:not(:placeholder-shown)~label::after,.form-floating>.form-select~label::after{position:absolute;inset:1rem 0.375rem;z-index:-1;height:1.5em;content:"";background-color:var(--bs-body-bg);border-radius:var(--bs-border-radius)}.form-floating>.form-control:-webkit-autofill~label{color:rgba(var(--bs-body-color-rgb),.65);transform:scale(.85) translateY(-.5rem) translateX(.15rem)}.form-floating>.form-control-plaintext~label{border-width:var(--bs-border-width) 0}.form-floating>.form-control:disabled~label,.form-floating>:disabled~label{color:#6c757d}.form-floating>.form-control:disabled~label::after,.form-floating>:disabled~label::after{background-color:var(--bs-secondary-bg)}.input-group{position:relative;display:flex;flex-wrap:wrap;align-items:stretch;width:100%}.input-group>.form-control,.input-group>.form-floating,.input-group>.form-select{position:relative;flex:1 1 auto;width:1%;min-width:0}.input-group>.form-control:focus,.input-group>.form-floating:focus-within,.input-group>.form-select:focus{z-index:5}.input-group .btn{position:relative;z-index:2}.input-group .btn:focus{z-index:5}.input-group-text{display:flex;align-items:center;padding:.375rem .75rem;font-size:1rem;font-weight:400;line-height:1.5;color:var(--bs-body-color);text-align:center;white-space:nowrap;background-color:var(--bs-tertiary-bg);border:var(--bs-border-width) solid var(--bs-border-color);border-radius:var(--bs-border-radius)}.input-group-lg>.btn,.input-group-lg>.form-control,.input-group-lg>.form-select,.input-group-lg>.input-group-text{padding:.5rem 1rem;font-size:1.25rem;border-radius:var(--bs-border-radius-lg)}.input-group-sm>.btn,.input-group-sm>.form-control,.input-group-sm>.form-select,.input-group-sm>.input-group-text{padding:.25rem .5rem;font-size:.875rem;border-radius:var(--bs-border-radius-sm)}.input-group-lg>.form-select,.input-group-sm>.form-select{padding-right:3rem}.input-group:not(.has-validation)>.dropdown-toggle:nth-last-child(n+3),.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-control,.input-group:not(.has-validation)>.form-floating:not(:last-child)>.form-select,.input-group:not(.has-validation)>:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group.has-validation>.dropdown-toggle:nth-last-child(n+4),.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-control,.input-group.has-validation>.form-floating:nth-last-child(n+3)>.form-select,.input-group.has-validation>:nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating){border-top-right-radius:0;border-bottom-right-radius:0}.input-group>:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback){margin-left:calc(var(--bs-border-width) * -1);border-top-left-radius:0;border-bottom-left-radius:0}.input-group>.form-floating:not(:first-child)>.form-control,.input-group>.form-floating:not(:first-child)>.form-select{border-top-left-radius:0;border-bottom-left-radius:0}.valid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-valid-color)}.valid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-success);border-radius:var(--bs-border-radius)}.is-valid~.valid-feedback,.is-valid~.valid-tooltip,.was-validated :valid~.valid-feedback,.was-validated :valid~.valid-tooltip{display:block}.form-control.is-valid,.was-validated .form-control:valid{border-color:var(--bs-form-valid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-valid:focus,.was-validated .form-control:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.was-validated textarea.form-control:valid,textarea.form-control.is-valid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-valid,.was-validated .form-select:valid{border-color:var(--bs-form-valid-border-color)}.form-select.is-valid:not([multiple]):not([size]),.form-select.is-valid:not([multiple])[size="1"],.was-validated .form-select:valid:not([multiple]):not([size]),.was-validated .form-select:valid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%23198754' d='M2.3 6.73.6 4.53c-.4-1.04.46-1.4 1.1-.8l1.1 1.4 3.4-3.8c.6-.63 1.6-.27 1.2.7l-4 4.6c-.43.5-.8.4-1.1.1z'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-valid:focus,.was-validated .form-select:valid:focus{border-color:var(--bs-form-valid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-control-color.is-valid,.was-validated .form-control-color:valid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-valid,.was-validated .form-check-input:valid{border-color:var(--bs-form-valid-border-color)}.form-check-input.is-valid:checked,.was-validated .form-check-input:valid:checked{background-color:var(--bs-form-valid-color)}.form-check-input.is-valid:focus,.was-validated .form-check-input:valid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-success-rgb),.25)}.form-check-input.is-valid~.form-check-label,.was-validated .form-check-input:valid~.form-check-label{color:var(--bs-form-valid-color)}.form-check-inline .form-check-input~.valid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-valid,.input-group>.form-floating:not(:focus-within).is-valid,.input-group>.form-select:not(:focus).is-valid,.was-validated .input-group>.form-control:not(:focus):valid,.was-validated .input-group>.form-floating:not(:focus-within):valid,.was-validated .input-group>.form-select:not(:focus):valid{z-index:3}.invalid-feedback{display:none;width:100%;margin-top:.25rem;font-size:.875em;color:var(--bs-form-invalid-color)}.invalid-tooltip{position:absolute;top:100%;z-index:5;display:none;max-width:100%;padding:.25rem .5rem;margin-top:.1rem;font-size:.875rem;color:#fff;background-color:var(--bs-danger);border-radius:var(--bs-border-radius)}.is-invalid~.invalid-feedback,.is-invalid~.invalid-tooltip,.was-validated :invalid~.invalid-feedback,.was-validated :invalid~.invalid-tooltip{display:block}.form-control.is-invalid,.was-validated .form-control:invalid{border-color:var(--bs-form-invalid-border-color);padding-right:calc(1.5em + .75rem);background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");background-repeat:no-repeat;background-position:right calc(.375em + .1875rem) center;background-size:calc(.75em + .375rem) calc(.75em + .375rem)}.form-control.is-invalid:focus,.was-validated .form-control:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.was-validated textarea.form-control:invalid,textarea.form-control.is-invalid{padding-right:calc(1.5em + .75rem);background-position:top calc(.375em + .1875rem) right calc(.375em + .1875rem)}.form-select.is-invalid,.was-validated .form-select:invalid{border-color:var(--bs-form-invalid-border-color)}.form-select.is-invalid:not([multiple]):not([size]),.form-select.is-invalid:not([multiple])[size="1"],.was-validated .form-select:invalid:not([multiple]):not([size]),.was-validated .form-select:invalid:not([multiple])[size="1"]{--bs-form-select-bg-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' width='12' height='12' fill='none' stroke='%23dc3545'%3e%3ccircle cx='6' cy='6' r='4.5'/%3e%3cpath stroke-linejoin='round' d='M5.8 3.6h.4L6 6.5z'/%3e%3ccircle cx='6' cy='8.2' r='.6' fill='%23dc3545' stroke='none'/%3e%3c/svg%3e");padding-right:4.125rem;background-position:right .75rem center,center right 2.25rem;background-size:16px 12px,calc(.75em + .375rem) calc(.75em + .375rem)}.form-select.is-invalid:focus,.was-validated .form-select:invalid:focus{border-color:var(--bs-form-invalid-border-color);box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-control-color.is-invalid,.was-validated .form-control-color:invalid{width:calc(3rem + calc(1.5em + .75rem))}.form-check-input.is-invalid,.was-validated .form-check-input:invalid{border-color:var(--bs-form-invalid-border-color)}.form-check-input.is-invalid:checked,.was-validated .form-check-input:invalid:checked{background-color:var(--bs-form-invalid-color)}.form-check-input.is-invalid:focus,.was-validated .form-check-input:invalid:focus{box-shadow:0 0 0 .25rem rgba(var(--bs-danger-rgb),.25)}.form-check-input.is-invalid~.form-check-label,.was-validated .form-check-input:invalid~.form-check-label{color:var(--bs-form-invalid-color)}.form-check-inline .form-check-input~.invalid-feedback{margin-left:.5em}.input-group>.form-control:not(:focus).is-invalid,.input-group>.form-floating:not(:focus-within).is-invalid,.input-group>.form-select:not(:focus).is-invalid,.was-validated .input-group>.form-control:not(:focus):invalid,.was-validated .input-group>.form-floating:not(:focus-within):invalid,.was-validated .input-group>.form-select:not(:focus):invalid{z-index:4}.btn{--bs-btn-padding-x:0.75rem;--bs-btn-padding-y:0.375rem;--bs-btn-font-family: ;--bs-btn-font-size:1rem;--bs-btn-font-weight:400;--bs-btn-line-height:1.5;--bs-btn-color:var(--bs-body-color);--bs-btn-bg:transparent;--bs-btn-border-width:var(--bs-border-width);--bs-btn-border-color:transparent;--bs-btn-border-radius:var(--bs-border-radius);--bs-btn-hover-border-color:transparent;--bs-btn-box-shadow:inset 0 1px 0 rgba(255, 255, 255, 0.15),0 1px 1px rgba(0, 0, 0, 0.075);--bs-btn-disabled-opacity:0.65;--bs-btn-focus-box-shadow:0 0 0 0.25rem rgba(var(--bs-btn-focus-shadow-rgb), .5);display:inline-block;padding:var(--bs-btn-padding-y) var(--bs-btn-padding-x);font-family:var(--bs-btn-font-family);font-size:var(--bs-btn-font-size);font-weight:var(--bs-btn-font-weight);line-height:var(--bs-btn-line-height);color:var(--bs-btn-color);text-align:center;text-decoration:none;vertical-align:middle;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;border:var(--bs-btn-border-width) solid var(--bs-btn-border-color);border-radius:var(--bs-btn-border-radius);background-color:var(--bs-btn-bg);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.btn{transition:none}}.btn:hover{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color)}.btn-check+.btn:hover{color:var(--bs-btn-color);background-color:var(--bs-btn-bg);border-color:var(--bs-btn-border-color)}.btn:focus-visible{color:var(--bs-btn-hover-color);background-color:var(--bs-btn-hover-bg);border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:focus-visible+.btn{border-color:var(--bs-btn-hover-border-color);outline:0;box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked+.btn,.btn.active,.btn.show,.btn:first-child:active,:not(.btn-check)+.btn:active{color:var(--bs-btn-active-color);background-color:var(--bs-btn-active-bg);border-color:var(--bs-btn-active-border-color)}.btn-check:checked+.btn:focus-visible,.btn.active:focus-visible,.btn.show:focus-visible,.btn:first-child:active:focus-visible,:not(.btn-check)+.btn:active:focus-visible{box-shadow:var(--bs-btn-focus-box-shadow)}.btn-check:checked:focus-visible+.btn{box-shadow:var(--bs-btn-focus-box-shadow)}.btn.disabled,.btn:disabled,fieldset:disabled .btn{color:var(--bs-btn-disabled-color);pointer-events:none;background-color:var(--bs-btn-disabled-bg);border-color:var(--bs-btn-disabled-border-color);opacity:var(--bs-btn-disabled-opacity)}.btn-primary{--bs-btn-color:#fff;--bs-btn-bg:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0b5ed7;--bs-btn-hover-border-color:#0a58ca;--bs-btn-focus-shadow-rgb:49,132,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0a58ca;--bs-btn-active-border-color:#0a53be;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#0d6efd;--bs-btn-disabled-border-color:#0d6efd}.btn-secondary{--bs-btn-color:#fff;--bs-btn-bg:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#5c636a;--bs-btn-hover-border-color:#565e64;--bs-btn-focus-shadow-rgb:130,138,145;--bs-btn-active-color:#fff;--bs-btn-active-bg:#565e64;--bs-btn-active-border-color:#51585e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#6c757d;--bs-btn-disabled-border-color:#6c757d}.btn-success{--bs-btn-color:#fff;--bs-btn-bg:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#157347;--bs-btn-hover-border-color:#146c43;--bs-btn-focus-shadow-rgb:60,153,110;--bs-btn-active-color:#fff;--bs-btn-active-bg:#146c43;--bs-btn-active-border-color:#13653f;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#198754;--bs-btn-disabled-border-color:#198754}.btn-info{--bs-btn-color:#000;--bs-btn-bg:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#31d2f2;--bs-btn-hover-border-color:#25cff2;--bs-btn-focus-shadow-rgb:11,172,204;--bs-btn-active-color:#000;--bs-btn-active-bg:#3dd5f3;--bs-btn-active-border-color:#25cff2;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#0dcaf0;--bs-btn-disabled-border-color:#0dcaf0}.btn-warning{--bs-btn-color:#000;--bs-btn-bg:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffca2c;--bs-btn-hover-border-color:#ffc720;--bs-btn-focus-shadow-rgb:217,164,6;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffcd39;--bs-btn-active-border-color:#ffc720;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#ffc107;--bs-btn-disabled-border-color:#ffc107}.btn-danger{--bs-btn-color:#fff;--bs-btn-bg:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#bb2d3b;--bs-btn-hover-border-color:#b02a37;--bs-btn-focus-shadow-rgb:225,83,97;--bs-btn-active-color:#fff;--bs-btn-active-bg:#b02a37;--bs-btn-active-border-color:#a52834;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#dc3545;--bs-btn-disabled-border-color:#dc3545}.btn-light{--bs-btn-color:#000;--bs-btn-bg:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#d3d4d5;--bs-btn-hover-border-color:#c6c7c8;--bs-btn-focus-shadow-rgb:211,212,213;--bs-btn-active-color:#000;--bs-btn-active-bg:#c6c7c8;--bs-btn-active-border-color:#babbbc;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#000;--bs-btn-disabled-bg:#f8f9fa;--bs-btn-disabled-border-color:#f8f9fa}.btn-dark{--bs-btn-color:#fff;--bs-btn-bg:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#424649;--bs-btn-hover-border-color:#373b3e;--bs-btn-focus-shadow-rgb:66,70,73;--bs-btn-active-color:#fff;--bs-btn-active-bg:#4d5154;--bs-btn-active-border-color:#373b3e;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#fff;--bs-btn-disabled-bg:#212529;--bs-btn-disabled-border-color:#212529}.btn-outline-primary{--bs-btn-color:#0d6efd;--bs-btn-border-color:#0d6efd;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#0d6efd;--bs-btn-hover-border-color:#0d6efd;--bs-btn-focus-shadow-rgb:13,110,253;--bs-btn-active-color:#fff;--bs-btn-active-bg:#0d6efd;--bs-btn-active-border-color:#0d6efd;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0d6efd;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0d6efd;--bs-gradient:none}.btn-outline-secondary{--bs-btn-color:#6c757d;--bs-btn-border-color:#6c757d;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#6c757d;--bs-btn-hover-border-color:#6c757d;--bs-btn-focus-shadow-rgb:108,117,125;--bs-btn-active-color:#fff;--bs-btn-active-bg:#6c757d;--bs-btn-active-border-color:#6c757d;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#6c757d;--bs-gradient:none}.btn-outline-success{--bs-btn-color:#198754;--bs-btn-border-color:#198754;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#198754;--bs-btn-hover-border-color:#198754;--bs-btn-focus-shadow-rgb:25,135,84;--bs-btn-active-color:#fff;--bs-btn-active-bg:#198754;--bs-btn-active-border-color:#198754;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#198754;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#198754;--bs-gradient:none}.btn-outline-info{--bs-btn-color:#0dcaf0;--bs-btn-border-color:#0dcaf0;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#0dcaf0;--bs-btn-hover-border-color:#0dcaf0;--bs-btn-focus-shadow-rgb:13,202,240;--bs-btn-active-color:#000;--bs-btn-active-bg:#0dcaf0;--bs-btn-active-border-color:#0dcaf0;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#0dcaf0;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#0dcaf0;--bs-gradient:none}.btn-outline-warning{--bs-btn-color:#ffc107;--bs-btn-border-color:#ffc107;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#ffc107;--bs-btn-hover-border-color:#ffc107;--bs-btn-focus-shadow-rgb:255,193,7;--bs-btn-active-color:#000;--bs-btn-active-bg:#ffc107;--bs-btn-active-border-color:#ffc107;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#ffc107;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#ffc107;--bs-gradient:none}.btn-outline-danger{--bs-btn-color:#dc3545;--bs-btn-border-color:#dc3545;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#dc3545;--bs-btn-hover-border-color:#dc3545;--bs-btn-focus-shadow-rgb:220,53,69;--bs-btn-active-color:#fff;--bs-btn-active-bg:#dc3545;--bs-btn-active-border-color:#dc3545;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#dc3545;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#dc3545;--bs-gradient:none}.btn-outline-light{--bs-btn-color:#f8f9fa;--bs-btn-border-color:#f8f9fa;--bs-btn-hover-color:#000;--bs-btn-hover-bg:#f8f9fa;--bs-btn-hover-border-color:#f8f9fa;--bs-btn-focus-shadow-rgb:248,249,250;--bs-btn-active-color:#000;--bs-btn-active-bg:#f8f9fa;--bs-btn-active-border-color:#f8f9fa;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#f8f9fa;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#f8f9fa;--bs-gradient:none}.btn-outline-dark{--bs-btn-color:#212529;--bs-btn-border-color:#212529;--bs-btn-hover-color:#fff;--bs-btn-hover-bg:#212529;--bs-btn-hover-border-color:#212529;--bs-btn-focus-shadow-rgb:33,37,41;--bs-btn-active-color:#fff;--bs-btn-active-bg:#212529;--bs-btn-active-border-color:#212529;--bs-btn-active-shadow:inset 0 3px 5px rgba(0, 0, 0, 0.125);--bs-btn-disabled-color:#212529;--bs-btn-disabled-bg:transparent;--bs-btn-disabled-border-color:#212529;--bs-gradient:none}.btn-link{--bs-btn-font-weight:400;--bs-btn-color:var(--bs-link-color);--bs-btn-bg:transparent;--bs-btn-border-color:transparent;--bs-btn-hover-color:var(--bs-link-hover-color);--bs-btn-hover-border-color:transparent;--bs-btn-active-color:var(--bs-link-hover-color);--bs-btn-active-border-color:transparent;--bs-btn-disabled-color:#6c757d;--bs-btn-disabled-border-color:transparent;--bs-btn-box-shadow:0 0 0 #000;--bs-btn-focus-shadow-rgb:49,132,253;text-decoration:underline}.btn-link:focus-visible{color:var(--bs-btn-color)}.btn-link:hover{color:var(--bs-btn-hover-color)}.btn-group-lg>.btn,.btn-lg{--bs-btn-padding-y:0.5rem;--bs-btn-padding-x:1rem;--bs-btn-font-size:1.25rem;--bs-btn-border-radius:var(--bs-border-radius-lg)}.btn-group-sm>.btn,.btn-sm{--bs-btn-padding-y:0.25rem;--bs-btn-padding-x:0.5rem;--bs-btn-font-size:0.875rem;--bs-btn-border-radius:var(--bs-border-radius-sm)}.fade{transition:opacity .15s linear}@media (prefers-reduced-motion:reduce){.fade{transition:none}}.fade:not(.show){opacity:0}.collapse:not(.show){display:none}.collapsing{height:0;overflow:hidden;transition:height .35s ease}@media (prefers-reduced-motion:reduce){.collapsing{transition:none}}.collapsing.collapse-horizontal{width:0;height:auto;transition:width .35s ease}@media (prefers-reduced-motion:reduce){.collapsing.collapse-horizontal{transition:none}}.dropdown,.dropdown-center,.dropend,.dropstart,.dropup,.dropup-center{position:relative}.dropdown-toggle{white-space:nowrap}.dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid;border-right:.3em solid transparent;border-bottom:0;border-left:.3em solid transparent}.dropdown-toggle:empty::after{margin-left:0}.dropdown-menu{--bs-dropdown-zindex:1000;--bs-dropdown-min-width:10rem;--bs-dropdown-padding-x:0;--bs-dropdown-padding-y:0.5rem;--bs-dropdown-spacer:0.125rem;--bs-dropdown-font-size:1rem;--bs-dropdown-color:var(--bs-body-color);--bs-dropdown-bg:var(--bs-body-bg);--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-border-radius:var(--bs-border-radius);--bs-dropdown-border-width:var(--bs-border-width);--bs-dropdown-inner-border-radius:calc(var(--bs-border-radius) - var(--bs-border-width));--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-divider-margin-y:0.5rem;--bs-dropdown-box-shadow:var(--bs-box-shadow);--bs-dropdown-link-color:var(--bs-body-color);--bs-dropdown-link-hover-color:var(--bs-body-color);--bs-dropdown-link-hover-bg:var(--bs-tertiary-bg);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:var(--bs-tertiary-color);--bs-dropdown-item-padding-x:1rem;--bs-dropdown-item-padding-y:0.25rem;--bs-dropdown-header-color:#6c757d;--bs-dropdown-header-padding-x:1rem;--bs-dropdown-header-padding-y:0.5rem;position:absolute;z-index:var(--bs-dropdown-zindex);display:none;min-width:var(--bs-dropdown-min-width);padding:var(--bs-dropdown-padding-y) var(--bs-dropdown-padding-x);margin:0;font-size:var(--bs-dropdown-font-size);color:var(--bs-dropdown-color);text-align:left;list-style:none;background-color:var(--bs-dropdown-bg);background-clip:padding-box;border:var(--bs-dropdown-border-width) solid var(--bs-dropdown-border-color);border-radius:var(--bs-dropdown-border-radius)}.dropdown-menu[data-bs-popper]{top:100%;left:0;margin-top:var(--bs-dropdown-spacer)}.dropdown-menu-start{--bs-position:start}.dropdown-menu-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-end{--bs-position:end}.dropdown-menu-end[data-bs-popper]{right:0;left:auto}@media (min-width:576px){.dropdown-menu-sm-start{--bs-position:start}.dropdown-menu-sm-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-sm-end{--bs-position:end}.dropdown-menu-sm-end[data-bs-popper]{right:0;left:auto}}@media (min-width:768px){.dropdown-menu-md-start{--bs-position:start}.dropdown-menu-md-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-md-end{--bs-position:end}.dropdown-menu-md-end[data-bs-popper]{right:0;left:auto}}@media (min-width:992px){.dropdown-menu-lg-start{--bs-position:start}.dropdown-menu-lg-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-lg-end{--bs-position:end}.dropdown-menu-lg-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1200px){.dropdown-menu-xl-start{--bs-position:start}.dropdown-menu-xl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xl-end{--bs-position:end}.dropdown-menu-xl-end[data-bs-popper]{right:0;left:auto}}@media (min-width:1400px){.dropdown-menu-xxl-start{--bs-position:start}.dropdown-menu-xxl-start[data-bs-popper]{right:auto;left:0}.dropdown-menu-xxl-end{--bs-position:end}.dropdown-menu-xxl-end[data-bs-popper]{right:0;left:auto}}.dropup .dropdown-menu[data-bs-popper]{top:auto;bottom:100%;margin-top:0;margin-bottom:var(--bs-dropdown-spacer)}.dropup .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:0;border-right:.3em solid transparent;border-bottom:.3em solid;border-left:.3em solid transparent}.dropup .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-menu[data-bs-popper]{top:0;right:auto;left:100%;margin-top:0;margin-left:var(--bs-dropdown-spacer)}.dropend .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:0;border-bottom:.3em solid transparent;border-left:.3em solid}.dropend .dropdown-toggle:empty::after{margin-left:0}.dropend .dropdown-toggle::after{vertical-align:0}.dropstart .dropdown-menu[data-bs-popper]{top:0;right:100%;left:auto;margin-top:0;margin-right:var(--bs-dropdown-spacer)}.dropstart .dropdown-toggle::after{display:inline-block;margin-left:.255em;vertical-align:.255em;content:""}.dropstart .dropdown-toggle::after{display:none}.dropstart .dropdown-toggle::before{display:inline-block;margin-right:.255em;vertical-align:.255em;content:"";border-top:.3em solid transparent;border-right:.3em solid;border-bottom:.3em solid transparent}.dropstart .dropdown-toggle:empty::after{margin-left:0}.dropstart .dropdown-toggle::before{vertical-align:0}.dropdown-divider{height:0;margin:var(--bs-dropdown-divider-margin-y) 0;overflow:hidden;border-top:1px solid var(--bs-dropdown-divider-bg);opacity:1}.dropdown-item{display:block;width:100%;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);clear:both;font-weight:400;color:var(--bs-dropdown-link-color);text-align:inherit;text-decoration:none;white-space:nowrap;background-color:transparent;border:0;border-radius:var(--bs-dropdown-item-border-radius,0)}.dropdown-item:focus,.dropdown-item:hover{color:var(--bs-dropdown-link-hover-color);background-color:var(--bs-dropdown-link-hover-bg)}.dropdown-item.active,.dropdown-item:active{color:var(--bs-dropdown-link-active-color);text-decoration:none;background-color:var(--bs-dropdown-link-active-bg)}.dropdown-item.disabled,.dropdown-item:disabled{color:var(--bs-dropdown-link-disabled-color);pointer-events:none;background-color:transparent}.dropdown-menu.show{display:block}.dropdown-header{display:block;padding:var(--bs-dropdown-header-padding-y) var(--bs-dropdown-header-padding-x);margin-bottom:0;font-size:.875rem;color:var(--bs-dropdown-header-color);white-space:nowrap}.dropdown-item-text{display:block;padding:var(--bs-dropdown-item-padding-y) var(--bs-dropdown-item-padding-x);color:var(--bs-dropdown-link-color)}.dropdown-menu-dark{--bs-dropdown-color:#dee2e6;--bs-dropdown-bg:#343a40;--bs-dropdown-border-color:var(--bs-border-color-translucent);--bs-dropdown-box-shadow: ;--bs-dropdown-link-color:#dee2e6;--bs-dropdown-link-hover-color:#fff;--bs-dropdown-divider-bg:var(--bs-border-color-translucent);--bs-dropdown-link-hover-bg:rgba(255, 255, 255, 0.15);--bs-dropdown-link-active-color:#fff;--bs-dropdown-link-active-bg:#0d6efd;--bs-dropdown-link-disabled-color:#adb5bd;--bs-dropdown-header-color:#adb5bd}.btn-group,.btn-group-vertical{position:relative;display:inline-flex;vertical-align:middle}.btn-group-vertical>.btn,.btn-group>.btn{position:relative;flex:1 1 auto}.btn-group-vertical>.btn-check:checked+.btn,.btn-group-vertical>.btn-check:focus+.btn,.btn-group-vertical>.btn.active,.btn-group-vertical>.btn:active,.btn-group-vertical>.btn:focus,.btn-group-vertical>.btn:hover,.btn-group>.btn-check:checked+.btn,.btn-group>.btn-check:focus+.btn,.btn-group>.btn.active,.btn-group>.btn:active,.btn-group>.btn:focus,.btn-group>.btn:hover{z-index:1}.btn-toolbar{display:flex;flex-wrap:wrap;justify-content:flex-start}.btn-toolbar .input-group{width:auto}.btn-group{border-radius:var(--bs-border-radius)}.btn-group>.btn-group:not(:first-child),.btn-group>:not(.btn-check:first-child)+.btn{margin-left:calc(var(--bs-border-width) * -1)}.btn-group>.btn-group:not(:last-child)>.btn,.btn-group>.btn.dropdown-toggle-split:first-child,.btn-group>.btn:not(:last-child):not(.dropdown-toggle){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group>.btn-group:not(:first-child)>.btn,.btn-group>.btn:nth-child(n+3),.btn-group>:not(.btn-check)+.btn{border-top-left-radius:0;border-bottom-left-radius:0}.dropdown-toggle-split{padding-right:.5625rem;padding-left:.5625rem}.dropdown-toggle-split::after,.dropend .dropdown-toggle-split::after,.dropup .dropdown-toggle-split::after{margin-left:0}.dropstart .dropdown-toggle-split::before{margin-right:0}.btn-group-sm>.btn+.dropdown-toggle-split,.btn-sm+.dropdown-toggle-split{padding-right:.375rem;padding-left:.375rem}.btn-group-lg>.btn+.dropdown-toggle-split,.btn-lg+.dropdown-toggle-split{padding-right:.75rem;padding-left:.75rem}.btn-group-vertical{flex-direction:column;align-items:flex-start;justify-content:center}.btn-group-vertical>.btn,.btn-group-vertical>.btn-group{width:100%}.btn-group-vertical>.btn-group:not(:first-child),.btn-group-vertical>.btn:not(:first-child){margin-top:calc(var(--bs-border-width) * -1)}.btn-group-vertical>.btn-group:not(:last-child)>.btn,.btn-group-vertical>.btn:not(:last-child):not(.dropdown-toggle){border-bottom-right-radius:0;border-bottom-left-radius:0}.btn-group-vertical>.btn-group:not(:first-child)>.btn,.btn-group-vertical>.btn~.btn{border-top-left-radius:0;border-top-right-radius:0}.nav{--bs-nav-link-padding-x:1rem;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-link-color);--bs-nav-link-hover-color:var(--bs-link-hover-color);--bs-nav-link-disabled-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding-left:0;margin-bottom:0;list-style:none}.nav-link{display:block;padding:var(--bs-nav-link-padding-y) var(--bs-nav-link-padding-x);font-size:var(--bs-nav-link-font-size);font-weight:var(--bs-nav-link-font-weight);color:var(--bs-nav-link-color);text-decoration:none;background:0 0;border:0;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out}@media (prefers-reduced-motion:reduce){.nav-link{transition:none}}.nav-link:focus,.nav-link:hover{color:var(--bs-nav-link-hover-color)}.nav-link:focus-visible{outline:0;box-shadow:0 0 0 .25rem rgba(13,110,253,.25)}.nav-link.disabled,.nav-link:disabled{color:var(--bs-nav-link-disabled-color);pointer-events:none;cursor:default}.nav-tabs{--bs-nav-tabs-border-width:var(--bs-border-width);--bs-nav-tabs-border-color:var(--bs-border-color);--bs-nav-tabs-border-radius:var(--bs-border-radius);--bs-nav-tabs-link-hover-border-color:var(--bs-secondary-bg) var(--bs-secondary-bg) var(--bs-border-color);--bs-nav-tabs-link-active-color:var(--bs-emphasis-color);--bs-nav-tabs-link-active-bg:var(--bs-body-bg);--bs-nav-tabs-link-active-border-color:var(--bs-border-color) var(--bs-border-color) var(--bs-body-bg);border-bottom:var(--bs-nav-tabs-border-width) solid var(--bs-nav-tabs-border-color)}.nav-tabs .nav-link{margin-bottom:calc(-1 * var(--bs-nav-tabs-border-width));border:var(--bs-nav-tabs-border-width) solid transparent;border-top-left-radius:var(--bs-nav-tabs-border-radius);border-top-right-radius:var(--bs-nav-tabs-border-radius)}.nav-tabs .nav-link:focus,.nav-tabs .nav-link:hover{isolation:isolate;border-color:var(--bs-nav-tabs-link-hover-border-color)}.nav-tabs .nav-item.show .nav-link,.nav-tabs .nav-link.active{color:var(--bs-nav-tabs-link-active-color);background-color:var(--bs-nav-tabs-link-active-bg);border-color:var(--bs-nav-tabs-link-active-border-color)}.nav-tabs .dropdown-menu{margin-top:calc(-1 * var(--bs-nav-tabs-border-width));border-top-left-radius:0;border-top-right-radius:0}.nav-pills{--bs-nav-pills-border-radius:var(--bs-border-radius);--bs-nav-pills-link-active-color:#fff;--bs-nav-pills-link-active-bg:#0d6efd}.nav-pills .nav-link{border-radius:var(--bs-nav-pills-border-radius)}.nav-pills .nav-link.active,.nav-pills .show>.nav-link{color:var(--bs-nav-pills-link-active-color);background-color:var(--bs-nav-pills-link-active-bg)}.nav-underline{--bs-nav-underline-gap:1rem;--bs-nav-underline-border-width:0.125rem;--bs-nav-underline-link-active-color:var(--bs-emphasis-color);gap:var(--bs-nav-underline-gap)}.nav-underline .nav-link{padding-right:0;padding-left:0;border-bottom:var(--bs-nav-underline-border-width) solid transparent}.nav-underline .nav-link:focus,.nav-underline .nav-link:hover{border-bottom-color:currentcolor}.nav-underline .nav-link.active,.nav-underline .show>.nav-link{font-weight:700;color:var(--bs-nav-underline-link-active-color);border-bottom-color:currentcolor}.nav-fill .nav-item,.nav-fill>.nav-link{flex:1 1 auto;text-align:center}.nav-justified .nav-item,.nav-justified>.nav-link{flex-basis:0;flex-grow:1;text-align:center}.nav-fill .nav-item .nav-link,.nav-justified .nav-item .nav-link{width:100%}.tab-content>.tab-pane{display:none}.tab-content>.active{display:block}.navbar{--bs-navbar-padding-x:0;--bs-navbar-padding-y:0.5rem;--bs-navbar-color:rgba(var(--bs-emphasis-color-rgb), 0.65);--bs-navbar-hover-color:rgba(var(--bs-emphasis-color-rgb), 0.8);--bs-navbar-disabled-color:rgba(var(--bs-emphasis-color-rgb), 0.3);--bs-navbar-active-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-padding-y:0.3125rem;--bs-navbar-brand-margin-end:1rem;--bs-navbar-brand-font-size:1.25rem;--bs-navbar-brand-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-brand-hover-color:rgba(var(--bs-emphasis-color-rgb), 1);--bs-navbar-nav-link-padding-x:0.5rem;--bs-navbar-toggler-padding-y:0.25rem;--bs-navbar-toggler-padding-x:0.75rem;--bs-navbar-toggler-font-size:1.25rem;--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%2833, 37, 41, 0.75%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e");--bs-navbar-toggler-border-color:rgba(var(--bs-emphasis-color-rgb), 0.15);--bs-navbar-toggler-border-radius:var(--bs-border-radius);--bs-navbar-toggler-focus-width:0.25rem;--bs-navbar-toggler-transition:box-shadow 0.15s ease-in-out;position:relative;display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;padding:var(--bs-navbar-padding-y) var(--bs-navbar-padding-x)}.navbar>.container,.navbar>.container-fluid,.navbar>.container-lg,.navbar>.container-md,.navbar>.container-sm,.navbar>.container-xl,.navbar>.container-xxl{display:flex;flex-wrap:inherit;align-items:center;justify-content:space-between}.navbar-brand{padding-top:var(--bs-navbar-brand-padding-y);padding-bottom:var(--bs-navbar-brand-padding-y);margin-right:var(--bs-navbar-brand-margin-end);font-size:var(--bs-navbar-brand-font-size);color:var(--bs-navbar-brand-color);text-decoration:none;white-space:nowrap}.navbar-brand:focus,.navbar-brand:hover{color:var(--bs-navbar-brand-hover-color)}.navbar-nav{--bs-nav-link-padding-x:0;--bs-nav-link-padding-y:0.5rem;--bs-nav-link-font-weight: ;--bs-nav-link-color:var(--bs-navbar-color);--bs-nav-link-hover-color:var(--bs-navbar-hover-color);--bs-nav-link-disabled-color:var(--bs-navbar-disabled-color);display:flex;flex-direction:column;padding-left:0;margin-bottom:0;list-style:none}.navbar-nav .nav-link.active,.navbar-nav .nav-link.show{color:var(--bs-navbar-active-color)}.navbar-nav .dropdown-menu{position:static}.navbar-text{padding-top:.5rem;padding-bottom:.5rem;color:var(--bs-navbar-color)}.navbar-text a,.navbar-text a:focus,.navbar-text a:hover{color:var(--bs-navbar-active-color)}.navbar-collapse{flex-basis:100%;flex-grow:1;align-items:center}.navbar-toggler{padding:var(--bs-navbar-toggler-padding-y) var(--bs-navbar-toggler-padding-x);font-size:var(--bs-navbar-toggler-font-size);line-height:1;color:var(--bs-navbar-color);background-color:transparent;border:var(--bs-border-width) solid var(--bs-navbar-toggler-border-color);border-radius:var(--bs-navbar-toggler-border-radius);transition:var(--bs-navbar-toggler-transition)}@media (prefers-reduced-motion:reduce){.navbar-toggler{transition:none}}.navbar-toggler:hover{text-decoration:none}.navbar-toggler:focus{text-decoration:none;outline:0;box-shadow:0 0 0 var(--bs-navbar-toggler-focus-width)}.navbar-toggler-icon{display:inline-block;width:1.5em;height:1.5em;vertical-align:middle;background-image:var(--bs-navbar-toggler-icon-bg);background-repeat:no-repeat;background-position:center;background-size:100%}.navbar-nav-scroll{max-height:var(--bs-scroll-height,75vh);overflow-y:auto}@media (min-width:576px){.navbar-expand-sm{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-sm .navbar-nav{flex-direction:row}.navbar-expand-sm .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-sm .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-sm .navbar-nav-scroll{overflow:visible}.navbar-expand-sm .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-sm .navbar-toggler{display:none}.navbar-expand-sm .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-sm .offcanvas .offcanvas-header{display:none}.navbar-expand-sm .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:768px){.navbar-expand-md{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-md .navbar-nav{flex-direction:row}.navbar-expand-md .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-md .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-md .navbar-nav-scroll{overflow:visible}.navbar-expand-md .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-md .navbar-toggler{display:none}.navbar-expand-md .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-md .offcanvas .offcanvas-header{display:none}.navbar-expand-md .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:992px){.navbar-expand-lg{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-lg .navbar-nav{flex-direction:row}.navbar-expand-lg .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-lg .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-lg .navbar-nav-scroll{overflow:visible}.navbar-expand-lg .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-lg .navbar-toggler{display:none}.navbar-expand-lg .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-lg .offcanvas .offcanvas-header{display:none}.navbar-expand-lg .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1200px){.navbar-expand-xl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xl .navbar-nav{flex-direction:row}.navbar-expand-xl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xl .navbar-nav-scroll{overflow:visible}.navbar-expand-xl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xl .navbar-toggler{display:none}.navbar-expand-xl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xl .offcanvas .offcanvas-header{display:none}.navbar-expand-xl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}@media (min-width:1400px){.navbar-expand-xxl{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand-xxl .navbar-nav{flex-direction:row}.navbar-expand-xxl .navbar-nav .dropdown-menu{position:absolute}.navbar-expand-xxl .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand-xxl .navbar-nav-scroll{overflow:visible}.navbar-expand-xxl .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand-xxl .navbar-toggler{display:none}.navbar-expand-xxl .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand-xxl .offcanvas .offcanvas-header{display:none}.navbar-expand-xxl .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}}.navbar-expand{flex-wrap:nowrap;justify-content:flex-start}.navbar-expand .navbar-nav{flex-direction:row}.navbar-expand .navbar-nav .dropdown-menu{position:absolute}.navbar-expand .navbar-nav .nav-link{padding-right:var(--bs-navbar-nav-link-padding-x);padding-left:var(--bs-navbar-nav-link-padding-x)}.navbar-expand .navbar-nav-scroll{overflow:visible}.navbar-expand .navbar-collapse{display:flex!important;flex-basis:auto}.navbar-expand .navbar-toggler{display:none}.navbar-expand .offcanvas{position:static;z-index:auto;flex-grow:1;width:auto!important;height:auto!important;visibility:visible!important;background-color:transparent!important;border:0!important;transform:none!important;transition:none}.navbar-expand .offcanvas .offcanvas-header{display:none}.navbar-expand .offcanvas .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible}.navbar-dark,.navbar[data-bs-theme=dark]{--bs-navbar-color:rgba(255, 255, 255, 0.55);--bs-navbar-hover-color:rgba(255, 255, 255, 0.75);--bs-navbar-disabled-color:rgba(255, 255, 255, 0.25);--bs-navbar-active-color:#fff;--bs-navbar-brand-color:#fff;--bs-navbar-brand-hover-color:#fff;--bs-navbar-toggler-border-color:rgba(255, 255, 255, 0.1);--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}[data-bs-theme=dark] .navbar-toggler-icon{--bs-navbar-toggler-icon-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'%3e%3cpath stroke='rgba%28255, 255, 255, 0.55%29' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/%3e%3c/svg%3e")}.card{--bs-card-spacer-y:1rem;--bs-card-spacer-x:1rem;--bs-card-title-spacer-y:0.5rem;--bs-card-title-color: ;--bs-card-subtitle-color: ;--bs-card-border-width:var(--bs-border-width);--bs-card-border-color:var(--bs-border-color-translucent);--bs-card-border-radius:var(--bs-border-radius);--bs-card-box-shadow: ;--bs-card-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-card-cap-padding-y:0.5rem;--bs-card-cap-padding-x:1rem;--bs-card-cap-bg:rgba(var(--bs-body-color-rgb), 0.03);--bs-card-cap-color: ;--bs-card-height: ;--bs-card-color: ;--bs-card-bg:var(--bs-body-bg);--bs-card-img-overlay-padding:1rem;--bs-card-group-margin:0.75rem;position:relative;display:flex;flex-direction:column;min-width:0;height:var(--bs-card-height);color:var(--bs-body-color);word-wrap:break-word;background-color:var(--bs-card-bg);background-clip:border-box;border:var(--bs-card-border-width) solid var(--bs-card-border-color);border-radius:var(--bs-card-border-radius)}.card>hr{margin-right:0;margin-left:0}.card>.list-group{border-top:inherit;border-bottom:inherit}.card>.list-group:first-child{border-top-width:0;border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card>.list-group:last-child{border-bottom-width:0;border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card>.card-header+.list-group,.card>.list-group+.card-footer{border-top:0}.card-body{flex:1 1 auto;padding:var(--bs-card-spacer-y) var(--bs-card-spacer-x);color:var(--bs-card-color)}.card-title{margin-bottom:var(--bs-card-title-spacer-y);color:var(--bs-card-title-color)}.card-subtitle{margin-top:calc(-.5 * var(--bs-card-title-spacer-y));margin-bottom:0;color:var(--bs-card-subtitle-color)}.card-text:last-child{margin-bottom:0}.card-link+.card-link{margin-left:var(--bs-card-spacer-x)}.card-header{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);margin-bottom:0;color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-bottom:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-header:first-child{border-radius:var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius) 0 0}.card-footer{padding:var(--bs-card-cap-padding-y) var(--bs-card-cap-padding-x);color:var(--bs-card-cap-color);background-color:var(--bs-card-cap-bg);border-top:var(--bs-card-border-width) solid var(--bs-card-border-color)}.card-footer:last-child{border-radius:0 0 var(--bs-card-inner-border-radius) var(--bs-card-inner-border-radius)}.card-header-tabs{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-bottom:calc(-1 * var(--bs-card-cap-padding-y));margin-left:calc(-.5 * var(--bs-card-cap-padding-x));border-bottom:0}.card-header-tabs .nav-link.active{background-color:var(--bs-card-bg);border-bottom-color:var(--bs-card-bg)}.card-header-pills{margin-right:calc(-.5 * var(--bs-card-cap-padding-x));margin-left:calc(-.5 * var(--bs-card-cap-padding-x))}.card-img-overlay{position:absolute;top:0;right:0;bottom:0;left:0;padding:var(--bs-card-img-overlay-padding);border-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom,.card-img-top{width:100%}.card-img,.card-img-top{border-top-left-radius:var(--bs-card-inner-border-radius);border-top-right-radius:var(--bs-card-inner-border-radius)}.card-img,.card-img-bottom{border-bottom-right-radius:var(--bs-card-inner-border-radius);border-bottom-left-radius:var(--bs-card-inner-border-radius)}.card-group>.card{margin-bottom:var(--bs-card-group-margin)}@media (min-width:576px){.card-group{display:flex;flex-flow:row wrap}.card-group>.card{flex:1 0 0%;margin-bottom:0}.card-group>.card+.card{margin-left:0;border-left:0}.card-group>.card:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.card-group>.card:not(:last-child) .card-header,.card-group>.card:not(:last-child) .card-img-top{border-top-right-radius:0}.card-group>.card:not(:last-child) .card-footer,.card-group>.card:not(:last-child) .card-img-bottom{border-bottom-right-radius:0}.card-group>.card:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.card-group>.card:not(:first-child) .card-header,.card-group>.card:not(:first-child) .card-img-top{border-top-left-radius:0}.card-group>.card:not(:first-child) .card-footer,.card-group>.card:not(:first-child) .card-img-bottom{border-bottom-left-radius:0}}.accordion{--bs-accordion-color:var(--bs-body-color);--bs-accordion-bg:var(--bs-body-bg);--bs-accordion-transition:color 0.15s ease-in-out,background-color 0.15s ease-in-out,border-color 0.15s ease-in-out,box-shadow 0.15s ease-in-out,border-radius 0.15s ease;--bs-accordion-border-color:var(--bs-border-color);--bs-accordion-border-width:var(--bs-border-width);--bs-accordion-border-radius:var(--bs-border-radius);--bs-accordion-inner-border-radius:calc(var(--bs-border-radius) - (var(--bs-border-width)));--bs-accordion-btn-padding-x:1.25rem;--bs-accordion-btn-padding-y:1rem;--bs-accordion-btn-color:var(--bs-body-color);--bs-accordion-btn-bg:var(--bs-accordion-bg);--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23212529' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-icon-width:1.25rem;--bs-accordion-btn-icon-transform:rotate(-180deg);--bs-accordion-btn-icon-transition:transform 0.2s ease-in-out;--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%23052c65' stroke-linecap='round' stroke-linejoin='round'%3e%3cpath d='M2 5L8 11L14 5'/%3e%3c/svg%3e");--bs-accordion-btn-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-accordion-body-padding-x:1.25rem;--bs-accordion-body-padding-y:1rem;--bs-accordion-active-color:var(--bs-primary-text-emphasis);--bs-accordion-active-bg:var(--bs-primary-bg-subtle)}.accordion-button{position:relative;display:flex;align-items:center;width:100%;padding:var(--bs-accordion-btn-padding-y) var(--bs-accordion-btn-padding-x);font-size:1rem;color:var(--bs-accordion-btn-color);text-align:left;background-color:var(--bs-accordion-btn-bg);border:0;border-radius:0;overflow-anchor:none;transition:var(--bs-accordion-transition)}@media (prefers-reduced-motion:reduce){.accordion-button{transition:none}}.accordion-button:not(.collapsed){color:var(--bs-accordion-active-color);background-color:var(--bs-accordion-active-bg);box-shadow:inset 0 calc(-1 * var(--bs-accordion-border-width)) 0 var(--bs-accordion-border-color)}.accordion-button:not(.collapsed)::after{background-image:var(--bs-accordion-btn-active-icon);transform:var(--bs-accordion-btn-icon-transform)}.accordion-button::after{flex-shrink:0;width:var(--bs-accordion-btn-icon-width);height:var(--bs-accordion-btn-icon-width);margin-left:auto;content:"";background-image:var(--bs-accordion-btn-icon);background-repeat:no-repeat;background-size:var(--bs-accordion-btn-icon-width);transition:var(--bs-accordion-btn-icon-transition)}@media (prefers-reduced-motion:reduce){.accordion-button::after{transition:none}}.accordion-button:hover{z-index:2}.accordion-button:focus{z-index:3;outline:0;box-shadow:var(--bs-accordion-btn-focus-box-shadow)}.accordion-header{margin-bottom:0}.accordion-item{color:var(--bs-accordion-color);background-color:var(--bs-accordion-bg);border:var(--bs-accordion-border-width) solid var(--bs-accordion-border-color)}.accordion-item:first-of-type{border-top-left-radius:var(--bs-accordion-border-radius);border-top-right-radius:var(--bs-accordion-border-radius)}.accordion-item:first-of-type>.accordion-header .accordion-button{border-top-left-radius:var(--bs-accordion-inner-border-radius);border-top-right-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:not(:first-of-type){border-top:0}.accordion-item:last-of-type{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-item:last-of-type>.accordion-header .accordion-button.collapsed{border-bottom-right-radius:var(--bs-accordion-inner-border-radius);border-bottom-left-radius:var(--bs-accordion-inner-border-radius)}.accordion-item:last-of-type>.accordion-collapse{border-bottom-right-radius:var(--bs-accordion-border-radius);border-bottom-left-radius:var(--bs-accordion-border-radius)}.accordion-body{padding:var(--bs-accordion-body-padding-y) var(--bs-accordion-body-padding-x)}.accordion-flush>.accordion-item{border-right:0;border-left:0;border-radius:0}.accordion-flush>.accordion-item:first-child{border-top:0}.accordion-flush>.accordion-item:last-child{border-bottom:0}.accordion-flush>.accordion-item>.accordion-header .accordion-button,.accordion-flush>.accordion-item>.accordion-header .accordion-button.collapsed{border-radius:0}.accordion-flush>.accordion-item>.accordion-collapse{border-radius:0}[data-bs-theme=dark] .accordion-button::after{--bs-accordion-btn-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e");--bs-accordion-btn-active-icon:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%236ea8fe'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.breadcrumb{--bs-breadcrumb-padding-x:0;--bs-breadcrumb-padding-y:0;--bs-breadcrumb-margin-bottom:1rem;--bs-breadcrumb-bg: ;--bs-breadcrumb-border-radius: ;--bs-breadcrumb-divider-color:var(--bs-secondary-color);--bs-breadcrumb-item-padding-x:0.5rem;--bs-breadcrumb-item-active-color:var(--bs-secondary-color);display:flex;flex-wrap:wrap;padding:var(--bs-breadcrumb-padding-y) var(--bs-breadcrumb-padding-x);margin-bottom:var(--bs-breadcrumb-margin-bottom);font-size:var(--bs-breadcrumb-font-size);list-style:none;background-color:var(--bs-breadcrumb-bg);border-radius:var(--bs-breadcrumb-border-radius)}.breadcrumb-item+.breadcrumb-item{padding-left:var(--bs-breadcrumb-item-padding-x)}.breadcrumb-item+.breadcrumb-item::before{float:left;padding-right:var(--bs-breadcrumb-item-padding-x);color:var(--bs-breadcrumb-divider-color);content:var(--bs-breadcrumb-divider, "/")}.breadcrumb-item.active{color:var(--bs-breadcrumb-item-active-color)}.pagination{--bs-pagination-padding-x:0.75rem;--bs-pagination-padding-y:0.375rem;--bs-pagination-font-size:1rem;--bs-pagination-color:var(--bs-link-color);--bs-pagination-bg:var(--bs-body-bg);--bs-pagination-border-width:var(--bs-border-width);--bs-pagination-border-color:var(--bs-border-color);--bs-pagination-border-radius:var(--bs-border-radius);--bs-pagination-hover-color:var(--bs-link-hover-color);--bs-pagination-hover-bg:var(--bs-tertiary-bg);--bs-pagination-hover-border-color:var(--bs-border-color);--bs-pagination-focus-color:var(--bs-link-hover-color);--bs-pagination-focus-bg:var(--bs-secondary-bg);--bs-pagination-focus-box-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-pagination-active-color:#fff;--bs-pagination-active-bg:#0d6efd;--bs-pagination-active-border-color:#0d6efd;--bs-pagination-disabled-color:var(--bs-secondary-color);--bs-pagination-disabled-bg:var(--bs-secondary-bg);--bs-pagination-disabled-border-color:var(--bs-border-color);display:flex;padding-left:0;list-style:none}.page-link{position:relative;display:block;padding:var(--bs-pagination-padding-y) var(--bs-pagination-padding-x);font-size:var(--bs-pagination-font-size);color:var(--bs-pagination-color);text-decoration:none;background-color:var(--bs-pagination-bg);border:var(--bs-pagination-border-width) solid var(--bs-pagination-border-color);transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}@media (prefers-reduced-motion:reduce){.page-link{transition:none}}.page-link:hover{z-index:2;color:var(--bs-pagination-hover-color);background-color:var(--bs-pagination-hover-bg);border-color:var(--bs-pagination-hover-border-color)}.page-link:focus{z-index:3;color:var(--bs-pagination-focus-color);background-color:var(--bs-pagination-focus-bg);outline:0;box-shadow:var(--bs-pagination-focus-box-shadow)}.active>.page-link,.page-link.active{z-index:3;color:var(--bs-pagination-active-color);background-color:var(--bs-pagination-active-bg);border-color:var(--bs-pagination-active-border-color)}.disabled>.page-link,.page-link.disabled{color:var(--bs-pagination-disabled-color);pointer-events:none;background-color:var(--bs-pagination-disabled-bg);border-color:var(--bs-pagination-disabled-border-color)}.page-item:not(:first-child) .page-link{margin-left:calc(var(--bs-border-width) * -1)}.page-item:first-child .page-link{border-top-left-radius:var(--bs-pagination-border-radius);border-bottom-left-radius:var(--bs-pagination-border-radius)}.page-item:last-child .page-link{border-top-right-radius:var(--bs-pagination-border-radius);border-bottom-right-radius:var(--bs-pagination-border-radius)}.pagination-lg{--bs-pagination-padding-x:1.5rem;--bs-pagination-padding-y:0.75rem;--bs-pagination-font-size:1.25rem;--bs-pagination-border-radius:var(--bs-border-radius-lg)}.pagination-sm{--bs-pagination-padding-x:0.5rem;--bs-pagination-padding-y:0.25rem;--bs-pagination-font-size:0.875rem;--bs-pagination-border-radius:var(--bs-border-radius-sm)}.badge{--bs-badge-padding-x:0.65em;--bs-badge-padding-y:0.35em;--bs-badge-font-size:0.75em;--bs-badge-font-weight:700;--bs-badge-color:#fff;--bs-badge-border-radius:var(--bs-border-radius);display:inline-block;padding:var(--bs-badge-padding-y) var(--bs-badge-padding-x);font-size:var(--bs-badge-font-size);font-weight:var(--bs-badge-font-weight);line-height:1;color:var(--bs-badge-color);text-align:center;white-space:nowrap;vertical-align:baseline;border-radius:var(--bs-badge-border-radius)}.badge:empty{display:none}.btn .badge{position:relative;top:-1px}.alert{--bs-alert-bg:transparent;--bs-alert-padding-x:1rem;--bs-alert-padding-y:1rem;--bs-alert-margin-bottom:1rem;--bs-alert-color:inherit;--bs-alert-border-color:transparent;--bs-alert-border:var(--bs-border-width) solid var(--bs-alert-border-color);--bs-alert-border-radius:var(--bs-border-radius);--bs-alert-link-color:inherit;position:relative;padding:var(--bs-alert-padding-y) var(--bs-alert-padding-x);margin-bottom:var(--bs-alert-margin-bottom);color:var(--bs-alert-color);background-color:var(--bs-alert-bg);border:var(--bs-alert-border);border-radius:var(--bs-alert-border-radius)}.alert-heading{color:inherit}.alert-link{font-weight:700;color:var(--bs-alert-link-color)}.alert-dismissible{padding-right:3rem}.alert-dismissible .btn-close{position:absolute;top:0;right:0;z-index:2;padding:1.25rem 1rem}.alert-primary{--bs-alert-color:var(--bs-primary-text-emphasis);--bs-alert-bg:var(--bs-primary-bg-subtle);--bs-alert-border-color:var(--bs-primary-border-subtle);--bs-alert-link-color:var(--bs-primary-text-emphasis)}.alert-secondary{--bs-alert-color:var(--bs-secondary-text-emphasis);--bs-alert-bg:var(--bs-secondary-bg-subtle);--bs-alert-border-color:var(--bs-secondary-border-subtle);--bs-alert-link-color:var(--bs-secondary-text-emphasis)}.alert-success{--bs-alert-color:var(--bs-success-text-emphasis);--bs-alert-bg:var(--bs-success-bg-subtle);--bs-alert-border-color:var(--bs-success-border-subtle);--bs-alert-link-color:var(--bs-success-text-emphasis)}.alert-info{--bs-alert-color:var(--bs-info-text-emphasis);--bs-alert-bg:var(--bs-info-bg-subtle);--bs-alert-border-color:var(--bs-info-border-subtle);--bs-alert-link-color:var(--bs-info-text-emphasis)}.alert-warning{--bs-alert-color:var(--bs-warning-text-emphasis);--bs-alert-bg:var(--bs-warning-bg-subtle);--bs-alert-border-color:var(--bs-warning-border-subtle);--bs-alert-link-color:var(--bs-warning-text-emphasis)}.alert-danger{--bs-alert-color:var(--bs-danger-text-emphasis);--bs-alert-bg:var(--bs-danger-bg-subtle);--bs-alert-border-color:var(--bs-danger-border-subtle);--bs-alert-link-color:var(--bs-danger-text-emphasis)}.alert-light{--bs-alert-color:var(--bs-light-text-emphasis);--bs-alert-bg:var(--bs-light-bg-subtle);--bs-alert-border-color:var(--bs-light-border-subtle);--bs-alert-link-color:var(--bs-light-text-emphasis)}.alert-dark{--bs-alert-color:var(--bs-dark-text-emphasis);--bs-alert-bg:var(--bs-dark-bg-subtle);--bs-alert-border-color:var(--bs-dark-border-subtle);--bs-alert-link-color:var(--bs-dark-text-emphasis)}@keyframes progress-bar-stripes{0%{background-position-x:1rem}}.progress,.progress-stacked{--bs-progress-height:1rem;--bs-progress-font-size:0.75rem;--bs-progress-bg:var(--bs-secondary-bg);--bs-progress-border-radius:var(--bs-border-radius);--bs-progress-box-shadow:var(--bs-box-shadow-inset);--bs-progress-bar-color:#fff;--bs-progress-bar-bg:#0d6efd;--bs-progress-bar-transition:width 0.6s ease;display:flex;height:var(--bs-progress-height);overflow:hidden;font-size:var(--bs-progress-font-size);background-color:var(--bs-progress-bg);border-radius:var(--bs-progress-border-radius)}.progress-bar{display:flex;flex-direction:column;justify-content:center;overflow:hidden;color:var(--bs-progress-bar-color);text-align:center;white-space:nowrap;background-color:var(--bs-progress-bar-bg);transition:var(--bs-progress-bar-transition)}@media (prefers-reduced-motion:reduce){.progress-bar{transition:none}}.progress-bar-striped{background-image:linear-gradient(45deg,rgba(255,255,255,.15) 25%,transparent 25%,transparent 50%,rgba(255,255,255,.15) 50%,rgba(255,255,255,.15) 75%,transparent 75%,transparent);background-size:var(--bs-progress-height) var(--bs-progress-height)}.progress-stacked>.progress{overflow:visible}.progress-stacked>.progress>.progress-bar{width:100%}.progress-bar-animated{animation:1s linear infinite progress-bar-stripes}@media (prefers-reduced-motion:reduce){.progress-bar-animated{animation:none}}.list-group{--bs-list-group-color:var(--bs-body-color);--bs-list-group-bg:var(--bs-body-bg);--bs-list-group-border-color:var(--bs-border-color);--bs-list-group-border-width:var(--bs-border-width);--bs-list-group-border-radius:var(--bs-border-radius);--bs-list-group-item-padding-x:1rem;--bs-list-group-item-padding-y:0.5rem;--bs-list-group-action-color:var(--bs-secondary-color);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-tertiary-bg);--bs-list-group-action-active-color:var(--bs-body-color);--bs-list-group-action-active-bg:var(--bs-secondary-bg);--bs-list-group-disabled-color:var(--bs-secondary-color);--bs-list-group-disabled-bg:var(--bs-body-bg);--bs-list-group-active-color:#fff;--bs-list-group-active-bg:#0d6efd;--bs-list-group-active-border-color:#0d6efd;display:flex;flex-direction:column;padding-left:0;margin-bottom:0;border-radius:var(--bs-list-group-border-radius)}.list-group-numbered{list-style-type:none;counter-reset:section}.list-group-numbered>.list-group-item::before{content:counters(section, ".") ". ";counter-increment:section}.list-group-item-action{width:100%;color:var(--bs-list-group-action-color);text-align:inherit}.list-group-item-action:focus,.list-group-item-action:hover{z-index:1;color:var(--bs-list-group-action-hover-color);text-decoration:none;background-color:var(--bs-list-group-action-hover-bg)}.list-group-item-action:active{color:var(--bs-list-group-action-active-color);background-color:var(--bs-list-group-action-active-bg)}.list-group-item{position:relative;display:block;padding:var(--bs-list-group-item-padding-y) var(--bs-list-group-item-padding-x);color:var(--bs-list-group-color);text-decoration:none;background-color:var(--bs-list-group-bg);border:var(--bs-list-group-border-width) solid var(--bs-list-group-border-color)}.list-group-item:first-child{border-top-left-radius:inherit;border-top-right-radius:inherit}.list-group-item:last-child{border-bottom-right-radius:inherit;border-bottom-left-radius:inherit}.list-group-item.disabled,.list-group-item:disabled{color:var(--bs-list-group-disabled-color);pointer-events:none;background-color:var(--bs-list-group-disabled-bg)}.list-group-item.active{z-index:2;color:var(--bs-list-group-active-color);background-color:var(--bs-list-group-active-bg);border-color:var(--bs-list-group-active-border-color)}.list-group-item+.list-group-item{border-top-width:0}.list-group-item+.list-group-item.active{margin-top:calc(-1 * var(--bs-list-group-border-width));border-top-width:var(--bs-list-group-border-width)}.list-group-horizontal{flex-direction:row}.list-group-horizontal>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal>.list-group-item.active{margin-top:0}.list-group-horizontal>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}@media (min-width:576px){.list-group-horizontal-sm{flex-direction:row}.list-group-horizontal-sm>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-sm>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-sm>.list-group-item.active{margin-top:0}.list-group-horizontal-sm>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-sm>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:768px){.list-group-horizontal-md{flex-direction:row}.list-group-horizontal-md>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-md>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-md>.list-group-item.active{margin-top:0}.list-group-horizontal-md>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-md>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:992px){.list-group-horizontal-lg{flex-direction:row}.list-group-horizontal-lg>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-lg>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-lg>.list-group-item.active{margin-top:0}.list-group-horizontal-lg>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-lg>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1200px){.list-group-horizontal-xl{flex-direction:row}.list-group-horizontal-xl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xl>.list-group-item.active{margin-top:0}.list-group-horizontal-xl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}@media (min-width:1400px){.list-group-horizontal-xxl{flex-direction:row}.list-group-horizontal-xxl>.list-group-item:first-child:not(:last-child){border-bottom-left-radius:var(--bs-list-group-border-radius);border-top-right-radius:0}.list-group-horizontal-xxl>.list-group-item:last-child:not(:first-child){border-top-right-radius:var(--bs-list-group-border-radius);border-bottom-left-radius:0}.list-group-horizontal-xxl>.list-group-item.active{margin-top:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item{border-top-width:var(--bs-list-group-border-width);border-left-width:0}.list-group-horizontal-xxl>.list-group-item+.list-group-item.active{margin-left:calc(-1 * var(--bs-list-group-border-width));border-left-width:var(--bs-list-group-border-width)}}.list-group-flush{border-radius:0}.list-group-flush>.list-group-item{border-width:0 0 var(--bs-list-group-border-width)}.list-group-flush>.list-group-item:last-child{border-bottom-width:0}.list-group-item-primary{--bs-list-group-color:var(--bs-primary-text-emphasis);--bs-list-group-bg:var(--bs-primary-bg-subtle);--bs-list-group-border-color:var(--bs-primary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-primary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-primary-border-subtle);--bs-list-group-active-color:var(--bs-primary-bg-subtle);--bs-list-group-active-bg:var(--bs-primary-text-emphasis);--bs-list-group-active-border-color:var(--bs-primary-text-emphasis)}.list-group-item-secondary{--bs-list-group-color:var(--bs-secondary-text-emphasis);--bs-list-group-bg:var(--bs-secondary-bg-subtle);--bs-list-group-border-color:var(--bs-secondary-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-secondary-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-secondary-border-subtle);--bs-list-group-active-color:var(--bs-secondary-bg-subtle);--bs-list-group-active-bg:var(--bs-secondary-text-emphasis);--bs-list-group-active-border-color:var(--bs-secondary-text-emphasis)}.list-group-item-success{--bs-list-group-color:var(--bs-success-text-emphasis);--bs-list-group-bg:var(--bs-success-bg-subtle);--bs-list-group-border-color:var(--bs-success-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-success-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-success-border-subtle);--bs-list-group-active-color:var(--bs-success-bg-subtle);--bs-list-group-active-bg:var(--bs-success-text-emphasis);--bs-list-group-active-border-color:var(--bs-success-text-emphasis)}.list-group-item-info{--bs-list-group-color:var(--bs-info-text-emphasis);--bs-list-group-bg:var(--bs-info-bg-subtle);--bs-list-group-border-color:var(--bs-info-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-info-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-info-border-subtle);--bs-list-group-active-color:var(--bs-info-bg-subtle);--bs-list-group-active-bg:var(--bs-info-text-emphasis);--bs-list-group-active-border-color:var(--bs-info-text-emphasis)}.list-group-item-warning{--bs-list-group-color:var(--bs-warning-text-emphasis);--bs-list-group-bg:var(--bs-warning-bg-subtle);--bs-list-group-border-color:var(--bs-warning-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-warning-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-warning-border-subtle);--bs-list-group-active-color:var(--bs-warning-bg-subtle);--bs-list-group-active-bg:var(--bs-warning-text-emphasis);--bs-list-group-active-border-color:var(--bs-warning-text-emphasis)}.list-group-item-danger{--bs-list-group-color:var(--bs-danger-text-emphasis);--bs-list-group-bg:var(--bs-danger-bg-subtle);--bs-list-group-border-color:var(--bs-danger-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-danger-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-danger-border-subtle);--bs-list-group-active-color:var(--bs-danger-bg-subtle);--bs-list-group-active-bg:var(--bs-danger-text-emphasis);--bs-list-group-active-border-color:var(--bs-danger-text-emphasis)}.list-group-item-light{--bs-list-group-color:var(--bs-light-text-emphasis);--bs-list-group-bg:var(--bs-light-bg-subtle);--bs-list-group-border-color:var(--bs-light-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-light-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-light-border-subtle);--bs-list-group-active-color:var(--bs-light-bg-subtle);--bs-list-group-active-bg:var(--bs-light-text-emphasis);--bs-list-group-active-border-color:var(--bs-light-text-emphasis)}.list-group-item-dark{--bs-list-group-color:var(--bs-dark-text-emphasis);--bs-list-group-bg:var(--bs-dark-bg-subtle);--bs-list-group-border-color:var(--bs-dark-border-subtle);--bs-list-group-action-hover-color:var(--bs-emphasis-color);--bs-list-group-action-hover-bg:var(--bs-dark-border-subtle);--bs-list-group-action-active-color:var(--bs-emphasis-color);--bs-list-group-action-active-bg:var(--bs-dark-border-subtle);--bs-list-group-active-color:var(--bs-dark-bg-subtle);--bs-list-group-active-bg:var(--bs-dark-text-emphasis);--bs-list-group-active-border-color:var(--bs-dark-text-emphasis)}.btn-close{--bs-btn-close-color:#000;--bs-btn-close-bg:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23000'%3e%3cpath d='M.293.293a1 1 0 0 1 1.414 0L8 6.586 14.293.293a1 1 0 1 1 1.414 1.414L9.414 8l6.293 6.293a1 1 0 0 1-1.414 1.414L8 9.414l-6.293 6.293a1 1 0 0 1-1.414-1.414L6.586 8 .293 1.707a1 1 0 0 1 0-1.414z'/%3e%3c/svg%3e");--bs-btn-close-opacity:0.5;--bs-btn-close-hover-opacity:0.75;--bs-btn-close-focus-shadow:0 0 0 0.25rem rgba(13, 110, 253, 0.25);--bs-btn-close-focus-opacity:1;--bs-btn-close-disabled-opacity:0.25;--bs-btn-close-white-filter:invert(1) grayscale(100%) brightness(200%);box-sizing:content-box;width:1em;height:1em;padding:.25em .25em;color:var(--bs-btn-close-color);background:transparent var(--bs-btn-close-bg) center/1em auto no-repeat;border:0;border-radius:.375rem;opacity:var(--bs-btn-close-opacity)}.btn-close:hover{color:var(--bs-btn-close-color);text-decoration:none;opacity:var(--bs-btn-close-hover-opacity)}.btn-close:focus{outline:0;box-shadow:var(--bs-btn-close-focus-shadow);opacity:var(--bs-btn-close-focus-opacity)}.btn-close.disabled,.btn-close:disabled{pointer-events:none;-webkit-user-select:none;-moz-user-select:none;user-select:none;opacity:var(--bs-btn-close-disabled-opacity)}.btn-close-white{filter:var(--bs-btn-close-white-filter)}[data-bs-theme=dark] .btn-close{filter:var(--bs-btn-close-white-filter)}.toast{--bs-toast-zindex:1090;--bs-toast-padding-x:0.75rem;--bs-toast-padding-y:0.5rem;--bs-toast-spacing:1.5rem;--bs-toast-max-width:350px;--bs-toast-font-size:0.875rem;--bs-toast-color: ;--bs-toast-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-border-width:var(--bs-border-width);--bs-toast-border-color:var(--bs-border-color-translucent);--bs-toast-border-radius:var(--bs-border-radius);--bs-toast-box-shadow:var(--bs-box-shadow);--bs-toast-header-color:var(--bs-secondary-color);--bs-toast-header-bg:rgba(var(--bs-body-bg-rgb), 0.85);--bs-toast-header-border-color:var(--bs-border-color-translucent);width:var(--bs-toast-max-width);max-width:100%;font-size:var(--bs-toast-font-size);color:var(--bs-toast-color);pointer-events:auto;background-color:var(--bs-toast-bg);background-clip:padding-box;border:var(--bs-toast-border-width) solid var(--bs-toast-border-color);box-shadow:var(--bs-toast-box-shadow);border-radius:var(--bs-toast-border-radius)}.toast.showing{opacity:0}.toast:not(.show){display:none}.toast-container{--bs-toast-zindex:1090;position:absolute;z-index:var(--bs-toast-zindex);width:-webkit-max-content;width:-moz-max-content;width:max-content;max-width:100%;pointer-events:none}.toast-container>:not(:last-child){margin-bottom:var(--bs-toast-spacing)}.toast-header{display:flex;align-items:center;padding:var(--bs-toast-padding-y) var(--bs-toast-padding-x);color:var(--bs-toast-header-color);background-color:var(--bs-toast-header-bg);background-clip:padding-box;border-bottom:var(--bs-toast-border-width) solid var(--bs-toast-header-border-color);border-top-left-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width));border-top-right-radius:calc(var(--bs-toast-border-radius) - var(--bs-toast-border-width))}.toast-header .btn-close{margin-right:calc(-.5 * var(--bs-toast-padding-x));margin-left:var(--bs-toast-padding-x)}.toast-body{padding:var(--bs-toast-padding-x);word-wrap:break-word}.modal{--bs-modal-zindex:1055;--bs-modal-width:500px;--bs-modal-padding:1rem;--bs-modal-margin:0.5rem;--bs-modal-color: ;--bs-modal-bg:var(--bs-body-bg);--bs-modal-border-color:var(--bs-border-color-translucent);--bs-modal-border-width:var(--bs-border-width);--bs-modal-border-radius:var(--bs-border-radius-lg);--bs-modal-box-shadow:var(--bs-box-shadow-sm);--bs-modal-inner-border-radius:calc(var(--bs-border-radius-lg) - (var(--bs-border-width)));--bs-modal-header-padding-x:1rem;--bs-modal-header-padding-y:1rem;--bs-modal-header-padding:1rem 1rem;--bs-modal-header-border-color:var(--bs-border-color);--bs-modal-header-border-width:var(--bs-border-width);--bs-modal-title-line-height:1.5;--bs-modal-footer-gap:0.5rem;--bs-modal-footer-bg: ;--bs-modal-footer-border-color:var(--bs-border-color);--bs-modal-footer-border-width:var(--bs-border-width);position:fixed;top:0;left:0;z-index:var(--bs-modal-zindex);display:none;width:100%;height:100%;overflow-x:hidden;overflow-y:auto;outline:0}.modal-dialog{position:relative;width:auto;margin:var(--bs-modal-margin);pointer-events:none}.modal.fade .modal-dialog{transition:transform .3s ease-out;transform:translate(0,-50px)}@media (prefers-reduced-motion:reduce){.modal.fade .modal-dialog{transition:none}}.modal.show .modal-dialog{transform:none}.modal.modal-static .modal-dialog{transform:scale(1.02)}.modal-dialog-scrollable{height:calc(100% - var(--bs-modal-margin) * 2)}.modal-dialog-scrollable .modal-content{max-height:100%;overflow:hidden}.modal-dialog-scrollable .modal-body{overflow-y:auto}.modal-dialog-centered{display:flex;align-items:center;min-height:calc(100% - var(--bs-modal-margin) * 2)}.modal-content{position:relative;display:flex;flex-direction:column;width:100%;color:var(--bs-modal-color);pointer-events:auto;background-color:var(--bs-modal-bg);background-clip:padding-box;border:var(--bs-modal-border-width) solid var(--bs-modal-border-color);border-radius:var(--bs-modal-border-radius);outline:0}.modal-backdrop{--bs-backdrop-zindex:1050;--bs-backdrop-bg:#000;--bs-backdrop-opacity:0.5;position:fixed;top:0;left:0;z-index:var(--bs-backdrop-zindex);width:100vw;height:100vh;background-color:var(--bs-backdrop-bg)}.modal-backdrop.fade{opacity:0}.modal-backdrop.show{opacity:var(--bs-backdrop-opacity)}.modal-header{display:flex;flex-shrink:0;align-items:center;padding:var(--bs-modal-header-padding);border-bottom:var(--bs-modal-header-border-width) solid var(--bs-modal-header-border-color);border-top-left-radius:var(--bs-modal-inner-border-radius);border-top-right-radius:var(--bs-modal-inner-border-radius)}.modal-header .btn-close{padding:calc(var(--bs-modal-header-padding-y) * .5) calc(var(--bs-modal-header-padding-x) * .5);margin:calc(-.5 * var(--bs-modal-header-padding-y)) calc(-.5 * var(--bs-modal-header-padding-x)) calc(-.5 * var(--bs-modal-header-padding-y)) auto}.modal-title{margin-bottom:0;line-height:var(--bs-modal-title-line-height)}.modal-body{position:relative;flex:1 1 auto;padding:var(--bs-modal-padding)}.modal-footer{display:flex;flex-shrink:0;flex-wrap:wrap;align-items:center;justify-content:flex-end;padding:calc(var(--bs-modal-padding) - var(--bs-modal-footer-gap) * .5);background-color:var(--bs-modal-footer-bg);border-top:var(--bs-modal-footer-border-width) solid var(--bs-modal-footer-border-color);border-bottom-right-radius:var(--bs-modal-inner-border-radius);border-bottom-left-radius:var(--bs-modal-inner-border-radius)}.modal-footer>*{margin:calc(var(--bs-modal-footer-gap) * .5)}@media (min-width:576px){.modal{--bs-modal-margin:1.75rem;--bs-modal-box-shadow:var(--bs-box-shadow)}.modal-dialog{max-width:var(--bs-modal-width);margin-right:auto;margin-left:auto}.modal-sm{--bs-modal-width:300px}}@media (min-width:992px){.modal-lg,.modal-xl{--bs-modal-width:800px}}@media (min-width:1200px){.modal-xl{--bs-modal-width:1140px}}.modal-fullscreen{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen .modal-footer,.modal-fullscreen .modal-header{border-radius:0}.modal-fullscreen .modal-body{overflow-y:auto}@media (max-width:575.98px){.modal-fullscreen-sm-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-sm-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-sm-down .modal-footer,.modal-fullscreen-sm-down .modal-header{border-radius:0}.modal-fullscreen-sm-down .modal-body{overflow-y:auto}}@media (max-width:767.98px){.modal-fullscreen-md-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-md-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-md-down .modal-footer,.modal-fullscreen-md-down .modal-header{border-radius:0}.modal-fullscreen-md-down .modal-body{overflow-y:auto}}@media (max-width:991.98px){.modal-fullscreen-lg-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-lg-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-lg-down .modal-footer,.modal-fullscreen-lg-down .modal-header{border-radius:0}.modal-fullscreen-lg-down .modal-body{overflow-y:auto}}@media (max-width:1199.98px){.modal-fullscreen-xl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xl-down .modal-footer,.modal-fullscreen-xl-down .modal-header{border-radius:0}.modal-fullscreen-xl-down .modal-body{overflow-y:auto}}@media (max-width:1399.98px){.modal-fullscreen-xxl-down{width:100vw;max-width:none;height:100%;margin:0}.modal-fullscreen-xxl-down .modal-content{height:100%;border:0;border-radius:0}.modal-fullscreen-xxl-down .modal-footer,.modal-fullscreen-xxl-down .modal-header{border-radius:0}.modal-fullscreen-xxl-down .modal-body{overflow-y:auto}}.tooltip{--bs-tooltip-zindex:1080;--bs-tooltip-max-width:200px;--bs-tooltip-padding-x:0.5rem;--bs-tooltip-padding-y:0.25rem;--bs-tooltip-margin: ;--bs-tooltip-font-size:0.875rem;--bs-tooltip-color:var(--bs-body-bg);--bs-tooltip-bg:var(--bs-emphasis-color);--bs-tooltip-border-radius:var(--bs-border-radius);--bs-tooltip-opacity:0.9;--bs-tooltip-arrow-width:0.8rem;--bs-tooltip-arrow-height:0.4rem;z-index:var(--bs-tooltip-zindex);display:block;margin:var(--bs-tooltip-margin);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-tooltip-font-size);word-wrap:break-word;opacity:0}.tooltip.show{opacity:var(--bs-tooltip-opacity)}.tooltip .tooltip-arrow{display:block;width:var(--bs-tooltip-arrow-width);height:var(--bs-tooltip-arrow-height)}.tooltip .tooltip-arrow::before{position:absolute;content:"";border-color:transparent;border-style:solid}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow,.bs-tooltip-top .tooltip-arrow{bottom:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before,.bs-tooltip-top .tooltip-arrow::before{top:-1px;border-width:var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-top-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow,.bs-tooltip-end .tooltip-arrow{left:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before,.bs-tooltip-end .tooltip-arrow::before{right:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * .5) 0;border-right-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow,.bs-tooltip-bottom .tooltip-arrow{top:calc(-1 * var(--bs-tooltip-arrow-height))}.bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before,.bs-tooltip-bottom .tooltip-arrow::before{bottom:-1px;border-width:0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-bottom-color:var(--bs-tooltip-bg)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow,.bs-tooltip-start .tooltip-arrow{right:calc(-1 * var(--bs-tooltip-arrow-height));width:var(--bs-tooltip-arrow-height);height:var(--bs-tooltip-arrow-width)}.bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before,.bs-tooltip-start .tooltip-arrow::before{left:-1px;border-width:calc(var(--bs-tooltip-arrow-width) * .5) 0 calc(var(--bs-tooltip-arrow-width) * .5) var(--bs-tooltip-arrow-height);border-left-color:var(--bs-tooltip-bg)}.tooltip-inner{max-width:var(--bs-tooltip-max-width);padding:var(--bs-tooltip-padding-y) var(--bs-tooltip-padding-x);color:var(--bs-tooltip-color);text-align:center;background-color:var(--bs-tooltip-bg);border-radius:var(--bs-tooltip-border-radius)}.popover{--bs-popover-zindex:1070;--bs-popover-max-width:276px;--bs-popover-font-size:0.875rem;--bs-popover-bg:var(--bs-body-bg);--bs-popover-border-width:var(--bs-border-width);--bs-popover-border-color:var(--bs-border-color-translucent);--bs-popover-border-radius:var(--bs-border-radius-lg);--bs-popover-inner-border-radius:calc(var(--bs-border-radius-lg) - var(--bs-border-width));--bs-popover-box-shadow:var(--bs-box-shadow);--bs-popover-header-padding-x:1rem;--bs-popover-header-padding-y:0.5rem;--bs-popover-header-font-size:1rem;--bs-popover-header-color:inherit;--bs-popover-header-bg:var(--bs-secondary-bg);--bs-popover-body-padding-x:1rem;--bs-popover-body-padding-y:1rem;--bs-popover-body-color:var(--bs-body-color);--bs-popover-arrow-width:1rem;--bs-popover-arrow-height:0.5rem;--bs-popover-arrow-border:var(--bs-popover-border-color);z-index:var(--bs-popover-zindex);display:block;max-width:var(--bs-popover-max-width);font-family:var(--bs-font-sans-serif);font-style:normal;font-weight:400;line-height:1.5;text-align:left;text-align:start;text-decoration:none;text-shadow:none;text-transform:none;letter-spacing:normal;word-break:normal;white-space:normal;word-spacing:normal;line-break:auto;font-size:var(--bs-popover-font-size);word-wrap:break-word;background-color:var(--bs-popover-bg);background-clip:padding-box;border:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-radius:var(--bs-popover-border-radius)}.popover .popover-arrow{display:block;width:var(--bs-popover-arrow-width);height:var(--bs-popover-arrow-height)}.popover .popover-arrow::after,.popover .popover-arrow::before{position:absolute;display:block;content:"";border-color:transparent;border-style:solid;border-width:0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow,.bs-popover-top>.popover-arrow{bottom:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::after,.bs-popover-top>.popover-arrow::before{border-width:var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::before,.bs-popover-top>.popover-arrow::before{bottom:0;border-top-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=top]>.popover-arrow::after,.bs-popover-top>.popover-arrow::after{bottom:var(--bs-popover-border-width);border-top-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow,.bs-popover-end>.popover-arrow{left:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::after,.bs-popover-end>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * .5) 0}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::before,.bs-popover-end>.popover-arrow::before{left:0;border-right-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=right]>.popover-arrow::after,.bs-popover-end>.popover-arrow::after{left:var(--bs-popover-border-width);border-right-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow,.bs-popover-bottom>.popover-arrow{top:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width))}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::before{border-width:0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::before,.bs-popover-bottom>.popover-arrow::before{top:0;border-bottom-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=bottom]>.popover-arrow::after,.bs-popover-bottom>.popover-arrow::after{top:var(--bs-popover-border-width);border-bottom-color:var(--bs-popover-bg)}.bs-popover-auto[data-popper-placement^=bottom] .popover-header::before,.bs-popover-bottom .popover-header::before{position:absolute;top:0;left:50%;display:block;width:var(--bs-popover-arrow-width);margin-left:calc(-.5 * var(--bs-popover-arrow-width));content:"";border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-header-bg)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow,.bs-popover-start>.popover-arrow{right:calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width));width:var(--bs-popover-arrow-height);height:var(--bs-popover-arrow-width)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::after,.bs-popover-start>.popover-arrow::before{border-width:calc(var(--bs-popover-arrow-width) * .5) 0 calc(var(--bs-popover-arrow-width) * .5) var(--bs-popover-arrow-height)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::before,.bs-popover-start>.popover-arrow::before{right:0;border-left-color:var(--bs-popover-arrow-border)}.bs-popover-auto[data-popper-placement^=left]>.popover-arrow::after,.bs-popover-start>.popover-arrow::after{right:var(--bs-popover-border-width);border-left-color:var(--bs-popover-bg)}.popover-header{padding:var(--bs-popover-header-padding-y) var(--bs-popover-header-padding-x);margin-bottom:0;font-size:var(--bs-popover-header-font-size);color:var(--bs-popover-header-color);background-color:var(--bs-popover-header-bg);border-bottom:var(--bs-popover-border-width) solid var(--bs-popover-border-color);border-top-left-radius:var(--bs-popover-inner-border-radius);border-top-right-radius:var(--bs-popover-inner-border-radius)}.popover-header:empty{display:none}.popover-body{padding:var(--bs-popover-body-padding-y) var(--bs-popover-body-padding-x);color:var(--bs-popover-body-color)}.carousel{position:relative}.carousel.pointer-event{touch-action:pan-y}.carousel-inner{position:relative;width:100%;overflow:hidden}.carousel-inner::after{display:block;clear:both;content:""}.carousel-item{position:relative;display:none;float:left;width:100%;margin-right:-100%;-webkit-backface-visibility:hidden;backface-visibility:hidden;transition:transform .6s ease-in-out}@media (prefers-reduced-motion:reduce){.carousel-item{transition:none}}.carousel-item-next,.carousel-item-prev,.carousel-item.active{display:block}.active.carousel-item-end,.carousel-item-next:not(.carousel-item-start){transform:translateX(100%)}.active.carousel-item-start,.carousel-item-prev:not(.carousel-item-end){transform:translateX(-100%)}.carousel-fade .carousel-item{opacity:0;transition-property:opacity;transform:none}.carousel-fade .carousel-item-next.carousel-item-start,.carousel-fade .carousel-item-prev.carousel-item-end,.carousel-fade .carousel-item.active{z-index:1;opacity:1}.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{z-index:0;opacity:0;transition:opacity 0s .6s}@media (prefers-reduced-motion:reduce){.carousel-fade .active.carousel-item-end,.carousel-fade .active.carousel-item-start{transition:none}}.carousel-control-next,.carousel-control-prev{position:absolute;top:0;bottom:0;z-index:1;display:flex;align-items:center;justify-content:center;width:15%;padding:0;color:#fff;text-align:center;background:0 0;border:0;opacity:.5;transition:opacity .15s ease}@media (prefers-reduced-motion:reduce){.carousel-control-next,.carousel-control-prev{transition:none}}.carousel-control-next:focus,.carousel-control-next:hover,.carousel-control-prev:focus,.carousel-control-prev:hover{color:#fff;text-decoration:none;outline:0;opacity:.9}.carousel-control-prev{left:0}.carousel-control-next{right:0}.carousel-control-next-icon,.carousel-control-prev-icon{display:inline-block;width:2rem;height:2rem;background-repeat:no-repeat;background-position:50%;background-size:100% 100%}.carousel-control-prev-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M11.354 1.646a.5.5 0 0 1 0 .708L5.707 8l5.647 5.646a.5.5 0 0 1-.708.708l-6-6a.5.5 0 0 1 0-.708l6-6a.5.5 0 0 1 .708 0z'/%3e%3c/svg%3e")}.carousel-control-next-icon{background-image:url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='%23fff'%3e%3cpath d='M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e")}.carousel-indicators{position:absolute;right:0;bottom:0;left:0;z-index:2;display:flex;justify-content:center;padding:0;margin-right:15%;margin-bottom:1rem;margin-left:15%}.carousel-indicators [data-bs-target]{box-sizing:content-box;flex:0 1 auto;width:30px;height:3px;padding:0;margin-right:3px;margin-left:3px;text-indent:-999px;cursor:pointer;background-color:#fff;background-clip:padding-box;border:0;border-top:10px solid transparent;border-bottom:10px solid transparent;opacity:.5;transition:opacity .6s ease}@media (prefers-reduced-motion:reduce){.carousel-indicators [data-bs-target]{transition:none}}.carousel-indicators .active{opacity:1}.carousel-caption{position:absolute;right:15%;bottom:1.25rem;left:15%;padding-top:1.25rem;padding-bottom:1.25rem;color:#fff;text-align:center}.carousel-dark .carousel-control-next-icon,.carousel-dark .carousel-control-prev-icon{filter:invert(1) grayscale(100)}.carousel-dark .carousel-indicators [data-bs-target]{background-color:#000}.carousel-dark .carousel-caption{color:#000}[data-bs-theme=dark] .carousel .carousel-control-next-icon,[data-bs-theme=dark] .carousel .carousel-control-prev-icon,[data-bs-theme=dark].carousel .carousel-control-next-icon,[data-bs-theme=dark].carousel .carousel-control-prev-icon{filter:invert(1) grayscale(100)}[data-bs-theme=dark] .carousel .carousel-indicators [data-bs-target],[data-bs-theme=dark].carousel .carousel-indicators [data-bs-target]{background-color:#000}[data-bs-theme=dark] .carousel .carousel-caption,[data-bs-theme=dark].carousel .carousel-caption{color:#000}.spinner-border,.spinner-grow{display:inline-block;width:var(--bs-spinner-width);height:var(--bs-spinner-height);vertical-align:var(--bs-spinner-vertical-align);border-radius:50%;animation:var(--bs-spinner-animation-speed) linear infinite var(--bs-spinner-animation-name)}@keyframes spinner-border{to{transform:rotate(360deg)}}.spinner-border{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-border-width:0.25em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-border;border:var(--bs-spinner-border-width) solid currentcolor;border-right-color:transparent}.spinner-border-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem;--bs-spinner-border-width:0.2em}@keyframes spinner-grow{0%{transform:scale(0)}50%{opacity:1;transform:none}}.spinner-grow{--bs-spinner-width:2rem;--bs-spinner-height:2rem;--bs-spinner-vertical-align:-0.125em;--bs-spinner-animation-speed:0.75s;--bs-spinner-animation-name:spinner-grow;background-color:currentcolor;opacity:0}.spinner-grow-sm{--bs-spinner-width:1rem;--bs-spinner-height:1rem}@media (prefers-reduced-motion:reduce){.spinner-border,.spinner-grow{--bs-spinner-animation-speed:1.5s}}.offcanvas,.offcanvas-lg,.offcanvas-md,.offcanvas-sm,.offcanvas-xl,.offcanvas-xxl{--bs-offcanvas-zindex:1045;--bs-offcanvas-width:400px;--bs-offcanvas-height:30vh;--bs-offcanvas-padding-x:1rem;--bs-offcanvas-padding-y:1rem;--bs-offcanvas-color:var(--bs-body-color);--bs-offcanvas-bg:var(--bs-body-bg);--bs-offcanvas-border-width:var(--bs-border-width);--bs-offcanvas-border-color:var(--bs-border-color-translucent);--bs-offcanvas-box-shadow:var(--bs-box-shadow-sm);--bs-offcanvas-transition:transform 0.3s ease-in-out;--bs-offcanvas-title-line-height:1.5}@media (max-width:575.98px){.offcanvas-sm{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:575.98px) and (prefers-reduced-motion:reduce){.offcanvas-sm{transition:none}}@media (max-width:575.98px){.offcanvas-sm.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-sm.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-sm.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-sm.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-sm.show:not(.hiding),.offcanvas-sm.showing{transform:none}.offcanvas-sm.hiding,.offcanvas-sm.show,.offcanvas-sm.showing{visibility:visible}}@media (min-width:576px){.offcanvas-sm{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-sm .offcanvas-header{display:none}.offcanvas-sm .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:767.98px){.offcanvas-md{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:767.98px) and (prefers-reduced-motion:reduce){.offcanvas-md{transition:none}}@media (max-width:767.98px){.offcanvas-md.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-md.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-md.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-md.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-md.show:not(.hiding),.offcanvas-md.showing{transform:none}.offcanvas-md.hiding,.offcanvas-md.show,.offcanvas-md.showing{visibility:visible}}@media (min-width:768px){.offcanvas-md{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-md .offcanvas-header{display:none}.offcanvas-md .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:991.98px){.offcanvas-lg{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:991.98px) and (prefers-reduced-motion:reduce){.offcanvas-lg{transition:none}}@media (max-width:991.98px){.offcanvas-lg.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-lg.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-lg.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-lg.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-lg.show:not(.hiding),.offcanvas-lg.showing{transform:none}.offcanvas-lg.hiding,.offcanvas-lg.show,.offcanvas-lg.showing{visibility:visible}}@media (min-width:992px){.offcanvas-lg{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-lg .offcanvas-header{display:none}.offcanvas-lg .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1199.98px){.offcanvas-xl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1199.98px) and (prefers-reduced-motion:reduce){.offcanvas-xl{transition:none}}@media (max-width:1199.98px){.offcanvas-xl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xl.show:not(.hiding),.offcanvas-xl.showing{transform:none}.offcanvas-xl.hiding,.offcanvas-xl.show,.offcanvas-xl.showing{visibility:visible}}@media (min-width:1200px){.offcanvas-xl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xl .offcanvas-header{display:none}.offcanvas-xl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}@media (max-width:1399.98px){.offcanvas-xxl{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}}@media (max-width:1399.98px) and (prefers-reduced-motion:reduce){.offcanvas-xxl{transition:none}}@media (max-width:1399.98px){.offcanvas-xxl.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas-xxl.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas-xxl.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas-xxl.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas-xxl.show:not(.hiding),.offcanvas-xxl.showing{transform:none}.offcanvas-xxl.hiding,.offcanvas-xxl.show,.offcanvas-xxl.showing{visibility:visible}}@media (min-width:1400px){.offcanvas-xxl{--bs-offcanvas-height:auto;--bs-offcanvas-border-width:0;background-color:transparent!important}.offcanvas-xxl .offcanvas-header{display:none}.offcanvas-xxl .offcanvas-body{display:flex;flex-grow:0;padding:0;overflow-y:visible;background-color:transparent!important}}.offcanvas{position:fixed;bottom:0;z-index:var(--bs-offcanvas-zindex);display:flex;flex-direction:column;max-width:100%;color:var(--bs-offcanvas-color);visibility:hidden;background-color:var(--bs-offcanvas-bg);background-clip:padding-box;outline:0;transition:var(--bs-offcanvas-transition)}@media (prefers-reduced-motion:reduce){.offcanvas{transition:none}}.offcanvas.offcanvas-start{top:0;left:0;width:var(--bs-offcanvas-width);border-right:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(-100%)}.offcanvas.offcanvas-end{top:0;right:0;width:var(--bs-offcanvas-width);border-left:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateX(100%)}.offcanvas.offcanvas-top{top:0;right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-bottom:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(-100%)}.offcanvas.offcanvas-bottom{right:0;left:0;height:var(--bs-offcanvas-height);max-height:100%;border-top:var(--bs-offcanvas-border-width) solid var(--bs-offcanvas-border-color);transform:translateY(100%)}.offcanvas.show:not(.hiding),.offcanvas.showing{transform:none}.offcanvas.hiding,.offcanvas.show,.offcanvas.showing{visibility:visible}.offcanvas-backdrop{position:fixed;top:0;left:0;z-index:1040;width:100vw;height:100vh;background-color:#000}.offcanvas-backdrop.fade{opacity:0}.offcanvas-backdrop.show{opacity:.5}.offcanvas-header{display:flex;align-items:center;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x)}.offcanvas-header .btn-close{padding:calc(var(--bs-offcanvas-padding-y) * .5) calc(var(--bs-offcanvas-padding-x) * .5);margin:calc(-.5 * var(--bs-offcanvas-padding-y)) calc(-.5 * var(--bs-offcanvas-padding-x)) calc(-.5 * var(--bs-offcanvas-padding-y)) auto}.offcanvas-title{margin-bottom:0;line-height:var(--bs-offcanvas-title-line-height)}.offcanvas-body{flex-grow:1;padding:var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x);overflow-y:auto}.placeholder{display:inline-block;min-height:1em;vertical-align:middle;cursor:wait;background-color:currentcolor;opacity:.5}.placeholder.btn::before{display:inline-block;content:""}.placeholder-xs{min-height:.6em}.placeholder-sm{min-height:.8em}.placeholder-lg{min-height:1.2em}.placeholder-glow .placeholder{animation:placeholder-glow 2s ease-in-out infinite}@keyframes placeholder-glow{50%{opacity:.2}}.placeholder-wave{-webkit-mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);mask-image:linear-gradient(130deg,#000 55%,rgba(0,0,0,0.8) 75%,#000 95%);-webkit-mask-size:200% 100%;mask-size:200% 100%;animation:placeholder-wave 2s linear infinite}@keyframes placeholder-wave{100%{-webkit-mask-position:-200% 0%;mask-position:-200% 0%}}.clearfix::after{display:block;clear:both;content:""}.text-bg-primary{color:#fff!important;background-color:RGBA(var(--bs-primary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-secondary{color:#fff!important;background-color:RGBA(var(--bs-secondary-rgb),var(--bs-bg-opacity,1))!important}.text-bg-success{color:#fff!important;background-color:RGBA(var(--bs-success-rgb),var(--bs-bg-opacity,1))!important}.text-bg-info{color:#000!important;background-color:RGBA(var(--bs-info-rgb),var(--bs-bg-opacity,1))!important}.text-bg-warning{color:#000!important;background-color:RGBA(var(--bs-warning-rgb),var(--bs-bg-opacity,1))!important}.text-bg-danger{color:#fff!important;background-color:RGBA(var(--bs-danger-rgb),var(--bs-bg-opacity,1))!important}.text-bg-light{color:#000!important;background-color:RGBA(var(--bs-light-rgb),var(--bs-bg-opacity,1))!important}.text-bg-dark{color:#fff!important;background-color:RGBA(var(--bs-dark-rgb),var(--bs-bg-opacity,1))!important}.link-primary{color:RGBA(var(--bs-primary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-primary-rgb),var(--bs-link-underline-opacity,1))!important}.link-primary:focus,.link-primary:hover{color:RGBA(10,88,202,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(10,88,202,var(--bs-link-underline-opacity,1))!important}.link-secondary{color:RGBA(var(--bs-secondary-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-secondary-rgb),var(--bs-link-underline-opacity,1))!important}.link-secondary:focus,.link-secondary:hover{color:RGBA(86,94,100,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(86,94,100,var(--bs-link-underline-opacity,1))!important}.link-success{color:RGBA(var(--bs-success-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-success-rgb),var(--bs-link-underline-opacity,1))!important}.link-success:focus,.link-success:hover{color:RGBA(20,108,67,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(20,108,67,var(--bs-link-underline-opacity,1))!important}.link-info{color:RGBA(var(--bs-info-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-info-rgb),var(--bs-link-underline-opacity,1))!important}.link-info:focus,.link-info:hover{color:RGBA(61,213,243,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(61,213,243,var(--bs-link-underline-opacity,1))!important}.link-warning{color:RGBA(var(--bs-warning-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-warning-rgb),var(--bs-link-underline-opacity,1))!important}.link-warning:focus,.link-warning:hover{color:RGBA(255,205,57,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(255,205,57,var(--bs-link-underline-opacity,1))!important}.link-danger{color:RGBA(var(--bs-danger-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-danger-rgb),var(--bs-link-underline-opacity,1))!important}.link-danger:focus,.link-danger:hover{color:RGBA(176,42,55,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(176,42,55,var(--bs-link-underline-opacity,1))!important}.link-light{color:RGBA(var(--bs-light-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-light-rgb),var(--bs-link-underline-opacity,1))!important}.link-light:focus,.link-light:hover{color:RGBA(249,250,251,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(249,250,251,var(--bs-link-underline-opacity,1))!important}.link-dark{color:RGBA(var(--bs-dark-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-dark-rgb),var(--bs-link-underline-opacity,1))!important}.link-dark:focus,.link-dark:hover{color:RGBA(26,30,33,var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(26,30,33,var(--bs-link-underline-opacity,1))!important}.link-body-emphasis{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,1))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-body-emphasis:focus,.link-body-emphasis:hover{color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-opacity,.75))!important;-webkit-text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important;text-decoration-color:RGBA(var(--bs-emphasis-color-rgb),var(--bs-link-underline-opacity,0.75))!important}.focus-ring:focus{outline:0;box-shadow:var(--bs-focus-ring-x,0) var(--bs-focus-ring-y,0) var(--bs-focus-ring-blur,0) var(--bs-focus-ring-width) var(--bs-focus-ring-color)}.icon-link{display:inline-flex;gap:.375rem;align-items:center;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-opacity,0.5));text-underline-offset:0.25em;-webkit-backface-visibility:hidden;backface-visibility:hidden}.icon-link>.bi{flex-shrink:0;width:1em;height:1em;fill:currentcolor;transition:.2s ease-in-out transform}@media (prefers-reduced-motion:reduce){.icon-link>.bi{transition:none}}.icon-link-hover:focus-visible>.bi,.icon-link-hover:hover>.bi{transform:var(--bs-icon-link-transform,translate3d(.25em,0,0))}.ratio{position:relative;width:100%}.ratio::before{display:block;padding-top:var(--bs-aspect-ratio);content:""}.ratio>*{position:absolute;top:0;left:0;width:100%;height:100%}.ratio-1x1{--bs-aspect-ratio:100%}.ratio-4x3{--bs-aspect-ratio:75%}.ratio-16x9{--bs-aspect-ratio:56.25%}.ratio-21x9{--bs-aspect-ratio:42.8571428571%}.fixed-top{position:fixed;top:0;right:0;left:0;z-index:1030}.fixed-bottom{position:fixed;right:0;bottom:0;left:0;z-index:1030}.sticky-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}@media (min-width:576px){.sticky-sm-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-sm-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:768px){.sticky-md-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-md-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:992px){.sticky-lg-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-lg-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1200px){.sticky-xl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}@media (min-width:1400px){.sticky-xxl-top{position:-webkit-sticky;position:sticky;top:0;z-index:1020}.sticky-xxl-bottom{position:-webkit-sticky;position:sticky;bottom:0;z-index:1020}}.hstack{display:flex;flex-direction:row;align-items:center;align-self:stretch}.vstack{display:flex;flex:1 1 auto;flex-direction:column;align-self:stretch}.visually-hidden,.visually-hidden-focusable:not(:focus):not(:focus-within){width:1px!important;height:1px!important;padding:0!important;margin:-1px!important;overflow:hidden!important;clip:rect(0,0,0,0)!important;white-space:nowrap!important;border:0!important}.visually-hidden-focusable:not(:focus):not(:focus-within):not(caption),.visually-hidden:not(caption){position:absolute!important}.stretched-link::after{position:absolute;top:0;right:0;bottom:0;left:0;z-index:1;content:""}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.vr{display:inline-block;align-self:stretch;width:var(--bs-border-width);min-height:1em;background-color:currentcolor;opacity:.25}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.float-start{float:left!important}.float-end{float:right!important}.float-none{float:none!important}.object-fit-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-none{-o-object-fit:none!important;object-fit:none!important}.opacity-0{opacity:0!important}.opacity-25{opacity:.25!important}.opacity-50{opacity:.5!important}.opacity-75{opacity:.75!important}.opacity-100{opacity:1!important}.overflow-auto{overflow:auto!important}.overflow-hidden{overflow:hidden!important}.overflow-visible{overflow:visible!important}.overflow-scroll{overflow:scroll!important}.overflow-x-auto{overflow-x:auto!important}.overflow-x-hidden{overflow-x:hidden!important}.overflow-x-visible{overflow-x:visible!important}.overflow-x-scroll{overflow-x:scroll!important}.overflow-y-auto{overflow-y:auto!important}.overflow-y-hidden{overflow-y:hidden!important}.overflow-y-visible{overflow-y:visible!important}.overflow-y-scroll{overflow-y:scroll!important}.d-inline{display:inline!important}.d-inline-block{display:inline-block!important}.d-block{display:block!important}.d-grid{display:grid!important}.d-inline-grid{display:inline-grid!important}.d-table{display:table!important}.d-table-row{display:table-row!important}.d-table-cell{display:table-cell!important}.d-flex{display:flex!important}.d-inline-flex{display:inline-flex!important}.d-none{display:none!important}.shadow{box-shadow:var(--bs-box-shadow)!important}.shadow-sm{box-shadow:var(--bs-box-shadow-sm)!important}.shadow-lg{box-shadow:var(--bs-box-shadow-lg)!important}.shadow-none{box-shadow:none!important}.focus-ring-primary{--bs-focus-ring-color:rgba(var(--bs-primary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-secondary{--bs-focus-ring-color:rgba(var(--bs-secondary-rgb), var(--bs-focus-ring-opacity))}.focus-ring-success{--bs-focus-ring-color:rgba(var(--bs-success-rgb), var(--bs-focus-ring-opacity))}.focus-ring-info{--bs-focus-ring-color:rgba(var(--bs-info-rgb), var(--bs-focus-ring-opacity))}.focus-ring-warning{--bs-focus-ring-color:rgba(var(--bs-warning-rgb), var(--bs-focus-ring-opacity))}.focus-ring-danger{--bs-focus-ring-color:rgba(var(--bs-danger-rgb), var(--bs-focus-ring-opacity))}.focus-ring-light{--bs-focus-ring-color:rgba(var(--bs-light-rgb), var(--bs-focus-ring-opacity))}.focus-ring-dark{--bs-focus-ring-color:rgba(var(--bs-dark-rgb), var(--bs-focus-ring-opacity))}.position-static{position:static!important}.position-relative{position:relative!important}.position-absolute{position:absolute!important}.position-fixed{position:fixed!important}.position-sticky{position:-webkit-sticky!important;position:sticky!important}.top-0{top:0!important}.top-50{top:50%!important}.top-100{top:100%!important}.bottom-0{bottom:0!important}.bottom-50{bottom:50%!important}.bottom-100{bottom:100%!important}.start-0{left:0!important}.start-50{left:50%!important}.start-100{left:100%!important}.end-0{right:0!important}.end-50{right:50%!important}.end-100{right:100%!important}.translate-middle{transform:translate(-50%,-50%)!important}.translate-middle-x{transform:translateX(-50%)!important}.translate-middle-y{transform:translateY(-50%)!important}.border{border:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-0{border:0!important}.border-top{border-top:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-top-0{border-top:0!important}.border-end{border-right:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-end-0{border-right:0!important}.border-bottom{border-bottom:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-bottom-0{border-bottom:0!important}.border-start{border-left:var(--bs-border-width) var(--bs-border-style) var(--bs-border-color)!important}.border-start-0{border-left:0!important}.border-primary{--bs-border-opacity:1;border-color:rgba(var(--bs-primary-rgb),var(--bs-border-opacity))!important}.border-secondary{--bs-border-opacity:1;border-color:rgba(var(--bs-secondary-rgb),var(--bs-border-opacity))!important}.border-success{--bs-border-opacity:1;border-color:rgba(var(--bs-success-rgb),var(--bs-border-opacity))!important}.border-info{--bs-border-opacity:1;border-color:rgba(var(--bs-info-rgb),var(--bs-border-opacity))!important}.border-warning{--bs-border-opacity:1;border-color:rgba(var(--bs-warning-rgb),var(--bs-border-opacity))!important}.border-danger{--bs-border-opacity:1;border-color:rgba(var(--bs-danger-rgb),var(--bs-border-opacity))!important}.border-light{--bs-border-opacity:1;border-color:rgba(var(--bs-light-rgb),var(--bs-border-opacity))!important}.border-dark{--bs-border-opacity:1;border-color:rgba(var(--bs-dark-rgb),var(--bs-border-opacity))!important}.border-black{--bs-border-opacity:1;border-color:rgba(var(--bs-black-rgb),var(--bs-border-opacity))!important}.border-white{--bs-border-opacity:1;border-color:rgba(var(--bs-white-rgb),var(--bs-border-opacity))!important}.border-primary-subtle{border-color:var(--bs-primary-border-subtle)!important}.border-secondary-subtle{border-color:var(--bs-secondary-border-subtle)!important}.border-success-subtle{border-color:var(--bs-success-border-subtle)!important}.border-info-subtle{border-color:var(--bs-info-border-subtle)!important}.border-warning-subtle{border-color:var(--bs-warning-border-subtle)!important}.border-danger-subtle{border-color:var(--bs-danger-border-subtle)!important}.border-light-subtle{border-color:var(--bs-light-border-subtle)!important}.border-dark-subtle{border-color:var(--bs-dark-border-subtle)!important}.border-1{border-width:1px!important}.border-2{border-width:2px!important}.border-3{border-width:3px!important}.border-4{border-width:4px!important}.border-5{border-width:5px!important}.border-opacity-10{--bs-border-opacity:0.1}.border-opacity-25{--bs-border-opacity:0.25}.border-opacity-50{--bs-border-opacity:0.5}.border-opacity-75{--bs-border-opacity:0.75}.border-opacity-100{--bs-border-opacity:1}.w-25{width:25%!important}.w-50{width:50%!important}.w-75{width:75%!important}.w-100{width:100%!important}.w-auto{width:auto!important}.mw-100{max-width:100%!important}.vw-100{width:100vw!important}.min-vw-100{min-width:100vw!important}.h-25{height:25%!important}.h-50{height:50%!important}.h-75{height:75%!important}.h-100{height:100%!important}.h-auto{height:auto!important}.mh-100{max-height:100%!important}.vh-100{height:100vh!important}.min-vh-100{min-height:100vh!important}.flex-fill{flex:1 1 auto!important}.flex-row{flex-direction:row!important}.flex-column{flex-direction:column!important}.flex-row-reverse{flex-direction:row-reverse!important}.flex-column-reverse{flex-direction:column-reverse!important}.flex-grow-0{flex-grow:0!important}.flex-grow-1{flex-grow:1!important}.flex-shrink-0{flex-shrink:0!important}.flex-shrink-1{flex-shrink:1!important}.flex-wrap{flex-wrap:wrap!important}.flex-nowrap{flex-wrap:nowrap!important}.flex-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-start{justify-content:flex-start!important}.justify-content-end{justify-content:flex-end!important}.justify-content-center{justify-content:center!important}.justify-content-between{justify-content:space-between!important}.justify-content-around{justify-content:space-around!important}.justify-content-evenly{justify-content:space-evenly!important}.align-items-start{align-items:flex-start!important}.align-items-end{align-items:flex-end!important}.align-items-center{align-items:center!important}.align-items-baseline{align-items:baseline!important}.align-items-stretch{align-items:stretch!important}.align-content-start{align-content:flex-start!important}.align-content-end{align-content:flex-end!important}.align-content-center{align-content:center!important}.align-content-between{align-content:space-between!important}.align-content-around{align-content:space-around!important}.align-content-stretch{align-content:stretch!important}.align-self-auto{align-self:auto!important}.align-self-start{align-self:flex-start!important}.align-self-end{align-self:flex-end!important}.align-self-center{align-self:center!important}.align-self-baseline{align-self:baseline!important}.align-self-stretch{align-self:stretch!important}.order-first{order:-1!important}.order-0{order:0!important}.order-1{order:1!important}.order-2{order:2!important}.order-3{order:3!important}.order-4{order:4!important}.order-5{order:5!important}.order-last{order:6!important}.m-0{margin:0!important}.m-1{margin:.25rem!important}.m-2{margin:.5rem!important}.m-3{margin:1rem!important}.m-4{margin:1.5rem!important}.m-5{margin:3rem!important}.m-auto{margin:auto!important}.mx-0{margin-right:0!important;margin-left:0!important}.mx-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-3{margin-right:1rem!important;margin-left:1rem!important}.mx-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-5{margin-right:3rem!important;margin-left:3rem!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.my-0{margin-top:0!important;margin-bottom:0!important}.my-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-0{margin-top:0!important}.mt-1{margin-top:.25rem!important}.mt-2{margin-top:.5rem!important}.mt-3{margin-top:1rem!important}.mt-4{margin-top:1.5rem!important}.mt-5{margin-top:3rem!important}.mt-auto{margin-top:auto!important}.me-0{margin-right:0!important}.me-1{margin-right:.25rem!important}.me-2{margin-right:.5rem!important}.me-3{margin-right:1rem!important}.me-4{margin-right:1.5rem!important}.me-5{margin-right:3rem!important}.me-auto{margin-right:auto!important}.mb-0{margin-bottom:0!important}.mb-1{margin-bottom:.25rem!important}.mb-2{margin-bottom:.5rem!important}.mb-3{margin-bottom:1rem!important}.mb-4{margin-bottom:1.5rem!important}.mb-5{margin-bottom:3rem!important}.mb-auto{margin-bottom:auto!important}.ms-0{margin-left:0!important}.ms-1{margin-left:.25rem!important}.ms-2{margin-left:.5rem!important}.ms-3{margin-left:1rem!important}.ms-4{margin-left:1.5rem!important}.ms-5{margin-left:3rem!important}.ms-auto{margin-left:auto!important}.p-0{padding:0!important}.p-1{padding:.25rem!important}.p-2{padding:.5rem!important}.p-3{padding:1rem!important}.p-4{padding:1.5rem!important}.p-5{padding:3rem!important}.px-0{padding-right:0!important;padding-left:0!important}.px-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-3{padding-right:1rem!important;padding-left:1rem!important}.px-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-5{padding-right:3rem!important;padding-left:3rem!important}.py-0{padding-top:0!important;padding-bottom:0!important}.py-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-0{padding-top:0!important}.pt-1{padding-top:.25rem!important}.pt-2{padding-top:.5rem!important}.pt-3{padding-top:1rem!important}.pt-4{padding-top:1.5rem!important}.pt-5{padding-top:3rem!important}.pe-0{padding-right:0!important}.pe-1{padding-right:.25rem!important}.pe-2{padding-right:.5rem!important}.pe-3{padding-right:1rem!important}.pe-4{padding-right:1.5rem!important}.pe-5{padding-right:3rem!important}.pb-0{padding-bottom:0!important}.pb-1{padding-bottom:.25rem!important}.pb-2{padding-bottom:.5rem!important}.pb-3{padding-bottom:1rem!important}.pb-4{padding-bottom:1.5rem!important}.pb-5{padding-bottom:3rem!important}.ps-0{padding-left:0!important}.ps-1{padding-left:.25rem!important}.ps-2{padding-left:.5rem!important}.ps-3{padding-left:1rem!important}.ps-4{padding-left:1.5rem!important}.ps-5{padding-left:3rem!important}.gap-0{gap:0!important}.gap-1{gap:.25rem!important}.gap-2{gap:.5rem!important}.gap-3{gap:1rem!important}.gap-4{gap:1.5rem!important}.gap-5{gap:3rem!important}.row-gap-0{row-gap:0!important}.row-gap-1{row-gap:.25rem!important}.row-gap-2{row-gap:.5rem!important}.row-gap-3{row-gap:1rem!important}.row-gap-4{row-gap:1.5rem!important}.row-gap-5{row-gap:3rem!important}.column-gap-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.font-monospace{font-family:var(--bs-font-monospace)!important}.fs-1{font-size:calc(1.375rem + 1.5vw)!important}.fs-2{font-size:calc(1.325rem + .9vw)!important}.fs-3{font-size:calc(1.3rem + .6vw)!important}.fs-4{font-size:calc(1.275rem + .3vw)!important}.fs-5{font-size:1.25rem!important}.fs-6{font-size:1rem!important}.fst-italic{font-style:italic!important}.fst-normal{font-style:normal!important}.fw-lighter{font-weight:lighter!important}.fw-light{font-weight:300!important}.fw-normal{font-weight:400!important}.fw-medium{font-weight:500!important}.fw-semibold{font-weight:600!important}.fw-bold{font-weight:700!important}.fw-bolder{font-weight:bolder!important}.lh-1{line-height:1!important}.lh-sm{line-height:1.25!important}.lh-base{line-height:1.5!important}.lh-lg{line-height:2!important}.text-start{text-align:left!important}.text-end{text-align:right!important}.text-center{text-align:center!important}.text-decoration-none{text-decoration:none!important}.text-decoration-underline{text-decoration:underline!important}.text-decoration-line-through{text-decoration:line-through!important}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.text-wrap{white-space:normal!important}.text-nowrap{white-space:nowrap!important}.text-break{word-wrap:break-word!important;word-break:break-word!important}.text-primary{--bs-text-opacity:1;color:rgba(var(--bs-primary-rgb),var(--bs-text-opacity))!important}.text-secondary{--bs-text-opacity:1;color:rgba(var(--bs-secondary-rgb),var(--bs-text-opacity))!important}.text-success{--bs-text-opacity:1;color:rgba(var(--bs-success-rgb),var(--bs-text-opacity))!important}.text-info{--bs-text-opacity:1;color:rgba(var(--bs-info-rgb),var(--bs-text-opacity))!important}.text-warning{--bs-text-opacity:1;color:rgba(var(--bs-warning-rgb),var(--bs-text-opacity))!important}.text-danger{--bs-text-opacity:1;color:rgba(var(--bs-danger-rgb),var(--bs-text-opacity))!important}.text-light{--bs-text-opacity:1;color:rgba(var(--bs-light-rgb),var(--bs-text-opacity))!important}.text-dark{--bs-text-opacity:1;color:rgba(var(--bs-dark-rgb),var(--bs-text-opacity))!important}.text-black{--bs-text-opacity:1;color:rgba(var(--bs-black-rgb),var(--bs-text-opacity))!important}.text-white{--bs-text-opacity:1;color:rgba(var(--bs-white-rgb),var(--bs-text-opacity))!important}.text-body{--bs-text-opacity:1;color:rgba(var(--bs-body-color-rgb),var(--bs-text-opacity))!important}.text-muted{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-black-50{--bs-text-opacity:1;color:rgba(0,0,0,.5)!important}.text-white-50{--bs-text-opacity:1;color:rgba(255,255,255,.5)!important}.text-body-secondary{--bs-text-opacity:1;color:var(--bs-secondary-color)!important}.text-body-tertiary{--bs-text-opacity:1;color:var(--bs-tertiary-color)!important}.text-body-emphasis{--bs-text-opacity:1;color:var(--bs-emphasis-color)!important}.text-reset{--bs-text-opacity:1;color:inherit!important}.text-opacity-25{--bs-text-opacity:0.25}.text-opacity-50{--bs-text-opacity:0.5}.text-opacity-75{--bs-text-opacity:0.75}.text-opacity-100{--bs-text-opacity:1}.text-primary-emphasis{color:var(--bs-primary-text-emphasis)!important}.text-secondary-emphasis{color:var(--bs-secondary-text-emphasis)!important}.text-success-emphasis{color:var(--bs-success-text-emphasis)!important}.text-info-emphasis{color:var(--bs-info-text-emphasis)!important}.text-warning-emphasis{color:var(--bs-warning-text-emphasis)!important}.text-danger-emphasis{color:var(--bs-danger-text-emphasis)!important}.text-light-emphasis{color:var(--bs-light-text-emphasis)!important}.text-dark-emphasis{color:var(--bs-dark-text-emphasis)!important}.link-opacity-10{--bs-link-opacity:0.1}.link-opacity-10-hover:hover{--bs-link-opacity:0.1}.link-opacity-25{--bs-link-opacity:0.25}.link-opacity-25-hover:hover{--bs-link-opacity:0.25}.link-opacity-50{--bs-link-opacity:0.5}.link-opacity-50-hover:hover{--bs-link-opacity:0.5}.link-opacity-75{--bs-link-opacity:0.75}.link-opacity-75-hover:hover{--bs-link-opacity:0.75}.link-opacity-100{--bs-link-opacity:1}.link-opacity-100-hover:hover{--bs-link-opacity:1}.link-offset-1{text-underline-offset:0.125em!important}.link-offset-1-hover:hover{text-underline-offset:0.125em!important}.link-offset-2{text-underline-offset:0.25em!important}.link-offset-2-hover:hover{text-underline-offset:0.25em!important}.link-offset-3{text-underline-offset:0.375em!important}.link-offset-3-hover:hover{text-underline-offset:0.375em!important}.link-underline-primary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-primary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-secondary{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-secondary-rgb),var(--bs-link-underline-opacity))!important}.link-underline-success{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-success-rgb),var(--bs-link-underline-opacity))!important}.link-underline-info{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-info-rgb),var(--bs-link-underline-opacity))!important}.link-underline-warning{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-warning-rgb),var(--bs-link-underline-opacity))!important}.link-underline-danger{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-danger-rgb),var(--bs-link-underline-opacity))!important}.link-underline-light{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-light-rgb),var(--bs-link-underline-opacity))!important}.link-underline-dark{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important;text-decoration-color:rgba(var(--bs-dark-rgb),var(--bs-link-underline-opacity))!important}.link-underline{--bs-link-underline-opacity:1;-webkit-text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important;text-decoration-color:rgba(var(--bs-link-color-rgb),var(--bs-link-underline-opacity,1))!important}.link-underline-opacity-0{--bs-link-underline-opacity:0}.link-underline-opacity-0-hover:hover{--bs-link-underline-opacity:0}.link-underline-opacity-10{--bs-link-underline-opacity:0.1}.link-underline-opacity-10-hover:hover{--bs-link-underline-opacity:0.1}.link-underline-opacity-25{--bs-link-underline-opacity:0.25}.link-underline-opacity-25-hover:hover{--bs-link-underline-opacity:0.25}.link-underline-opacity-50{--bs-link-underline-opacity:0.5}.link-underline-opacity-50-hover:hover{--bs-link-underline-opacity:0.5}.link-underline-opacity-75{--bs-link-underline-opacity:0.75}.link-underline-opacity-75-hover:hover{--bs-link-underline-opacity:0.75}.link-underline-opacity-100{--bs-link-underline-opacity:1}.link-underline-opacity-100-hover:hover{--bs-link-underline-opacity:1}.bg-primary{--bs-bg-opacity:1;background-color:rgba(var(--bs-primary-rgb),var(--bs-bg-opacity))!important}.bg-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-rgb),var(--bs-bg-opacity))!important}.bg-success{--bs-bg-opacity:1;background-color:rgba(var(--bs-success-rgb),var(--bs-bg-opacity))!important}.bg-info{--bs-bg-opacity:1;background-color:rgba(var(--bs-info-rgb),var(--bs-bg-opacity))!important}.bg-warning{--bs-bg-opacity:1;background-color:rgba(var(--bs-warning-rgb),var(--bs-bg-opacity))!important}.bg-danger{--bs-bg-opacity:1;background-color:rgba(var(--bs-danger-rgb),var(--bs-bg-opacity))!important}.bg-light{--bs-bg-opacity:1;background-color:rgba(var(--bs-light-rgb),var(--bs-bg-opacity))!important}.bg-dark{--bs-bg-opacity:1;background-color:rgba(var(--bs-dark-rgb),var(--bs-bg-opacity))!important}.bg-black{--bs-bg-opacity:1;background-color:rgba(var(--bs-black-rgb),var(--bs-bg-opacity))!important}.bg-white{--bs-bg-opacity:1;background-color:rgba(var(--bs-white-rgb),var(--bs-bg-opacity))!important}.bg-body{--bs-bg-opacity:1;background-color:rgba(var(--bs-body-bg-rgb),var(--bs-bg-opacity))!important}.bg-transparent{--bs-bg-opacity:1;background-color:transparent!important}.bg-body-secondary{--bs-bg-opacity:1;background-color:rgba(var(--bs-secondary-bg-rgb),var(--bs-bg-opacity))!important}.bg-body-tertiary{--bs-bg-opacity:1;background-color:rgba(var(--bs-tertiary-bg-rgb),var(--bs-bg-opacity))!important}.bg-opacity-10{--bs-bg-opacity:0.1}.bg-opacity-25{--bs-bg-opacity:0.25}.bg-opacity-50{--bs-bg-opacity:0.5}.bg-opacity-75{--bs-bg-opacity:0.75}.bg-opacity-100{--bs-bg-opacity:1}.bg-primary-subtle{background-color:var(--bs-primary-bg-subtle)!important}.bg-secondary-subtle{background-color:var(--bs-secondary-bg-subtle)!important}.bg-success-subtle{background-color:var(--bs-success-bg-subtle)!important}.bg-info-subtle{background-color:var(--bs-info-bg-subtle)!important}.bg-warning-subtle{background-color:var(--bs-warning-bg-subtle)!important}.bg-danger-subtle{background-color:var(--bs-danger-bg-subtle)!important}.bg-light-subtle{background-color:var(--bs-light-bg-subtle)!important}.bg-dark-subtle{background-color:var(--bs-dark-bg-subtle)!important}.bg-gradient{background-image:var(--bs-gradient)!important}.user-select-all{-webkit-user-select:all!important;-moz-user-select:all!important;user-select:all!important}.user-select-auto{-webkit-user-select:auto!important;-moz-user-select:auto!important;user-select:auto!important}.user-select-none{-webkit-user-select:none!important;-moz-user-select:none!important;user-select:none!important}.pe-none{pointer-events:none!important}.pe-auto{pointer-events:auto!important}.rounded{border-radius:var(--bs-border-radius)!important}.rounded-0{border-radius:0!important}.rounded-1{border-radius:var(--bs-border-radius-sm)!important}.rounded-2{border-radius:var(--bs-border-radius)!important}.rounded-3{border-radius:var(--bs-border-radius-lg)!important}.rounded-4{border-radius:var(--bs-border-radius-xl)!important}.rounded-5{border-radius:var(--bs-border-radius-xxl)!important}.rounded-circle{border-radius:50%!important}.rounded-pill{border-radius:var(--bs-border-radius-pill)!important}.rounded-top{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-0{border-top-left-radius:0!important;border-top-right-radius:0!important}.rounded-top-1{border-top-left-radius:var(--bs-border-radius-sm)!important;border-top-right-radius:var(--bs-border-radius-sm)!important}.rounded-top-2{border-top-left-radius:var(--bs-border-radius)!important;border-top-right-radius:var(--bs-border-radius)!important}.rounded-top-3{border-top-left-radius:var(--bs-border-radius-lg)!important;border-top-right-radius:var(--bs-border-radius-lg)!important}.rounded-top-4{border-top-left-radius:var(--bs-border-radius-xl)!important;border-top-right-radius:var(--bs-border-radius-xl)!important}.rounded-top-5{border-top-left-radius:var(--bs-border-radius-xxl)!important;border-top-right-radius:var(--bs-border-radius-xxl)!important}.rounded-top-circle{border-top-left-radius:50%!important;border-top-right-radius:50%!important}.rounded-top-pill{border-top-left-radius:var(--bs-border-radius-pill)!important;border-top-right-radius:var(--bs-border-radius-pill)!important}.rounded-end{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-0{border-top-right-radius:0!important;border-bottom-right-radius:0!important}.rounded-end-1{border-top-right-radius:var(--bs-border-radius-sm)!important;border-bottom-right-radius:var(--bs-border-radius-sm)!important}.rounded-end-2{border-top-right-radius:var(--bs-border-radius)!important;border-bottom-right-radius:var(--bs-border-radius)!important}.rounded-end-3{border-top-right-radius:var(--bs-border-radius-lg)!important;border-bottom-right-radius:var(--bs-border-radius-lg)!important}.rounded-end-4{border-top-right-radius:var(--bs-border-radius-xl)!important;border-bottom-right-radius:var(--bs-border-radius-xl)!important}.rounded-end-5{border-top-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-right-radius:var(--bs-border-radius-xxl)!important}.rounded-end-circle{border-top-right-radius:50%!important;border-bottom-right-radius:50%!important}.rounded-end-pill{border-top-right-radius:var(--bs-border-radius-pill)!important;border-bottom-right-radius:var(--bs-border-radius-pill)!important}.rounded-bottom{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-0{border-bottom-right-radius:0!important;border-bottom-left-radius:0!important}.rounded-bottom-1{border-bottom-right-radius:var(--bs-border-radius-sm)!important;border-bottom-left-radius:var(--bs-border-radius-sm)!important}.rounded-bottom-2{border-bottom-right-radius:var(--bs-border-radius)!important;border-bottom-left-radius:var(--bs-border-radius)!important}.rounded-bottom-3{border-bottom-right-radius:var(--bs-border-radius-lg)!important;border-bottom-left-radius:var(--bs-border-radius-lg)!important}.rounded-bottom-4{border-bottom-right-radius:var(--bs-border-radius-xl)!important;border-bottom-left-radius:var(--bs-border-radius-xl)!important}.rounded-bottom-5{border-bottom-right-radius:var(--bs-border-radius-xxl)!important;border-bottom-left-radius:var(--bs-border-radius-xxl)!important}.rounded-bottom-circle{border-bottom-right-radius:50%!important;border-bottom-left-radius:50%!important}.rounded-bottom-pill{border-bottom-right-radius:var(--bs-border-radius-pill)!important;border-bottom-left-radius:var(--bs-border-radius-pill)!important}.rounded-start{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-0{border-bottom-left-radius:0!important;border-top-left-radius:0!important}.rounded-start-1{border-bottom-left-radius:var(--bs-border-radius-sm)!important;border-top-left-radius:var(--bs-border-radius-sm)!important}.rounded-start-2{border-bottom-left-radius:var(--bs-border-radius)!important;border-top-left-radius:var(--bs-border-radius)!important}.rounded-start-3{border-bottom-left-radius:var(--bs-border-radius-lg)!important;border-top-left-radius:var(--bs-border-radius-lg)!important}.rounded-start-4{border-bottom-left-radius:var(--bs-border-radius-xl)!important;border-top-left-radius:var(--bs-border-radius-xl)!important}.rounded-start-5{border-bottom-left-radius:var(--bs-border-radius-xxl)!important;border-top-left-radius:var(--bs-border-radius-xxl)!important}.rounded-start-circle{border-bottom-left-radius:50%!important;border-top-left-radius:50%!important}.rounded-start-pill{border-bottom-left-radius:var(--bs-border-radius-pill)!important;border-top-left-radius:var(--bs-border-radius-pill)!important}.visible{visibility:visible!important}.invisible{visibility:hidden!important}.z-n1{z-index:-1!important}.z-0{z-index:0!important}.z-1{z-index:1!important}.z-2{z-index:2!important}.z-3{z-index:3!important}@media (min-width:576px){.float-sm-start{float:left!important}.float-sm-end{float:right!important}.float-sm-none{float:none!important}.object-fit-sm-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-sm-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-sm-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-sm-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-sm-none{-o-object-fit:none!important;object-fit:none!important}.d-sm-inline{display:inline!important}.d-sm-inline-block{display:inline-block!important}.d-sm-block{display:block!important}.d-sm-grid{display:grid!important}.d-sm-inline-grid{display:inline-grid!important}.d-sm-table{display:table!important}.d-sm-table-row{display:table-row!important}.d-sm-table-cell{display:table-cell!important}.d-sm-flex{display:flex!important}.d-sm-inline-flex{display:inline-flex!important}.d-sm-none{display:none!important}.flex-sm-fill{flex:1 1 auto!important}.flex-sm-row{flex-direction:row!important}.flex-sm-column{flex-direction:column!important}.flex-sm-row-reverse{flex-direction:row-reverse!important}.flex-sm-column-reverse{flex-direction:column-reverse!important}.flex-sm-grow-0{flex-grow:0!important}.flex-sm-grow-1{flex-grow:1!important}.flex-sm-shrink-0{flex-shrink:0!important}.flex-sm-shrink-1{flex-shrink:1!important}.flex-sm-wrap{flex-wrap:wrap!important}.flex-sm-nowrap{flex-wrap:nowrap!important}.flex-sm-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-sm-start{justify-content:flex-start!important}.justify-content-sm-end{justify-content:flex-end!important}.justify-content-sm-center{justify-content:center!important}.justify-content-sm-between{justify-content:space-between!important}.justify-content-sm-around{justify-content:space-around!important}.justify-content-sm-evenly{justify-content:space-evenly!important}.align-items-sm-start{align-items:flex-start!important}.align-items-sm-end{align-items:flex-end!important}.align-items-sm-center{align-items:center!important}.align-items-sm-baseline{align-items:baseline!important}.align-items-sm-stretch{align-items:stretch!important}.align-content-sm-start{align-content:flex-start!important}.align-content-sm-end{align-content:flex-end!important}.align-content-sm-center{align-content:center!important}.align-content-sm-between{align-content:space-between!important}.align-content-sm-around{align-content:space-around!important}.align-content-sm-stretch{align-content:stretch!important}.align-self-sm-auto{align-self:auto!important}.align-self-sm-start{align-self:flex-start!important}.align-self-sm-end{align-self:flex-end!important}.align-self-sm-center{align-self:center!important}.align-self-sm-baseline{align-self:baseline!important}.align-self-sm-stretch{align-self:stretch!important}.order-sm-first{order:-1!important}.order-sm-0{order:0!important}.order-sm-1{order:1!important}.order-sm-2{order:2!important}.order-sm-3{order:3!important}.order-sm-4{order:4!important}.order-sm-5{order:5!important}.order-sm-last{order:6!important}.m-sm-0{margin:0!important}.m-sm-1{margin:.25rem!important}.m-sm-2{margin:.5rem!important}.m-sm-3{margin:1rem!important}.m-sm-4{margin:1.5rem!important}.m-sm-5{margin:3rem!important}.m-sm-auto{margin:auto!important}.mx-sm-0{margin-right:0!important;margin-left:0!important}.mx-sm-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-sm-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-sm-3{margin-right:1rem!important;margin-left:1rem!important}.mx-sm-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-sm-5{margin-right:3rem!important;margin-left:3rem!important}.mx-sm-auto{margin-right:auto!important;margin-left:auto!important}.my-sm-0{margin-top:0!important;margin-bottom:0!important}.my-sm-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-sm-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-sm-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-sm-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-sm-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-sm-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-sm-0{margin-top:0!important}.mt-sm-1{margin-top:.25rem!important}.mt-sm-2{margin-top:.5rem!important}.mt-sm-3{margin-top:1rem!important}.mt-sm-4{margin-top:1.5rem!important}.mt-sm-5{margin-top:3rem!important}.mt-sm-auto{margin-top:auto!important}.me-sm-0{margin-right:0!important}.me-sm-1{margin-right:.25rem!important}.me-sm-2{margin-right:.5rem!important}.me-sm-3{margin-right:1rem!important}.me-sm-4{margin-right:1.5rem!important}.me-sm-5{margin-right:3rem!important}.me-sm-auto{margin-right:auto!important}.mb-sm-0{margin-bottom:0!important}.mb-sm-1{margin-bottom:.25rem!important}.mb-sm-2{margin-bottom:.5rem!important}.mb-sm-3{margin-bottom:1rem!important}.mb-sm-4{margin-bottom:1.5rem!important}.mb-sm-5{margin-bottom:3rem!important}.mb-sm-auto{margin-bottom:auto!important}.ms-sm-0{margin-left:0!important}.ms-sm-1{margin-left:.25rem!important}.ms-sm-2{margin-left:.5rem!important}.ms-sm-3{margin-left:1rem!important}.ms-sm-4{margin-left:1.5rem!important}.ms-sm-5{margin-left:3rem!important}.ms-sm-auto{margin-left:auto!important}.p-sm-0{padding:0!important}.p-sm-1{padding:.25rem!important}.p-sm-2{padding:.5rem!important}.p-sm-3{padding:1rem!important}.p-sm-4{padding:1.5rem!important}.p-sm-5{padding:3rem!important}.px-sm-0{padding-right:0!important;padding-left:0!important}.px-sm-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-sm-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-sm-3{padding-right:1rem!important;padding-left:1rem!important}.px-sm-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-sm-5{padding-right:3rem!important;padding-left:3rem!important}.py-sm-0{padding-top:0!important;padding-bottom:0!important}.py-sm-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-sm-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-sm-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-sm-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-sm-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-sm-0{padding-top:0!important}.pt-sm-1{padding-top:.25rem!important}.pt-sm-2{padding-top:.5rem!important}.pt-sm-3{padding-top:1rem!important}.pt-sm-4{padding-top:1.5rem!important}.pt-sm-5{padding-top:3rem!important}.pe-sm-0{padding-right:0!important}.pe-sm-1{padding-right:.25rem!important}.pe-sm-2{padding-right:.5rem!important}.pe-sm-3{padding-right:1rem!important}.pe-sm-4{padding-right:1.5rem!important}.pe-sm-5{padding-right:3rem!important}.pb-sm-0{padding-bottom:0!important}.pb-sm-1{padding-bottom:.25rem!important}.pb-sm-2{padding-bottom:.5rem!important}.pb-sm-3{padding-bottom:1rem!important}.pb-sm-4{padding-bottom:1.5rem!important}.pb-sm-5{padding-bottom:3rem!important}.ps-sm-0{padding-left:0!important}.ps-sm-1{padding-left:.25rem!important}.ps-sm-2{padding-left:.5rem!important}.ps-sm-3{padding-left:1rem!important}.ps-sm-4{padding-left:1.5rem!important}.ps-sm-5{padding-left:3rem!important}.gap-sm-0{gap:0!important}.gap-sm-1{gap:.25rem!important}.gap-sm-2{gap:.5rem!important}.gap-sm-3{gap:1rem!important}.gap-sm-4{gap:1.5rem!important}.gap-sm-5{gap:3rem!important}.row-gap-sm-0{row-gap:0!important}.row-gap-sm-1{row-gap:.25rem!important}.row-gap-sm-2{row-gap:.5rem!important}.row-gap-sm-3{row-gap:1rem!important}.row-gap-sm-4{row-gap:1.5rem!important}.row-gap-sm-5{row-gap:3rem!important}.column-gap-sm-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-sm-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-sm-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-sm-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-sm-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-sm-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-sm-start{text-align:left!important}.text-sm-end{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.float-md-start{float:left!important}.float-md-end{float:right!important}.float-md-none{float:none!important}.object-fit-md-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-md-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-md-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-md-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-md-none{-o-object-fit:none!important;object-fit:none!important}.d-md-inline{display:inline!important}.d-md-inline-block{display:inline-block!important}.d-md-block{display:block!important}.d-md-grid{display:grid!important}.d-md-inline-grid{display:inline-grid!important}.d-md-table{display:table!important}.d-md-table-row{display:table-row!important}.d-md-table-cell{display:table-cell!important}.d-md-flex{display:flex!important}.d-md-inline-flex{display:inline-flex!important}.d-md-none{display:none!important}.flex-md-fill{flex:1 1 auto!important}.flex-md-row{flex-direction:row!important}.flex-md-column{flex-direction:column!important}.flex-md-row-reverse{flex-direction:row-reverse!important}.flex-md-column-reverse{flex-direction:column-reverse!important}.flex-md-grow-0{flex-grow:0!important}.flex-md-grow-1{flex-grow:1!important}.flex-md-shrink-0{flex-shrink:0!important}.flex-md-shrink-1{flex-shrink:1!important}.flex-md-wrap{flex-wrap:wrap!important}.flex-md-nowrap{flex-wrap:nowrap!important}.flex-md-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-md-start{justify-content:flex-start!important}.justify-content-md-end{justify-content:flex-end!important}.justify-content-md-center{justify-content:center!important}.justify-content-md-between{justify-content:space-between!important}.justify-content-md-around{justify-content:space-around!important}.justify-content-md-evenly{justify-content:space-evenly!important}.align-items-md-start{align-items:flex-start!important}.align-items-md-end{align-items:flex-end!important}.align-items-md-center{align-items:center!important}.align-items-md-baseline{align-items:baseline!important}.align-items-md-stretch{align-items:stretch!important}.align-content-md-start{align-content:flex-start!important}.align-content-md-end{align-content:flex-end!important}.align-content-md-center{align-content:center!important}.align-content-md-between{align-content:space-between!important}.align-content-md-around{align-content:space-around!important}.align-content-md-stretch{align-content:stretch!important}.align-self-md-auto{align-self:auto!important}.align-self-md-start{align-self:flex-start!important}.align-self-md-end{align-self:flex-end!important}.align-self-md-center{align-self:center!important}.align-self-md-baseline{align-self:baseline!important}.align-self-md-stretch{align-self:stretch!important}.order-md-first{order:-1!important}.order-md-0{order:0!important}.order-md-1{order:1!important}.order-md-2{order:2!important}.order-md-3{order:3!important}.order-md-4{order:4!important}.order-md-5{order:5!important}.order-md-last{order:6!important}.m-md-0{margin:0!important}.m-md-1{margin:.25rem!important}.m-md-2{margin:.5rem!important}.m-md-3{margin:1rem!important}.m-md-4{margin:1.5rem!important}.m-md-5{margin:3rem!important}.m-md-auto{margin:auto!important}.mx-md-0{margin-right:0!important;margin-left:0!important}.mx-md-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-md-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-md-3{margin-right:1rem!important;margin-left:1rem!important}.mx-md-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-md-5{margin-right:3rem!important;margin-left:3rem!important}.mx-md-auto{margin-right:auto!important;margin-left:auto!important}.my-md-0{margin-top:0!important;margin-bottom:0!important}.my-md-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-md-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-md-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-md-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-md-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-md-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-md-0{margin-top:0!important}.mt-md-1{margin-top:.25rem!important}.mt-md-2{margin-top:.5rem!important}.mt-md-3{margin-top:1rem!important}.mt-md-4{margin-top:1.5rem!important}.mt-md-5{margin-top:3rem!important}.mt-md-auto{margin-top:auto!important}.me-md-0{margin-right:0!important}.me-md-1{margin-right:.25rem!important}.me-md-2{margin-right:.5rem!important}.me-md-3{margin-right:1rem!important}.me-md-4{margin-right:1.5rem!important}.me-md-5{margin-right:3rem!important}.me-md-auto{margin-right:auto!important}.mb-md-0{margin-bottom:0!important}.mb-md-1{margin-bottom:.25rem!important}.mb-md-2{margin-bottom:.5rem!important}.mb-md-3{margin-bottom:1rem!important}.mb-md-4{margin-bottom:1.5rem!important}.mb-md-5{margin-bottom:3rem!important}.mb-md-auto{margin-bottom:auto!important}.ms-md-0{margin-left:0!important}.ms-md-1{margin-left:.25rem!important}.ms-md-2{margin-left:.5rem!important}.ms-md-3{margin-left:1rem!important}.ms-md-4{margin-left:1.5rem!important}.ms-md-5{margin-left:3rem!important}.ms-md-auto{margin-left:auto!important}.p-md-0{padding:0!important}.p-md-1{padding:.25rem!important}.p-md-2{padding:.5rem!important}.p-md-3{padding:1rem!important}.p-md-4{padding:1.5rem!important}.p-md-5{padding:3rem!important}.px-md-0{padding-right:0!important;padding-left:0!important}.px-md-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-md-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-md-3{padding-right:1rem!important;padding-left:1rem!important}.px-md-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-md-5{padding-right:3rem!important;padding-left:3rem!important}.py-md-0{padding-top:0!important;padding-bottom:0!important}.py-md-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-md-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-md-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-md-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-md-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-md-0{padding-top:0!important}.pt-md-1{padding-top:.25rem!important}.pt-md-2{padding-top:.5rem!important}.pt-md-3{padding-top:1rem!important}.pt-md-4{padding-top:1.5rem!important}.pt-md-5{padding-top:3rem!important}.pe-md-0{padding-right:0!important}.pe-md-1{padding-right:.25rem!important}.pe-md-2{padding-right:.5rem!important}.pe-md-3{padding-right:1rem!important}.pe-md-4{padding-right:1.5rem!important}.pe-md-5{padding-right:3rem!important}.pb-md-0{padding-bottom:0!important}.pb-md-1{padding-bottom:.25rem!important}.pb-md-2{padding-bottom:.5rem!important}.pb-md-3{padding-bottom:1rem!important}.pb-md-4{padding-bottom:1.5rem!important}.pb-md-5{padding-bottom:3rem!important}.ps-md-0{padding-left:0!important}.ps-md-1{padding-left:.25rem!important}.ps-md-2{padding-left:.5rem!important}.ps-md-3{padding-left:1rem!important}.ps-md-4{padding-left:1.5rem!important}.ps-md-5{padding-left:3rem!important}.gap-md-0{gap:0!important}.gap-md-1{gap:.25rem!important}.gap-md-2{gap:.5rem!important}.gap-md-3{gap:1rem!important}.gap-md-4{gap:1.5rem!important}.gap-md-5{gap:3rem!important}.row-gap-md-0{row-gap:0!important}.row-gap-md-1{row-gap:.25rem!important}.row-gap-md-2{row-gap:.5rem!important}.row-gap-md-3{row-gap:1rem!important}.row-gap-md-4{row-gap:1.5rem!important}.row-gap-md-5{row-gap:3rem!important}.column-gap-md-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-md-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-md-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-md-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-md-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-md-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-md-start{text-align:left!important}.text-md-end{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.float-lg-start{float:left!important}.float-lg-end{float:right!important}.float-lg-none{float:none!important}.object-fit-lg-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-lg-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-lg-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-lg-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-lg-none{-o-object-fit:none!important;object-fit:none!important}.d-lg-inline{display:inline!important}.d-lg-inline-block{display:inline-block!important}.d-lg-block{display:block!important}.d-lg-grid{display:grid!important}.d-lg-inline-grid{display:inline-grid!important}.d-lg-table{display:table!important}.d-lg-table-row{display:table-row!important}.d-lg-table-cell{display:table-cell!important}.d-lg-flex{display:flex!important}.d-lg-inline-flex{display:inline-flex!important}.d-lg-none{display:none!important}.flex-lg-fill{flex:1 1 auto!important}.flex-lg-row{flex-direction:row!important}.flex-lg-column{flex-direction:column!important}.flex-lg-row-reverse{flex-direction:row-reverse!important}.flex-lg-column-reverse{flex-direction:column-reverse!important}.flex-lg-grow-0{flex-grow:0!important}.flex-lg-grow-1{flex-grow:1!important}.flex-lg-shrink-0{flex-shrink:0!important}.flex-lg-shrink-1{flex-shrink:1!important}.flex-lg-wrap{flex-wrap:wrap!important}.flex-lg-nowrap{flex-wrap:nowrap!important}.flex-lg-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-lg-start{justify-content:flex-start!important}.justify-content-lg-end{justify-content:flex-end!important}.justify-content-lg-center{justify-content:center!important}.justify-content-lg-between{justify-content:space-between!important}.justify-content-lg-around{justify-content:space-around!important}.justify-content-lg-evenly{justify-content:space-evenly!important}.align-items-lg-start{align-items:flex-start!important}.align-items-lg-end{align-items:flex-end!important}.align-items-lg-center{align-items:center!important}.align-items-lg-baseline{align-items:baseline!important}.align-items-lg-stretch{align-items:stretch!important}.align-content-lg-start{align-content:flex-start!important}.align-content-lg-end{align-content:flex-end!important}.align-content-lg-center{align-content:center!important}.align-content-lg-between{align-content:space-between!important}.align-content-lg-around{align-content:space-around!important}.align-content-lg-stretch{align-content:stretch!important}.align-self-lg-auto{align-self:auto!important}.align-self-lg-start{align-self:flex-start!important}.align-self-lg-end{align-self:flex-end!important}.align-self-lg-center{align-self:center!important}.align-self-lg-baseline{align-self:baseline!important}.align-self-lg-stretch{align-self:stretch!important}.order-lg-first{order:-1!important}.order-lg-0{order:0!important}.order-lg-1{order:1!important}.order-lg-2{order:2!important}.order-lg-3{order:3!important}.order-lg-4{order:4!important}.order-lg-5{order:5!important}.order-lg-last{order:6!important}.m-lg-0{margin:0!important}.m-lg-1{margin:.25rem!important}.m-lg-2{margin:.5rem!important}.m-lg-3{margin:1rem!important}.m-lg-4{margin:1.5rem!important}.m-lg-5{margin:3rem!important}.m-lg-auto{margin:auto!important}.mx-lg-0{margin-right:0!important;margin-left:0!important}.mx-lg-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-lg-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-lg-3{margin-right:1rem!important;margin-left:1rem!important}.mx-lg-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-lg-5{margin-right:3rem!important;margin-left:3rem!important}.mx-lg-auto{margin-right:auto!important;margin-left:auto!important}.my-lg-0{margin-top:0!important;margin-bottom:0!important}.my-lg-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-lg-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-lg-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-lg-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-lg-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-lg-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-lg-0{margin-top:0!important}.mt-lg-1{margin-top:.25rem!important}.mt-lg-2{margin-top:.5rem!important}.mt-lg-3{margin-top:1rem!important}.mt-lg-4{margin-top:1.5rem!important}.mt-lg-5{margin-top:3rem!important}.mt-lg-auto{margin-top:auto!important}.me-lg-0{margin-right:0!important}.me-lg-1{margin-right:.25rem!important}.me-lg-2{margin-right:.5rem!important}.me-lg-3{margin-right:1rem!important}.me-lg-4{margin-right:1.5rem!important}.me-lg-5{margin-right:3rem!important}.me-lg-auto{margin-right:auto!important}.mb-lg-0{margin-bottom:0!important}.mb-lg-1{margin-bottom:.25rem!important}.mb-lg-2{margin-bottom:.5rem!important}.mb-lg-3{margin-bottom:1rem!important}.mb-lg-4{margin-bottom:1.5rem!important}.mb-lg-5{margin-bottom:3rem!important}.mb-lg-auto{margin-bottom:auto!important}.ms-lg-0{margin-left:0!important}.ms-lg-1{margin-left:.25rem!important}.ms-lg-2{margin-left:.5rem!important}.ms-lg-3{margin-left:1rem!important}.ms-lg-4{margin-left:1.5rem!important}.ms-lg-5{margin-left:3rem!important}.ms-lg-auto{margin-left:auto!important}.p-lg-0{padding:0!important}.p-lg-1{padding:.25rem!important}.p-lg-2{padding:.5rem!important}.p-lg-3{padding:1rem!important}.p-lg-4{padding:1.5rem!important}.p-lg-5{padding:3rem!important}.px-lg-0{padding-right:0!important;padding-left:0!important}.px-lg-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-lg-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-lg-3{padding-right:1rem!important;padding-left:1rem!important}.px-lg-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-lg-5{padding-right:3rem!important;padding-left:3rem!important}.py-lg-0{padding-top:0!important;padding-bottom:0!important}.py-lg-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-lg-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-lg-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-lg-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-lg-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-lg-0{padding-top:0!important}.pt-lg-1{padding-top:.25rem!important}.pt-lg-2{padding-top:.5rem!important}.pt-lg-3{padding-top:1rem!important}.pt-lg-4{padding-top:1.5rem!important}.pt-lg-5{padding-top:3rem!important}.pe-lg-0{padding-right:0!important}.pe-lg-1{padding-right:.25rem!important}.pe-lg-2{padding-right:.5rem!important}.pe-lg-3{padding-right:1rem!important}.pe-lg-4{padding-right:1.5rem!important}.pe-lg-5{padding-right:3rem!important}.pb-lg-0{padding-bottom:0!important}.pb-lg-1{padding-bottom:.25rem!important}.pb-lg-2{padding-bottom:.5rem!important}.pb-lg-3{padding-bottom:1rem!important}.pb-lg-4{padding-bottom:1.5rem!important}.pb-lg-5{padding-bottom:3rem!important}.ps-lg-0{padding-left:0!important}.ps-lg-1{padding-left:.25rem!important}.ps-lg-2{padding-left:.5rem!important}.ps-lg-3{padding-left:1rem!important}.ps-lg-4{padding-left:1.5rem!important}.ps-lg-5{padding-left:3rem!important}.gap-lg-0{gap:0!important}.gap-lg-1{gap:.25rem!important}.gap-lg-2{gap:.5rem!important}.gap-lg-3{gap:1rem!important}.gap-lg-4{gap:1.5rem!important}.gap-lg-5{gap:3rem!important}.row-gap-lg-0{row-gap:0!important}.row-gap-lg-1{row-gap:.25rem!important}.row-gap-lg-2{row-gap:.5rem!important}.row-gap-lg-3{row-gap:1rem!important}.row-gap-lg-4{row-gap:1.5rem!important}.row-gap-lg-5{row-gap:3rem!important}.column-gap-lg-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-lg-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-lg-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-lg-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-lg-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-lg-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-lg-start{text-align:left!important}.text-lg-end{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.float-xl-start{float:left!important}.float-xl-end{float:right!important}.float-xl-none{float:none!important}.object-fit-xl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xl-none{-o-object-fit:none!important;object-fit:none!important}.d-xl-inline{display:inline!important}.d-xl-inline-block{display:inline-block!important}.d-xl-block{display:block!important}.d-xl-grid{display:grid!important}.d-xl-inline-grid{display:inline-grid!important}.d-xl-table{display:table!important}.d-xl-table-row{display:table-row!important}.d-xl-table-cell{display:table-cell!important}.d-xl-flex{display:flex!important}.d-xl-inline-flex{display:inline-flex!important}.d-xl-none{display:none!important}.flex-xl-fill{flex:1 1 auto!important}.flex-xl-row{flex-direction:row!important}.flex-xl-column{flex-direction:column!important}.flex-xl-row-reverse{flex-direction:row-reverse!important}.flex-xl-column-reverse{flex-direction:column-reverse!important}.flex-xl-grow-0{flex-grow:0!important}.flex-xl-grow-1{flex-grow:1!important}.flex-xl-shrink-0{flex-shrink:0!important}.flex-xl-shrink-1{flex-shrink:1!important}.flex-xl-wrap{flex-wrap:wrap!important}.flex-xl-nowrap{flex-wrap:nowrap!important}.flex-xl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xl-start{justify-content:flex-start!important}.justify-content-xl-end{justify-content:flex-end!important}.justify-content-xl-center{justify-content:center!important}.justify-content-xl-between{justify-content:space-between!important}.justify-content-xl-around{justify-content:space-around!important}.justify-content-xl-evenly{justify-content:space-evenly!important}.align-items-xl-start{align-items:flex-start!important}.align-items-xl-end{align-items:flex-end!important}.align-items-xl-center{align-items:center!important}.align-items-xl-baseline{align-items:baseline!important}.align-items-xl-stretch{align-items:stretch!important}.align-content-xl-start{align-content:flex-start!important}.align-content-xl-end{align-content:flex-end!important}.align-content-xl-center{align-content:center!important}.align-content-xl-between{align-content:space-between!important}.align-content-xl-around{align-content:space-around!important}.align-content-xl-stretch{align-content:stretch!important}.align-self-xl-auto{align-self:auto!important}.align-self-xl-start{align-self:flex-start!important}.align-self-xl-end{align-self:flex-end!important}.align-self-xl-center{align-self:center!important}.align-self-xl-baseline{align-self:baseline!important}.align-self-xl-stretch{align-self:stretch!important}.order-xl-first{order:-1!important}.order-xl-0{order:0!important}.order-xl-1{order:1!important}.order-xl-2{order:2!important}.order-xl-3{order:3!important}.order-xl-4{order:4!important}.order-xl-5{order:5!important}.order-xl-last{order:6!important}.m-xl-0{margin:0!important}.m-xl-1{margin:.25rem!important}.m-xl-2{margin:.5rem!important}.m-xl-3{margin:1rem!important}.m-xl-4{margin:1.5rem!important}.m-xl-5{margin:3rem!important}.m-xl-auto{margin:auto!important}.mx-xl-0{margin-right:0!important;margin-left:0!important}.mx-xl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xl-auto{margin-right:auto!important;margin-left:auto!important}.my-xl-0{margin-top:0!important;margin-bottom:0!important}.my-xl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xl-0{margin-top:0!important}.mt-xl-1{margin-top:.25rem!important}.mt-xl-2{margin-top:.5rem!important}.mt-xl-3{margin-top:1rem!important}.mt-xl-4{margin-top:1.5rem!important}.mt-xl-5{margin-top:3rem!important}.mt-xl-auto{margin-top:auto!important}.me-xl-0{margin-right:0!important}.me-xl-1{margin-right:.25rem!important}.me-xl-2{margin-right:.5rem!important}.me-xl-3{margin-right:1rem!important}.me-xl-4{margin-right:1.5rem!important}.me-xl-5{margin-right:3rem!important}.me-xl-auto{margin-right:auto!important}.mb-xl-0{margin-bottom:0!important}.mb-xl-1{margin-bottom:.25rem!important}.mb-xl-2{margin-bottom:.5rem!important}.mb-xl-3{margin-bottom:1rem!important}.mb-xl-4{margin-bottom:1.5rem!important}.mb-xl-5{margin-bottom:3rem!important}.mb-xl-auto{margin-bottom:auto!important}.ms-xl-0{margin-left:0!important}.ms-xl-1{margin-left:.25rem!important}.ms-xl-2{margin-left:.5rem!important}.ms-xl-3{margin-left:1rem!important}.ms-xl-4{margin-left:1.5rem!important}.ms-xl-5{margin-left:3rem!important}.ms-xl-auto{margin-left:auto!important}.p-xl-0{padding:0!important}.p-xl-1{padding:.25rem!important}.p-xl-2{padding:.5rem!important}.p-xl-3{padding:1rem!important}.p-xl-4{padding:1.5rem!important}.p-xl-5{padding:3rem!important}.px-xl-0{padding-right:0!important;padding-left:0!important}.px-xl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xl-0{padding-top:0!important;padding-bottom:0!important}.py-xl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xl-0{padding-top:0!important}.pt-xl-1{padding-top:.25rem!important}.pt-xl-2{padding-top:.5rem!important}.pt-xl-3{padding-top:1rem!important}.pt-xl-4{padding-top:1.5rem!important}.pt-xl-5{padding-top:3rem!important}.pe-xl-0{padding-right:0!important}.pe-xl-1{padding-right:.25rem!important}.pe-xl-2{padding-right:.5rem!important}.pe-xl-3{padding-right:1rem!important}.pe-xl-4{padding-right:1.5rem!important}.pe-xl-5{padding-right:3rem!important}.pb-xl-0{padding-bottom:0!important}.pb-xl-1{padding-bottom:.25rem!important}.pb-xl-2{padding-bottom:.5rem!important}.pb-xl-3{padding-bottom:1rem!important}.pb-xl-4{padding-bottom:1.5rem!important}.pb-xl-5{padding-bottom:3rem!important}.ps-xl-0{padding-left:0!important}.ps-xl-1{padding-left:.25rem!important}.ps-xl-2{padding-left:.5rem!important}.ps-xl-3{padding-left:1rem!important}.ps-xl-4{padding-left:1.5rem!important}.ps-xl-5{padding-left:3rem!important}.gap-xl-0{gap:0!important}.gap-xl-1{gap:.25rem!important}.gap-xl-2{gap:.5rem!important}.gap-xl-3{gap:1rem!important}.gap-xl-4{gap:1.5rem!important}.gap-xl-5{gap:3rem!important}.row-gap-xl-0{row-gap:0!important}.row-gap-xl-1{row-gap:.25rem!important}.row-gap-xl-2{row-gap:.5rem!important}.row-gap-xl-3{row-gap:1rem!important}.row-gap-xl-4{row-gap:1.5rem!important}.row-gap-xl-5{row-gap:3rem!important}.column-gap-xl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xl-start{text-align:left!important}.text-xl-end{text-align:right!important}.text-xl-center{text-align:center!important}}@media (min-width:1400px){.float-xxl-start{float:left!important}.float-xxl-end{float:right!important}.float-xxl-none{float:none!important}.object-fit-xxl-contain{-o-object-fit:contain!important;object-fit:contain!important}.object-fit-xxl-cover{-o-object-fit:cover!important;object-fit:cover!important}.object-fit-xxl-fill{-o-object-fit:fill!important;object-fit:fill!important}.object-fit-xxl-scale{-o-object-fit:scale-down!important;object-fit:scale-down!important}.object-fit-xxl-none{-o-object-fit:none!important;object-fit:none!important}.d-xxl-inline{display:inline!important}.d-xxl-inline-block{display:inline-block!important}.d-xxl-block{display:block!important}.d-xxl-grid{display:grid!important}.d-xxl-inline-grid{display:inline-grid!important}.d-xxl-table{display:table!important}.d-xxl-table-row{display:table-row!important}.d-xxl-table-cell{display:table-cell!important}.d-xxl-flex{display:flex!important}.d-xxl-inline-flex{display:inline-flex!important}.d-xxl-none{display:none!important}.flex-xxl-fill{flex:1 1 auto!important}.flex-xxl-row{flex-direction:row!important}.flex-xxl-column{flex-direction:column!important}.flex-xxl-row-reverse{flex-direction:row-reverse!important}.flex-xxl-column-reverse{flex-direction:column-reverse!important}.flex-xxl-grow-0{flex-grow:0!important}.flex-xxl-grow-1{flex-grow:1!important}.flex-xxl-shrink-0{flex-shrink:0!important}.flex-xxl-shrink-1{flex-shrink:1!important}.flex-xxl-wrap{flex-wrap:wrap!important}.flex-xxl-nowrap{flex-wrap:nowrap!important}.flex-xxl-wrap-reverse{flex-wrap:wrap-reverse!important}.justify-content-xxl-start{justify-content:flex-start!important}.justify-content-xxl-end{justify-content:flex-end!important}.justify-content-xxl-center{justify-content:center!important}.justify-content-xxl-between{justify-content:space-between!important}.justify-content-xxl-around{justify-content:space-around!important}.justify-content-xxl-evenly{justify-content:space-evenly!important}.align-items-xxl-start{align-items:flex-start!important}.align-items-xxl-end{align-items:flex-end!important}.align-items-xxl-center{align-items:center!important}.align-items-xxl-baseline{align-items:baseline!important}.align-items-xxl-stretch{align-items:stretch!important}.align-content-xxl-start{align-content:flex-start!important}.align-content-xxl-end{align-content:flex-end!important}.align-content-xxl-center{align-content:center!important}.align-content-xxl-between{align-content:space-between!important}.align-content-xxl-around{align-content:space-around!important}.align-content-xxl-stretch{align-content:stretch!important}.align-self-xxl-auto{align-self:auto!important}.align-self-xxl-start{align-self:flex-start!important}.align-self-xxl-end{align-self:flex-end!important}.align-self-xxl-center{align-self:center!important}.align-self-xxl-baseline{align-self:baseline!important}.align-self-xxl-stretch{align-self:stretch!important}.order-xxl-first{order:-1!important}.order-xxl-0{order:0!important}.order-xxl-1{order:1!important}.order-xxl-2{order:2!important}.order-xxl-3{order:3!important}.order-xxl-4{order:4!important}.order-xxl-5{order:5!important}.order-xxl-last{order:6!important}.m-xxl-0{margin:0!important}.m-xxl-1{margin:.25rem!important}.m-xxl-2{margin:.5rem!important}.m-xxl-3{margin:1rem!important}.m-xxl-4{margin:1.5rem!important}.m-xxl-5{margin:3rem!important}.m-xxl-auto{margin:auto!important}.mx-xxl-0{margin-right:0!important;margin-left:0!important}.mx-xxl-1{margin-right:.25rem!important;margin-left:.25rem!important}.mx-xxl-2{margin-right:.5rem!important;margin-left:.5rem!important}.mx-xxl-3{margin-right:1rem!important;margin-left:1rem!important}.mx-xxl-4{margin-right:1.5rem!important;margin-left:1.5rem!important}.mx-xxl-5{margin-right:3rem!important;margin-left:3rem!important}.mx-xxl-auto{margin-right:auto!important;margin-left:auto!important}.my-xxl-0{margin-top:0!important;margin-bottom:0!important}.my-xxl-1{margin-top:.25rem!important;margin-bottom:.25rem!important}.my-xxl-2{margin-top:.5rem!important;margin-bottom:.5rem!important}.my-xxl-3{margin-top:1rem!important;margin-bottom:1rem!important}.my-xxl-4{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.my-xxl-5{margin-top:3rem!important;margin-bottom:3rem!important}.my-xxl-auto{margin-top:auto!important;margin-bottom:auto!important}.mt-xxl-0{margin-top:0!important}.mt-xxl-1{margin-top:.25rem!important}.mt-xxl-2{margin-top:.5rem!important}.mt-xxl-3{margin-top:1rem!important}.mt-xxl-4{margin-top:1.5rem!important}.mt-xxl-5{margin-top:3rem!important}.mt-xxl-auto{margin-top:auto!important}.me-xxl-0{margin-right:0!important}.me-xxl-1{margin-right:.25rem!important}.me-xxl-2{margin-right:.5rem!important}.me-xxl-3{margin-right:1rem!important}.me-xxl-4{margin-right:1.5rem!important}.me-xxl-5{margin-right:3rem!important}.me-xxl-auto{margin-right:auto!important}.mb-xxl-0{margin-bottom:0!important}.mb-xxl-1{margin-bottom:.25rem!important}.mb-xxl-2{margin-bottom:.5rem!important}.mb-xxl-3{margin-bottom:1rem!important}.mb-xxl-4{margin-bottom:1.5rem!important}.mb-xxl-5{margin-bottom:3rem!important}.mb-xxl-auto{margin-bottom:auto!important}.ms-xxl-0{margin-left:0!important}.ms-xxl-1{margin-left:.25rem!important}.ms-xxl-2{margin-left:.5rem!important}.ms-xxl-3{margin-left:1rem!important}.ms-xxl-4{margin-left:1.5rem!important}.ms-xxl-5{margin-left:3rem!important}.ms-xxl-auto{margin-left:auto!important}.p-xxl-0{padding:0!important}.p-xxl-1{padding:.25rem!important}.p-xxl-2{padding:.5rem!important}.p-xxl-3{padding:1rem!important}.p-xxl-4{padding:1.5rem!important}.p-xxl-5{padding:3rem!important}.px-xxl-0{padding-right:0!important;padding-left:0!important}.px-xxl-1{padding-right:.25rem!important;padding-left:.25rem!important}.px-xxl-2{padding-right:.5rem!important;padding-left:.5rem!important}.px-xxl-3{padding-right:1rem!important;padding-left:1rem!important}.px-xxl-4{padding-right:1.5rem!important;padding-left:1.5rem!important}.px-xxl-5{padding-right:3rem!important;padding-left:3rem!important}.py-xxl-0{padding-top:0!important;padding-bottom:0!important}.py-xxl-1{padding-top:.25rem!important;padding-bottom:.25rem!important}.py-xxl-2{padding-top:.5rem!important;padding-bottom:.5rem!important}.py-xxl-3{padding-top:1rem!important;padding-bottom:1rem!important}.py-xxl-4{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.py-xxl-5{padding-top:3rem!important;padding-bottom:3rem!important}.pt-xxl-0{padding-top:0!important}.pt-xxl-1{padding-top:.25rem!important}.pt-xxl-2{padding-top:.5rem!important}.pt-xxl-3{padding-top:1rem!important}.pt-xxl-4{padding-top:1.5rem!important}.pt-xxl-5{padding-top:3rem!important}.pe-xxl-0{padding-right:0!important}.pe-xxl-1{padding-right:.25rem!important}.pe-xxl-2{padding-right:.5rem!important}.pe-xxl-3{padding-right:1rem!important}.pe-xxl-4{padding-right:1.5rem!important}.pe-xxl-5{padding-right:3rem!important}.pb-xxl-0{padding-bottom:0!important}.pb-xxl-1{padding-bottom:.25rem!important}.pb-xxl-2{padding-bottom:.5rem!important}.pb-xxl-3{padding-bottom:1rem!important}.pb-xxl-4{padding-bottom:1.5rem!important}.pb-xxl-5{padding-bottom:3rem!important}.ps-xxl-0{padding-left:0!important}.ps-xxl-1{padding-left:.25rem!important}.ps-xxl-2{padding-left:.5rem!important}.ps-xxl-3{padding-left:1rem!important}.ps-xxl-4{padding-left:1.5rem!important}.ps-xxl-5{padding-left:3rem!important}.gap-xxl-0{gap:0!important}.gap-xxl-1{gap:.25rem!important}.gap-xxl-2{gap:.5rem!important}.gap-xxl-3{gap:1rem!important}.gap-xxl-4{gap:1.5rem!important}.gap-xxl-5{gap:3rem!important}.row-gap-xxl-0{row-gap:0!important}.row-gap-xxl-1{row-gap:.25rem!important}.row-gap-xxl-2{row-gap:.5rem!important}.row-gap-xxl-3{row-gap:1rem!important}.row-gap-xxl-4{row-gap:1.5rem!important}.row-gap-xxl-5{row-gap:3rem!important}.column-gap-xxl-0{-moz-column-gap:0!important;column-gap:0!important}.column-gap-xxl-1{-moz-column-gap:0.25rem!important;column-gap:.25rem!important}.column-gap-xxl-2{-moz-column-gap:0.5rem!important;column-gap:.5rem!important}.column-gap-xxl-3{-moz-column-gap:1rem!important;column-gap:1rem!important}.column-gap-xxl-4{-moz-column-gap:1.5rem!important;column-gap:1.5rem!important}.column-gap-xxl-5{-moz-column-gap:3rem!important;column-gap:3rem!important}.text-xxl-start{text-align:left!important}.text-xxl-end{text-align:right!important}.text-xxl-center{text-align:center!important}}@media (min-width:1200px){.fs-1{font-size:2.5rem!important}.fs-2{font-size:2rem!important}.fs-3{font-size:1.75rem!important}.fs-4{font-size:1.5rem!important}}@media print{.d-print-inline{display:inline!important}.d-print-inline-block{display:inline-block!important}.d-print-block{display:block!important}.d-print-grid{display:grid!important}.d-print-inline-grid{display:inline-grid!important}.d-print-table{display:table!important}.d-print-table-row{display:table-row!important}.d-print-table-cell{display:table-cell!important}.d-print-flex{display:flex!important}.d-print-inline-flex{display:inline-flex!important}.d-print-none{display:none!important}} /*# sourceMappingURL=bootstrap.min.css.map */ \ No newline at end of file diff --git a/teraserver/python/static/bootstrap/css/bootstrap.min.css.map b/teraserver/python/static/bootstrap/css/bootstrap.min.css.map index 4eb46379..90ce7987 100644 --- a/teraserver/python/static/bootstrap/css/bootstrap.min.css.map +++ b/teraserver/python/static/bootstrap/css/bootstrap.min.css.map @@ -1 +1 @@ -{"version":3,"sources":["../../scss/bootstrap.scss","../../scss/_root.scss","../../scss/_reboot.scss","dist/css/bootstrap.css","../../scss/vendor/_rfs.scss","bootstrap.css","../../scss/mixins/_hover.scss","../../scss/_type.scss","../../scss/mixins/_lists.scss","../../scss/_images.scss","../../scss/mixins/_image.scss","../../scss/mixins/_border-radius.scss","../../scss/_code.scss","../../scss/_grid.scss","../../scss/mixins/_grid.scss","../../scss/mixins/_breakpoints.scss","../../scss/mixins/_grid-framework.scss","../../scss/_tables.scss","../../scss/mixins/_table-row.scss","../../scss/_forms.scss","../../scss/mixins/_transition.scss","../../scss/mixins/_forms.scss","../../scss/mixins/_gradients.scss","../../scss/_buttons.scss","../../scss/mixins/_buttons.scss","../../scss/_transitions.scss","../../scss/_dropdown.scss","../../scss/mixins/_caret.scss","../../scss/mixins/_nav-divider.scss","../../scss/_button-group.scss","../../scss/_input-group.scss","../../scss/_custom-forms.scss","../../scss/_nav.scss","../../scss/_navbar.scss","../../scss/_card.scss","../../scss/_breadcrumb.scss","../../scss/_pagination.scss","../../scss/mixins/_pagination.scss","../../scss/_badge.scss","../../scss/mixins/_badge.scss","../../scss/_jumbotron.scss","../../scss/_alert.scss","../../scss/mixins/_alert.scss","../../scss/_progress.scss","../../scss/_media.scss","../../scss/_list-group.scss","../../scss/mixins/_list-group.scss","../../scss/_close.scss","../../scss/_toasts.scss","../../scss/_modal.scss","../../scss/_tooltip.scss","../../scss/mixins/_reset-text.scss","../../scss/_popover.scss","../../scss/_carousel.scss","../../scss/mixins/_clearfix.scss","../../scss/_spinners.scss","../../scss/utilities/_align.scss","../../scss/mixins/_background-variant.scss","../../scss/utilities/_background.scss","../../scss/utilities/_borders.scss","../../scss/utilities/_display.scss","../../scss/utilities/_embed.scss","../../scss/utilities/_flex.scss","../../scss/utilities/_float.scss","../../scss/utilities/_interactions.scss","../../scss/utilities/_overflow.scss","../../scss/utilities/_position.scss","../../scss/utilities/_screenreaders.scss","../../scss/mixins/_screen-reader.scss","../../scss/utilities/_shadows.scss","../../scss/utilities/_sizing.scss","../../scss/utilities/_spacing.scss","../../scss/utilities/_stretched-link.scss","../../scss/utilities/_text.scss","../../scss/mixins/_text-truncate.scss","../../scss/mixins/_text-emphasis.scss","../../scss/mixins/_text-hide.scss","../../scss/utilities/_visibility.scss","../../scss/_print.scss"],"names":[],"mappings":"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","sourcesContent":["/*!\n * Bootstrap v4.6.2 (https://getbootstrap.com/)\n * Copyright 2011-2022 The Bootstrap Authors\n * Copyright 2011-2022 Twitter, Inc.\n * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE)\n */\n\n@import \"functions\";\n@import \"variables\";\n@import \"mixins\";\n@import \"root\";\n@import \"reboot\";\n@import \"type\";\n@import \"images\";\n@import \"code\";\n@import \"grid\";\n@import \"tables\";\n@import \"forms\";\n@import \"buttons\";\n@import \"transitions\";\n@import \"dropdown\";\n@import \"button-group\";\n@import \"input-group\";\n@import \"custom-forms\";\n@import \"nav\";\n@import \"navbar\";\n@import \"card\";\n@import \"breadcrumb\";\n@import \"pagination\";\n@import \"badge\";\n@import \"jumbotron\";\n@import \"alert\";\n@import \"progress\";\n@import \"media\";\n@import \"list-group\";\n@import \"close\";\n@import \"toasts\";\n@import \"modal\";\n@import \"tooltip\";\n@import \"popover\";\n@import \"carousel\";\n@import \"spinners\";\n@import \"utilities\";\n@import \"print\";\n",":root {\n // Custom variable values only support SassScript inside `#{}`.\n @each $color, $value in $colors {\n --#{$color}: #{$value};\n }\n\n @each $color, $value in $theme-colors {\n --#{$color}: #{$value};\n }\n\n @each $bp, $value in $grid-breakpoints {\n --breakpoint-#{$bp}: #{$value};\n }\n\n // Use `inspect` for lists so that quoted items keep the quotes.\n // See https://github.com/sass/sass/issues/2383#issuecomment-336349172\n --font-family-sans-serif: #{inspect($font-family-sans-serif)};\n --font-family-monospace: #{inspect($font-family-monospace)};\n}\n","// stylelint-disable declaration-no-important, selector-no-qualifying-type, property-no-vendor-prefix\n\n// Reboot\n//\n// Normalization of HTML elements, manually forked from Normalize.css to remove\n// styles targeting irrelevant browsers while applying new styles.\n//\n// Normalize is licensed MIT. https://github.com/necolas/normalize.css\n\n\n// Document\n//\n// 1. Change from `box-sizing: content-box` so that `width` is not affected by `padding` or `border`.\n// 2. Change the default font family in all browsers.\n// 3. Correct the line height in all browsers.\n// 4. Prevent adjustments of font size after orientation changes in IE on Windows Phone and in iOS.\n// 5. Change the default tap highlight to be completely transparent in iOS.\n\n*,\n*::before,\n*::after {\n box-sizing: border-box; // 1\n}\n\nhtml {\n font-family: sans-serif; // 2\n line-height: 1.15; // 3\n -webkit-text-size-adjust: 100%; // 4\n -webkit-tap-highlight-color: rgba($black, 0); // 5\n}\n\n// Shim for \"new\" HTML5 structural elements to display correctly (IE10, older browsers)\n// TODO: remove in v5\n// stylelint-disable-next-line selector-list-comma-newline-after\narticle, aside, figcaption, figure, footer, header, hgroup, main, nav, section {\n display: block;\n}\n\n// Body\n//\n// 1. Remove the margin in all browsers.\n// 2. As a best practice, apply a default `background-color`.\n// 3. Set an explicit initial text-align value so that we can later use\n// the `inherit` value on things like `` elements.\n\nbody {\n margin: 0; // 1\n font-family: $font-family-base;\n @include font-size($font-size-base);\n font-weight: $font-weight-base;\n line-height: $line-height-base;\n color: $body-color;\n text-align: left; // 3\n background-color: $body-bg; // 2\n}\n\n// Future-proof rule: in browsers that support :focus-visible, suppress the focus outline\n// on elements that programmatically receive focus but wouldn't normally show a visible\n// focus outline. In general, this would mean that the outline is only applied if the\n// interaction that led to the element receiving programmatic focus was a keyboard interaction,\n// or the browser has somehow determined that the user is primarily a keyboard user and/or\n// wants focus outlines to always be presented.\n//\n// See https://developer.mozilla.org/en-US/docs/Web/CSS/:focus-visible\n// and https://developer.paciellogroup.com/blog/2018/03/focus-visible-and-backwards-compatibility/\n[tabindex=\"-1\"]:focus:not(:focus-visible) {\n outline: 0 !important;\n}\n\n\n// Content grouping\n//\n// 1. Add the correct box sizing in Firefox.\n// 2. Show the overflow in Edge and IE.\n\nhr {\n box-sizing: content-box; // 1\n height: 0; // 1\n overflow: visible; // 2\n}\n\n\n//\n// Typography\n//\n\n// Remove top margins from headings\n//\n// By default, `

`-`

` all receive top and bottom margins. We nuke the top\n// margin for easier control within type scales as it avoids margin collapsing.\n// stylelint-disable-next-line selector-list-comma-newline-after\nh1, h2, h3, h4, h5, h6 {\n margin-top: 0;\n margin-bottom: $headings-margin-bottom;\n}\n\n// Reset margins on paragraphs\n//\n// Similarly, the top margin on `

`s get reset. However, we also reset the\n// bottom margin to use `rem` units instead of `em`.\np {\n margin-top: 0;\n margin-bottom: $paragraph-margin-bottom;\n}\n\n// Abbreviations\n//\n// 1. Duplicate behavior to the data-* attribute for our tooltip plugin\n// 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.\n// 3. Add explicit cursor to indicate changed behavior.\n// 4. Remove the bottom border in Firefox 39-.\n// 5. Prevent the text-decoration to be skipped.\n\nabbr[title],\nabbr[data-original-title] { // 1\n text-decoration: underline; // 2\n text-decoration: underline dotted; // 2\n cursor: help; // 3\n border-bottom: 0; // 4\n text-decoration-skip-ink: none; // 5\n}\n\naddress {\n margin-bottom: 1rem;\n font-style: normal;\n line-height: inherit;\n}\n\nol,\nul,\ndl {\n margin-top: 0;\n margin-bottom: 1rem;\n}\n\nol ol,\nul ul,\nol ul,\nul ol {\n margin-bottom: 0;\n}\n\ndt {\n font-weight: $dt-font-weight;\n}\n\ndd {\n margin-bottom: .5rem;\n margin-left: 0; // Undo browser default\n}\n\nblockquote {\n margin: 0 0 1rem;\n}\n\nb,\nstrong {\n font-weight: $font-weight-bolder; // Add the correct font weight in Chrome, Edge, and Safari\n}\n\nsmall {\n @include font-size(80%); // Add the correct font size in all browsers\n}\n\n//\n// Prevent `sub` and `sup` elements from affecting the line height in\n// all browsers.\n//\n\nsub,\nsup {\n position: relative;\n @include font-size(75%);\n line-height: 0;\n vertical-align: baseline;\n}\n\nsub { bottom: -.25em; }\nsup { top: -.5em; }\n\n\n//\n// Links\n//\n\na {\n color: $link-color;\n text-decoration: $link-decoration;\n background-color: transparent; // Remove the gray background on active links in IE 10.\n\n @include hover() {\n color: $link-hover-color;\n text-decoration: $link-hover-decoration;\n }\n}\n\n// And undo these styles for placeholder links/named anchors (without href).\n// It would be more straightforward to just use a[href] in previous block, but that\n// causes specificity issues in many other styles that are too complex to fix.\n// See https://github.com/twbs/bootstrap/issues/19402\n\na:not([href]):not([class]) {\n color: inherit;\n text-decoration: none;\n\n @include hover() {\n color: inherit;\n text-decoration: none;\n }\n}\n\n\n//\n// Code\n//\n\npre,\ncode,\nkbd,\nsamp {\n font-family: $font-family-monospace;\n @include font-size(1em); // Correct the odd `em` font sizing in all browsers.\n}\n\npre {\n // Remove browser default top margin\n margin-top: 0;\n // Reset browser default of `1em` to use `rem`s\n margin-bottom: 1rem;\n // Don't allow content to break outside\n overflow: auto;\n // Disable auto-hiding scrollbar in IE & legacy Edge to avoid overlap,\n // making it impossible to interact with the content\n -ms-overflow-style: scrollbar;\n}\n\n\n//\n// Figures\n//\n\nfigure {\n // Apply a consistent margin strategy (matches our type styles).\n margin: 0 0 1rem;\n}\n\n\n//\n// Images and content\n//\n\nimg {\n vertical-align: middle;\n border-style: none; // Remove the border on images inside links in IE 10-.\n}\n\nsvg {\n // Workaround for the SVG overflow bug in IE10/11 is still required.\n // See https://github.com/twbs/bootstrap/issues/26878\n overflow: hidden;\n vertical-align: middle;\n}\n\n\n//\n// Tables\n//\n\ntable {\n border-collapse: collapse; // Prevent double borders\n}\n\ncaption {\n padding-top: $table-cell-padding;\n padding-bottom: $table-cell-padding;\n color: $table-caption-color;\n text-align: left;\n caption-side: bottom;\n}\n\n// 1. Removes font-weight bold by inheriting\n// 2. Matches default `` alignment by inheriting `text-align`.\n// 3. Fix alignment for Safari\n\nth {\n font-weight: $table-th-font-weight; // 1\n text-align: inherit; // 2\n text-align: -webkit-match-parent; // 3\n}\n\n\n//\n// Forms\n//\n\nlabel {\n // Allow labels to use `margin` for spacing.\n display: inline-block;\n margin-bottom: $label-margin-bottom;\n}\n\n// Remove the default `border-radius` that macOS Chrome adds.\n//\n// Details at https://github.com/twbs/bootstrap/issues/24093\nbutton {\n // stylelint-disable-next-line property-disallowed-list\n border-radius: 0;\n}\n\n// Explicitly remove focus outline in Chromium when it shouldn't be\n// visible (e.g. as result of mouse click or touch tap). It already\n// should be doing this automatically, but seems to currently be\n// confused and applies its very visible two-tone outline anyway.\n\nbutton:focus:not(:focus-visible) {\n outline: 0;\n}\n\ninput,\nbutton,\nselect,\noptgroup,\ntextarea {\n margin: 0; // Remove the margin in Firefox and Safari\n font-family: inherit;\n @include font-size(inherit);\n line-height: inherit;\n}\n\nbutton,\ninput {\n overflow: visible; // Show the overflow in Edge\n}\n\nbutton,\nselect {\n text-transform: none; // Remove the inheritance of text transform in Firefox\n}\n\n// Set the cursor for non-` +

From feafedc445fd3ecb1ab3799055c24b1b18a50180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Wed, 25 Sep 2024 16:23:38 -0400 Subject: [PATCH 18/67] Refs #253, cleaning up login procedure using API, now able to log if 2fa is not enabled --- .../modules/FlaskModule/API/user/UserLogin.py | 214 +++++++----------- .../FlaskModule/API/user/UserLogin2FA.py | 19 +- .../FlaskModule/API/user/UserLoginBase.py | 8 +- .../FlaskModule/Views/LoginValidate2FAView.py | 74 +----- .../modules/FlaskModule/Views/LoginView.py | 34 --- teraserver/python/templates/login.html | 37 ++- 6 files changed, 128 insertions(+), 258 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py index 6caaa512..2a7e0e8b 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py @@ -1,6 +1,7 @@ from flask import session, request from flask_restx import Resource, reqparse, inputs from flask_babel import gettext +from flask_login import logout_user from modules.LoginModule.LoginModule import user_http_auth, LoginModule, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from opentera.redis.RedisRPCClient import RedisRPCClient @@ -9,148 +10,95 @@ import opentera.messages.python as messages from opentera.redis.RedisVars import RedisVars -from opentera.db.models.TeraUser import TeraUser -# model = api.model('Login', { -# 'websocket_url': fields.String, -# 'user_uuid': fields.String, -# 'user_token': fields.String -# }) -# Parser definition(s) +from modules.FlaskModule.API.user.UserLoginBase import UserLoginBase +from modules.FlaskModule.API.user.UserLoginBase import OutdatedClientVersionError, InvalidClientVersionError, \ + UserAlreadyLoggedInError + get_parser = api.parser() -get_parser.add_argument('with_websocket', type=inputs.boolean, help='If set, requires that a websocket url is returned.' - 'If not possible to do so, return a 403 error.') +get_parser.add_argument('with_websocket', type=inputs.boolean, default=False, + help='If set, requires that a websocket url is returned. If not possible to do so, return a 403 error.') +post_parser = api.parser() +post_parser.add_argument('with_websocket', type=inputs.boolean, default=False, + help='If set, requires that a websocket url is returned. If not possible to do so, return a 403 error.') -class UserLogin(Resource): +class UserLogin(UserLoginBase): + """ + UserLogin Resource. + """ def __init__(self, _api, *args, **kwargs): - Resource.__init__(self, _api, *args, **kwargs) - self.module = kwargs.get('flaskModule', None) - self.test = kwargs.get('test', False) + UserLoginBase.__init__(self, _api, *args, **kwargs) + + def _common_login_response(self, parser): + try: + # Validate args + args = parser.parse_args(strict=True) + response = {} + + version_info = self._verify_client_version() + if version_info: + response.update(version_info) + + # 2FA enabled? Client will need to proceed to 2FA login step first + if current_user.user_2fa_enabled: + if current_user.user_2fa_otp_enabled and current_user.user_2fa_otp_secret: + response['message'] = gettext('2FA required for this user.') + response['redirect_url'] = self._generate_2fa_verification_url() + else: + response['message'] = gettext('2FA enabled but OTP not set for this user. Please setup 2FA.') + response['redirect_url'] = self._generate_2fa_setup_url() + else: + # Standard Login without 2FA. Check if user is already logged in. + if args['with_websocket']: + self._verify_user_already_logged_in() + response['websocket_url'] = self._generate_websocket_url() + + # Generate user token + response['user_uuid'] = current_user.user_uuid + response['user_token'] = self._generate_user_token() + + except OutdatedClientVersionError as e: + self._user_logout() + + return { + 'version_latest': e.version_latest, + 'current_version': e.current_version, + 'version_error': e.version_error, + 'message': gettext('Client major version too old, not accepting login')}, 426 +# except InvalidClientVersionError as e: +# # Invalid client version, will not be handled for now +# pass + except UserAlreadyLoggedInError as e: + self._user_logout() + return gettext('User already logged in.') + str(e), 403 + except Exception as e: + # Something went wrong, logout user + self._user_logout() + raise e + else: + # Everything went well, return response + self._send_login_success_message() + return response, 200 + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)') @api.expect(get_parser) @user_http_auth.login_required def get(self): - args = get_parser.parse_args() - - # Redis key is handled in LoginModule - servername = self.module.config.server_config['hostname'] - port = self.module.config.server_config['port'] - if 'X_EXTERNALSERVER' in request.headers: - servername = request.headers['X_EXTERNALSERVER'] - - if 'X_EXTERNALPORT' in request.headers: - port = request.headers['X_EXTERNALPORT'] - - websocket_url = None - - # Get user token key from redis - token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) - - # Get login informations for log - login_infos = UserAgentParser.parse_request_for_login_infos(request) - - # Verify if user already logged in - online_users = [] - if not self.test: - rpc = RedisRPCClient(self.module.config.redis_config) - online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') - - if current_user.user_uuid not in online_users: - websocket_url = "wss://" + servername + ":" + str(port) + "/wss/user?id=" + session['_id'] - # print('Login - setting key with expiration in 60s', session['_id'], session['_user_id']) - self.module.redisSet(session['_id'], session['_user_id'], ex=60) - elif args['with_websocket']: - # User is online and a websocket is required - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_ERROR, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status= - messages.LoginEvent.LOGIN_STATUS_FAILED_WITH_ALREADY_LOGGED_IN, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint']) - - return gettext('User already logged in.'), 403 - - current_user.update_last_online() - user_token = current_user.get_token(token_key) - - # Return reply as json object - reply = {"user_uuid": session['_user_id'], - "user_token": user_token} - if websocket_url: - reply["websocket_url"] = websocket_url - - # Verify client version (optional for now) - # And add info to reply - if 'X-Client-Name' in request.headers and 'X-Client-Version' in request.headers: - try: - # Extract information - client_name = request.headers['X-Client-Name'] - client_version = request.headers['X-Client-Version'] - - client_version_parts = client_version.split('.') - - # Load known version from database. - from opentera.utils.TeraVersions import TeraVersions - versions = TeraVersions() - versions.load_from_db() - - # Verify if we have client information in DB - client_info = versions.get_client_version_with_name(client_name) - if client_info: - # We have something stored for this client, let's verify version numbers - # For now, we still allow login even when version mismatch - # Reply full version information - reply['version_latest'] = client_info.to_dict() - if client_info.version != client_version: - reply['version_error'] = gettext('Client version mismatch') - # If major version mismatch, kill client, first part of the version - stored_client_version_parts = client_info.version.split('.') - if len(stored_client_version_parts) and len(client_version_parts): - if stored_client_version_parts[0] != client_version_parts[0]: - # return 426 = upgrade required - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_ERROR, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status= - messages.LoginEvent.LOGIN_STATUS_UNKNOWN, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint'], - message=gettext('Client version mismatch')) - - return gettext('Client major version too old, not accepting login'), 426 - # else: - # return gettext('Invalid client name :') + client_name, 403 - except BaseException as e: - self.module.logger.log_error(self.module.module_name, - UserLogin.__name__, - 'get', 500, 'Invalid client version handler', str(e)) - return gettext('Invalid client version handler') + str(e), 500 - - self.module.logger.send_login_event(sender=self.module.module_name, - level=messages.LogEvent.LOGLEVEL_INFO, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status=messages.LoginEvent.LOGIN_STATUS_SUCCESS, - client_name=login_infos['client_name'], - client_version=login_infos['client_version'], - client_ip=login_infos['client_ip'], - os_name=login_infos['os_name'], - os_version=login_infos['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_infos['server_endpoint']) - - return reply + """ + Login to the server using HTTP Basic Authentication (HTTPAuth) + """ + return self._common_login_response(get_parser) + + + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)') + @api.expect(post_parser) + @user_http_auth.login_required + def post(self): + """ + Login to the server using HTTP Basic Authentication (HTTPAuth) + """ + return self._common_login_response(post_parser) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py index 65b9df87..00a916c6 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin2FA.py @@ -44,6 +44,7 @@ def _common_2fa_login_response(self, parser): try: # Validate args args = parser.parse_args(strict=True) + response = {} # Current user is logged in with HTTPAuth, or session # Let's verify if 2FA is enabled and if OTP is valid @@ -61,9 +62,9 @@ def _common_2fa_login_response(self, parser): return gettext('Invalid OTP code'), 403 # OTP validation completed, proceed with standard login - response = {} - version_info = self._verify_client_version() + if version_info: + response.update(version_info) if args['with_websocket']: self._verify_user_already_logged_in() @@ -73,9 +74,6 @@ def _common_2fa_login_response(self, parser): response['user_uuid'] = current_user.user_uuid response['user_token'] = self._generate_user_token() - if version_info: - response.update(version_info) - except OutdatedClientVersionError as e: self._user_logout() @@ -84,12 +82,12 @@ def _common_2fa_login_response(self, parser): 'current_version': e.current_version, 'version_error': e.version_error, 'message': gettext('Client major version too old, not accepting login')}, 426 - # except InvalidClientVersionError as e: - # # Invalid client version, will not be handled for now - # pass +# except InvalidClientVersionError as e: +# # Invalid client version, will not be handled for now +# pass except UserAlreadyLoggedInError as e: self._user_logout() - return gettext('User already logged in.'), 403 + return gettext('User already logged in.') + str(e), 403 except Exception as e: # Something went wrong, logout user self._user_logout() @@ -101,7 +99,7 @@ def _common_2fa_login_response(self, parser): @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth) and 2FA') @api.expect(get_parser, validate=True) - @user_http_auth.login_required + @LoginModule.user_session_required def get(self): return self._common_2fa_login_response(get_parser) @@ -110,4 +108,3 @@ def get(self): @LoginModule.user_session_required def post(self): return self._common_2fa_login_response(post_parser) - diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py index debe2e8c..e8ac3403 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -73,7 +73,7 @@ def _verify_user_already_logged_in(self) -> None: + current_user.user_name)) raise UserAlreadyLoggedInError(gettext('User already logged in.')) - def _verify_client_version(self) -> dict or None: + def _verify_client_version(self) -> dict | None: reply = {} # Extract login information @@ -153,6 +153,12 @@ def _generate_user_token(self) -> str: token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) return current_user.get_token(token_key) + def _generate_2fa_verification_url(self) -> str: + return "/login_validate_2fa" + + def _generate_2fa_setup_url(self) -> str: + return "/login_enable_2fa" + def _user_logout(self): logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py index bfd663c3..23c97c19 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginValidate2FAView.py @@ -43,76 +43,4 @@ def get(self): versions.load_from_db() return render_template('login_validate_2fa.html', hostname=hostname, port=port, - server_version=versions.version_string, - openteraplus_version=versions.get_client_version_with_name('OpenTeraPlus')) - - @LoginModule.user_session_required - def post(self): - # Verify the form - if '2fa_code' not in request.form: - return gettext('Missing 2FA code'), 400 - - # Get the user's 2FA code from the form - code = request.form['2fa_code'] - - # TODO Should use LoginModule instead of TeraUser directly ? - # Check the user's 2FA code - if not current_user.verify_2fa(code): - return gettext('Invalid 2FA code'), 401 - - # TODO This is duplication from the API login endpoint, how to avoid this ? - hostname = self.flaskModule.config.server_config['hostname'] - port = self.flaskModule.config.server_config['port'] - - if 'X_EXTERNALSERVER' in request.headers: - hostname = request.headers['X_EXTERNALSERVER'] - - if 'X_EXTERNALPORT' in request.headers: - port = request.headers['X_EXTERNALPORT'] - - # Generate user token - # Get user token key from redis - token_key = self.flaskModule.redisGet(RedisVars.RedisVar_UserTokenAPIKey) - - # Get login information for log - login_info = UserAgentParser.parse_request_for_login_infos(request) - - # Verify if user already logged in - online_users = [] - websocket_url = None - - if not self.test: - rpc = RedisRPCClient(self.flaskModule.config.redis_config) - online_users = rpc.call(ModuleNames.USER_MANAGER_MODULE_NAME.value, 'online_users') - - if current_user.user_uuid not in online_users: - websocket_url = "wss://" + hostname + ":" + str(port) + "/wss/user?id=" + session['_id'] - self.flaskModule.redisSet(session['_id'], session['_user_id'], ex=60) - else: - # User is online and a websocket is required - self.flaskModule.logger.send_login_event(sender=self.flaskModule.module_name, - level=messages.LogEvent.LOGLEVEL_ERROR, - login_type=messages.LoginEvent.LOGIN_TYPE_PASSWORD, - login_status= - messages.LoginEvent.LOGIN_STATUS_FAILED_WITH_ALREADY_LOGGED_IN, - client_name=login_info['client_name'], - client_version=login_info['client_version'], - client_ip=login_info['client_ip'], - os_name=login_info['os_name'], - os_version=login_info['os_version'], - user_uuid=current_user.user_uuid, - server_endpoint=login_info['server_endpoint']) - - return gettext('User already logged in.'), 403 - - current_user.update_last_online() - user_token = current_user.get_token(token_key) - - reply = {"user_uuid": session['_user_id'], - "user_token": user_token, - "websocket_url": websocket_url} - - return jsonify(reply) - - - + server_version=versions.version_string) diff --git a/teraserver/python/modules/FlaskModule/Views/LoginView.py b/teraserver/python/modules/FlaskModule/Views/LoginView.py index 7967ec28..340ab3a4 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginView.py @@ -28,37 +28,3 @@ def get(self): return render_template('login.html', hostname=hostname, port=port, server_version=versions.version_string) - - def post(self): - # Verify the form - if 'username' not in request.form or 'password' not in request.form: - return gettext('Missing username or password'), 400 - - # Get the user's name and password from the form - username = request.form['username'] - password = request.form['password'] - - # TODO Should use LoginModule instead of TeraUser directly - # Check the user's credentials - user = TeraUser.verify_password(username, password) - if user is None: - return gettext('Invalid username or password'), 401 - - login_user(user, remember=False) - - # Test for password change first - if user.user_force_password_change: - return redirect(url_for('login_change_password')) - - # Check if the user has 2FA enabled - # We may want to change the behavior here according to a configuration flag - if user.user_2fa_enabled and user.user_2fa_otp_secret is None: - # Redirect to enable 2FA page - return redirect(url_for('login_enable_2fa')) - elif user.user_2fa_enabled: - # Redirect to 2FA validation page - return redirect(url_for('login_validate_2fa')) - else: - # TODO Make standard user log in properly - # Should be logged in as a standard user - return redirect(url_for('login')) diff --git a/teraserver/python/templates/login.html b/teraserver/python/templates/login.html index 11226c8d..5cb7cc2e 100644 --- a/teraserver/python/templates/login.html +++ b/teraserver/python/templates/login.html @@ -9,6 +9,13 @@ + + - - - - - - - -
-
-
-
-
Enable 2FA
-
- QR Code -
-
- -
- -
-
-
- -
- - -
-
-
-
-
-
- - - - \ No newline at end of file diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html new file mode 100644 index 00000000..4dc9ad99 --- /dev/null +++ b/teraserver/python/templates/login_setup_2fa.html @@ -0,0 +1,90 @@ + + + + + OpenTera Setup 2FA + + + + + + + + + + + + + +
+
+
+
+
Enable 2FA
+
+ +
+
+ +
+ +
+
+
+ +
+ + +
+
+
+
+
+
+ + + + From ff7c01d7ecefba4811ac58970a54fcf74750fd66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 26 Sep 2024 12:59:37 -0400 Subject: [PATCH 21/67] Refs #253, Working Setup 2FA api. --- .../FlaskModule/API/user/UserLoginSetup2FA.py | 48 ++++++++++++++++++- .../FlaskModule/Views/LoginSetup2FAView.py | 24 ---------- .../python/templates/login_setup_2fa.html | 3 +- 3 files changed, 48 insertions(+), 27 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py index 6994c5f1..0b5d90e4 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py @@ -101,5 +101,49 @@ def get(self): @api.expect(post_parser, validate=True) @LoginModule.user_session_required def post(self): - args = post_parser.parse_args(strict=True) - return gettext('Not implemented'), 501 + try: + args = post_parser.parse_args(strict=True) + response = {} + + # Current user is logged in with HTTPAuth, or session + # Let's verify if 2FA is enabled and if OTP is valid + if not current_user.user_2fa_enabled: + self._user_logout() + return gettext('User does not have 2FA enabled'), 403 + + if current_user.user_2fa_otp_secret: + self._user_logout() + return gettext('User already has 2FA OTP secret set'), 403 + + data = {'user_2fa_enabled': True, + 'user_2fa_otp_enabled': True, + 'user_2fa_otp_secret': args['otp_secret'], + 'user_2fa_email_enabled': args['with_email_enabled']} + + # Save user to db + TeraUser.update(current_user.id_user, data) + + # Redirect to 2FA validation page + response['message'] = gettext('2FA enabled for this user.') + response['redirect_url'] = self._generate_2fa_verification_url() + + except OutdatedClientVersionError as e: + self._user_logout() + return { + 'version_latest': e.version_latest, + 'current_version': e.current_version, + 'version_error': e.version_error, + 'message': gettext('Client major version too old, not accepting login')}, 426 +# except InvalidClientVersionError as e: +# # Invalid client version, will not be handled for now +# pass + except UserAlreadyLoggedInError as e: + self._user_logout() + return gettext('User already logged in.') + str(e), 403 + except Exception as e: + # Something went wrong, logout user + self._user_logout() + raise e + else: + # Everything went well, return response + return response, 200 diff --git a/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py index 64662646..652444d5 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py @@ -40,27 +40,3 @@ def get(self): return render_template('login_setup_2fa.html', hostname=hostname, port=port, server_version=versions.version_string) - - @LoginModule.user_session_required - def post(self): - # Verify if user is authenticated, should be stored in session - if not current_user: - return redirect(url_for('login')) - - if 'enable_2fa' in request.form and request.form['enable_2fa'] == 'on' and 'otp_secret' in request.form: - # Enable 2FA - current_user.user_2fa_enabled = True - current_user.user_2fa_otp_enabled = True - current_user.user_2fa_email_enabled = False - # Save user to db - # TODO enable email 2FA - TeraUser.update(current_user.id_user, {'user_2fa_enabled': True, - 'user_2fa_otp_enabled': True, - 'user_2fa_otp_secret': request.form['otp_secret'], - 'user_2fa_email_enabled': False}) - - # Redirect to 2FA validation page - return redirect(url_for('login_validate_2fa')) - - # Redirect to login page - return redirect(url_for('login')) diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html index 4dc9ad99..da4651b9 100644 --- a/teraserver/python/templates/login_setup_2fa.html +++ b/teraserver/python/templates/login_setup_2fa.html @@ -46,10 +46,11 @@ success: function(response) { console.log("2FA setup success"); // Redirect to the main page - window.location.href = "/main"; + window.location.href = response.redirect_url }, error: function(response) { console.log("Error setting up 2FA"); + window.location.href = '/login'; } }); }); From bb357620d75b3c0042c2531024caa10c84e7387a Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Thu, 26 Sep 2024 13:05:02 -0400 Subject: [PATCH 22/67] Refs #253. Login view UI adjustments --- teraserver/python/static/img/loading.gif | Bin 0 -> 68199 bytes teraserver/python/templates/login.html | 35 +++++++++++++++++------ 2 files changed, 26 insertions(+), 9 deletions(-) create mode 100644 teraserver/python/static/img/loading.gif diff --git a/teraserver/python/static/img/loading.gif b/teraserver/python/static/img/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..b1b8b7f5e55f57d6d316902e78e965034ef2822f GIT binary patch literal 68199 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 z @@ -62,7 +64,7 @@
-
+
Enable 2FA
From 1cc9a055e9351f9a1f4b106c17238a17a1d4b0c5 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 30 Sep 2024 11:20:28 -0400 Subject: [PATCH 30/67] Refs #253. Disabled 2fa in progress Qt signal --- teraserver/python/templates/login.html | 2 +- teraserver/python/templates/login_setup_2fa.html | 7 +++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/teraserver/python/templates/login.html b/teraserver/python/templates/login.html index 84e2e1f8..bd508767 100644 --- a/teraserver/python/templates/login.html +++ b/teraserver/python/templates/login.html @@ -66,7 +66,7 @@ if (qtObject){ if ("reason" in response){ if (response.reason === '2fa'){ - qtObject.sendCheckInProgress(); + //qtObject.sendCheckInProgress(); } if (response.reason === '2fa_setup'){ qtObject.sendSetupInProgress(); diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html index f2156a73..d8bcea7a 100644 --- a/teraserver/python/templates/login_setup_2fa.html +++ b/teraserver/python/templates/login_setup_2fa.html @@ -62,9 +62,12 @@ -
+
+
+ {{ gettext('You need to setup multi-factor authentication before continuing.') }} +
-
+
Enable 2FA
From 88b7ca6b11436dd5f7e7cbe760c592b0037fb024 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 30 Sep 2024 14:07:02 -0400 Subject: [PATCH 31/67] Refs #253, add site_2fa_required column. --- ...2_add_site_2fa_required_column_to_tera_.py | 26 +++++++++++++++++++ .../python/opentera/db/models/TeraSite.py | 10 ++++--- 2 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 teraserver/python/alembic/versions/c58727df3ac2_add_site_2fa_required_column_to_tera_.py diff --git a/teraserver/python/alembic/versions/c58727df3ac2_add_site_2fa_required_column_to_tera_.py b/teraserver/python/alembic/versions/c58727df3ac2_add_site_2fa_required_column_to_tera_.py new file mode 100644 index 00000000..8b7d3f82 --- /dev/null +++ b/teraserver/python/alembic/versions/c58727df3ac2_add_site_2fa_required_column_to_tera_.py @@ -0,0 +1,26 @@ +"""add_site_2fa_required_column_to_tera_site + +Revision ID: c58727df3ac2 +Revises: 89343f5c95b9 +Create Date: 2024-09-30 13:58:38.839824 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'c58727df3ac2' +down_revision = '89343f5c95b9' +branch_labels = None +depends_on = None + + +def upgrade(): + # Add site_2fa_required column to t_sites table + op.add_column(table_name='t_sites', column=sa.Column('site_2fa_required', + sa.Boolean, nullable=False, server_default=str(False))) + +def downgrade(): + # Remove columns + op.drop_column('t_sites', 'site_2fa_required') diff --git a/teraserver/python/opentera/db/models/TeraSite.py b/teraserver/python/opentera/db/models/TeraSite.py index a6804a24..13bbd89d 100644 --- a/teraserver/python/opentera/db/models/TeraSite.py +++ b/teraserver/python/opentera/db/models/TeraSite.py @@ -1,14 +1,14 @@ -from opentera.db.Base import BaseModel -from opentera.db.SoftDeleteMixin import SoftDeleteMixin -from sqlalchemy import Column, Integer, String, Sequence +from sqlalchemy import Column, Integer, Boolean, String, Sequence from sqlalchemy.orm import relationship from sqlalchemy.exc import IntegrityError - +from opentera.db.Base import BaseModel +from opentera.db.SoftDeleteMixin import SoftDeleteMixin class TeraSite(BaseModel, SoftDeleteMixin): __tablename__ = 't_sites' id_site = Column(Integer, Sequence('id_site_sequence'), primary_key=True, autoincrement=True) site_name = Column(String, nullable=False) + site_2fa_required = Column(Boolean, nullable=False, default=False) site_devices = relationship("TeraDevice", secondary="t_devices_sites", back_populates="device_sites") site_projects = relationship("TeraProject", cascade="delete", passive_deletes=True, @@ -22,6 +22,8 @@ class TeraSite(BaseModel, SoftDeleteMixin): site_tests_types = relationship("TeraTestType", secondary="t_tests_types_sites", viewonly=True) + + def to_json(self, ignore_fields=None, minimal=False): if ignore_fields is None: ignore_fields = [] From d9ce7508f35af9add28b7350294fd32cf5d47db9 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 30 Sep 2024 15:12:06 -0400 Subject: [PATCH 32/67] Refs #253. Revised 2FA setup screen and flow. --- .../FlaskModule/API/user/UserLoginBase.py | 5 +- .../FlaskModule/API/user/UserLoginSetup2FA.py | 12 +++- .../FlaskModule/Views/LoginSetup2FAView.py | 3 +- teraserver/python/static/css/login_style.css | 4 ++ .../python/templates/login_setup_2fa.html | 64 +++++++++++++++---- .../python/templates/login_validate_2fa.html | 32 +++++++--- 6 files changed, 95 insertions(+), 25 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py index 4bb0090c..1b450d21 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -88,7 +88,7 @@ def _verify_user_already_logged_in(self) -> None: user_uuid=current_user.user_uuid, server_endpoint=user_agent_info['server_endpoint'], message=gettext('User already logged in :' - + current_user.user_name)) + + current_user.user_username)) raise UserAlreadyLoggedInError(gettext('User already logged in.')) @@ -190,6 +190,9 @@ def _generate_2fa_verification_url(self) -> str: def _generate_2fa_setup_url(self) -> str: return "/login_setup_2fa" + def _generate_login_url(self) -> str: + return "/login" + def _user_logout(self): logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py index fc515d2a..11bb4b5f 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginSetup2FA.py @@ -8,6 +8,7 @@ from modules.FlaskModule.API.user.UserLoginBase import OutdatedClientVersionError, \ UserAlreadyLoggedInError, TooMany2FALoginAttemptsError from opentera.db.models.TeraUser import TeraUser +import opentera.messages.python as messages # Get parser @@ -18,6 +19,7 @@ post_parser.add_argument('otp_secret', type=str, required=True, help='OTP Secret for the user.') post_parser.add_argument('with_email_enabled', type=inputs.boolean, help='Enable email notifications for 2FA', default=False) +post_parser.add_argument('otp_code', type=str, required=True, help='OTP code for validation on setup') class UserLoginSetup2FA(UserLoginBase): @@ -124,6 +126,14 @@ def post(self): # This should not happen here, but just in case self._verify_2fa_login_attempts(current_user.user_uuid) + # Verify OTP code if present + if args['otp_code']: + totp = pyotp.TOTP(args['otp_secret']) + if not totp.verify(args['otp_code']): + message = gettext('Invalid OTP code') + self._send_login_failure_message(messages.LoginEvent.LOGIN_STATUS_UNKNOWN, message) + return message, 401 + data = {'user_2fa_enabled': True, 'user_2fa_otp_enabled': True, 'user_2fa_otp_secret': args['otp_secret'], @@ -134,7 +144,7 @@ def post(self): # Redirect to 2FA validation page response['message'] = gettext('2FA enabled for this user.') - response['redirect_url'] = self._generate_2fa_verification_url() + response['redirect_url'] = self._generate_2fa_verification_url() + "?code=" + args['otp_code'] except OutdatedClientVersionError as e: self._user_logout() diff --git a/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py b/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py index 652444d5..e1d4ae13 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginSetup2FAView.py @@ -39,4 +39,5 @@ def get(self): versions.load_from_db() return render_template('login_setup_2fa.html', hostname=hostname, port=port, - server_version=versions.version_string) + server_version=versions.version_string, + user_has_email=current_user.user_email is not None and current_user.user_email != "") diff --git a/teraserver/python/static/css/login_style.css b/teraserver/python/static/css/login_style.css index 25badcce..7b079331 100644 --- a/teraserver/python/static/css/login_style.css +++ b/teraserver/python/static/css/login_style.css @@ -19,4 +19,8 @@ body{ .img-logo{ max-height: 175px; +} + +.img-qr{ + width: 200px; } \ No newline at end of file diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html index d8bcea7a..ca68345d 100644 --- a/teraserver/python/templates/login_setup_2fa.html +++ b/teraserver/python/templates/login_setup_2fa.html @@ -14,6 +14,14 @@ + + + + - @@ -79,40 +134,44 @@
-
2FA
+
+
+
+
+ +
+
+
{{ gettext('Multi Factor Authentication') }}
+
5:00
+
- - +
+
- - --> - - -
-
- -
- -
- - -
- -
+
+
+ +
+
+ - +
+
From a629124576af63e64972807ae2b816706ce1ab98 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Tue, 1 Oct 2024 15:09:49 -0400 Subject: [PATCH 38/67] Refs #253. Updated translations. --- .../en/LC_MESSAGES/filetransferservice.po | 2 +- .../fr/LC_MESSAGES/filetransferservice.po | 2 +- .../en/LC_MESSAGES/loggingservice.po | 2 +- .../fr/LC_MESSAGES/loggingservice.po | 2 +- .../en/LC_MESSAGES/videorehabservice.po | 2 +- .../fr/LC_MESSAGES/videorehabservice.po | 2 +- .../python/templates/login_setup_2fa.html | 2 +- .../translations/en/LC_MESSAGES/messages.po | 169 ++++++++++++------ .../translations/fr/LC_MESSAGES/messages.po | 168 +++++++++++------ 9 files changed, 240 insertions(+), 111 deletions(-) diff --git a/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po b/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po index c71411c0..77e5be28 100644 --- a/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po +++ b/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:06-0400\n" "PO-Revision-Date: 2021-01-19 16:16-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po b/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po index 70f17998..54bfafac 100644 --- a/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po +++ b/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:06-0400\n" "PO-Revision-Date: 2023-02-28 08:22-0500\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po b/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po index 65a0796c..576e9f7f 100644 --- a/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po +++ b/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:06-0400\n" "PO-Revision-Date: 2023-01-26 13:29-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po b/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po index 2e2026e6..0f474aa9 100644 --- a/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po +++ b/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:06-0400\n" "PO-Revision-Date: 2023-02-28 08:10-0500\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po b/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po index e5108852..936597b2 100644 --- a/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po +++ b/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:05-0400\n" "PO-Revision-Date: 2021-01-19 16:16-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po b/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po index 1240aebe..cc9bb5db 100644 --- a/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po +++ b/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:05-0400\n" "PO-Revision-Date: 2023-05-23 14:29-0400\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html index e55b0801..7b0bc8d3 100644 --- a/teraserver/python/templates/login_setup_2fa.html +++ b/teraserver/python/templates/login_setup_2fa.html @@ -45,7 +45,7 @@ $(document).ajaxComplete(function(event, request, settings) { $('#loading').removeClass("d-flex").addClass("d-none"); - timeoutSeconds = 30; + timeoutSeconds = 300; updateTimeLeft(); }); diff --git a/teraserver/python/translations/en/LC_MESSAGES/messages.po b/teraserver/python/translations/en/LC_MESSAGES/messages.po index 2d0555ae..20d3080b 100644 --- a/teraserver/python/translations/en/LC_MESSAGES/messages.po +++ b/teraserver/python/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" +"POT-Creation-Date: 2024-10-01 15:00-0400\n" "PO-Revision-Date: 2021-01-25 13:01-0500\n" "Last-Translator: \n" "Language: en\n" @@ -154,9 +154,9 @@ msgstr "" #: modules/FlaskModule/API/user/UserQuerySessionTypes.py:138 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:187 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:275 -#: modules/FlaskModule/API/user/UserQuerySites.py:124 #: modules/FlaskModule/API/user/UserQuerySites.py:127 -#: modules/FlaskModule/API/user/UserQuerySites.py:176 +#: modules/FlaskModule/API/user/UserQuerySites.py:131 +#: modules/FlaskModule/API/user/UserQuerySites.py:180 #: modules/FlaskModule/API/user/UserQueryStats.py:52 #: modules/FlaskModule/API/user/UserQueryStats.py:57 #: modules/FlaskModule/API/user/UserQueryStats.py:62 @@ -282,9 +282,9 @@ msgstr "" #: modules/FlaskModule/API/user/UserQuerySessions.py:272 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:246 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:286 -#: modules/FlaskModule/API/user/UserQuerySites.py:140 -#: modules/FlaskModule/API/user/UserQuerySites.py:155 -#: modules/FlaskModule/API/user/UserQuerySites.py:194 +#: modules/FlaskModule/API/user/UserQuerySites.py:144 +#: modules/FlaskModule/API/user/UserQuerySites.py:159 +#: modules/FlaskModule/API/user/UserQuerySites.py:198 #: modules/FlaskModule/API/user/UserQueryTestType.py:222 #: modules/FlaskModule/API/user/UserQueryTestType.py:237 #: modules/FlaskModule/API/user/UserQueryTestType.py:345 @@ -326,7 +326,7 @@ msgstr "" #: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:101 #: modules/FlaskModule/API/user/UserQuerySessionTypes.py:79 #: modules/FlaskModule/API/user/UserQuerySessions.py:103 -#: modules/FlaskModule/API/user/UserQuerySites.py:97 +#: modules/FlaskModule/API/user/UserQuerySites.py:100 #: modules/FlaskModule/API/user/UserQueryTestType.py:112 #: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:99 #: modules/FlaskModule/API/user/UserQueryUserPreferences.py:67 @@ -379,13 +379,13 @@ msgstr "" #: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:61 #: modules/FlaskModule/API/device/DeviceQuerySessions.py:137 #: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:164 -#: modules/LoginModule/LoginModule.py:584 -#: modules/LoginModule/LoginModule.py:684 -#: modules/LoginModule/LoginModule.py:750 -#: modules/LoginModule/LoginModule.py:777 -#: modules/LoginModule/LoginModule.py:796 -#: modules/LoginModule/LoginModule.py:815 +#: modules/LoginModule/LoginModule.py:586 +#: modules/LoginModule/LoginModule.py:686 +#: modules/LoginModule/LoginModule.py:752 +#: modules/LoginModule/LoginModule.py:779 +#: modules/LoginModule/LoginModule.py:798 #: modules/LoginModule/LoginModule.py:817 +#: modules/LoginModule/LoginModule.py:819 msgid "Unauthorized" msgstr "" @@ -943,66 +943,67 @@ msgstr "" msgid "No answer from service." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:48 +#: modules/FlaskModule/API/user/UserLogin.py:46 msgid "2FA required for this user." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:51 -msgid "2FA enabled but OTP not set for this user. Please setup 2FA." +#: modules/FlaskModule/API/user/UserLogin.py:50 +msgid "2FA enabled but OTP not set for this user.Please setup 2FA." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:70 -#: modules/FlaskModule/API/user/UserLogin2FA.py:84 -#: modules/FlaskModule/API/user/UserLoginBase.py:124 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:84 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:136 +#: modules/FlaskModule/API/user/UserLogin.py:71 +#: modules/FlaskModule/API/user/UserLogin2FA.py:101 +#: modules/FlaskModule/API/user/UserLoginBase.py:154 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:85 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:155 msgid "Client major version too old, not accepting login" msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:76 -#: modules/FlaskModule/API/user/UserLogin2FA.py:90 -#: modules/FlaskModule/API/user/UserLoginBase.py:74 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:90 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:142 -msgid "User already logged in." -msgstr "" - -#: modules/FlaskModule/API/user/UserLogin2FA.py:53 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:52 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:112 +#: modules/FlaskModule/API/user/UserLogin2FA.py:49 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:49 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:119 msgid "User does not have 2FA enabled" msgstr "" -#: modules/FlaskModule/API/user/UserLogin2FA.py:56 +#: modules/FlaskModule/API/user/UserLogin2FA.py:54 msgid "User does not have 2FA OTP enabled or secret set" msgstr "" -#: modules/FlaskModule/API/user/UserLogin2FA.py:62 +#: modules/FlaskModule/API/user/UserLogin2FA.py:74 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:133 msgid "Invalid OTP code" msgstr "" -#: modules/FlaskModule/API/user/UserLoginBase.py:72 +#: modules/FlaskModule/API/user/UserLoginBase.py:90 msgid "User already logged in :" msgstr "" -#: modules/FlaskModule/API/user/UserLoginBase.py:104 +#: modules/FlaskModule/API/user/UserLoginBase.py:92 +msgid "User already logged in." +msgstr "" + +#: modules/FlaskModule/API/user/UserLoginBase.py:101 +msgid "Too many 2FA attempts. Please wait and try again." +msgstr "" + +#: modules/FlaskModule/API/user/UserLoginBase.py:134 msgid "Client major version mismatch" msgstr "" -#: modules/FlaskModule/API/user/UserLoginBase.py:121 +#: modules/FlaskModule/API/user/UserLoginBase.py:151 msgid "Client version mismatch" msgstr "" -#: modules/FlaskModule/API/user/UserLoginBase.py:141 +#: modules/FlaskModule/API/user/UserLoginBase.py:171 msgid "Unknown client name :" msgstr "" -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:56 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:116 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:53 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:123 msgid "User already has 2FA OTP secret set" msgstr "" -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:127 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:146 msgid "2FA enabled for this user." msgstr "" @@ -1614,15 +1615,15 @@ msgstr "" msgid "No site access to delete" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:112 +#: modules/FlaskModule/API/user/UserQuerySites.py:115 msgid "Missing site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:119 +#: modules/FlaskModule/API/user/UserQuerySites.py:122 msgid "Missing id_site field" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:187 +#: modules/FlaskModule/API/user/UserQuerySites.py:191 msgid "" "Can't delete site: please delete all participants with sessions before " "deleting." @@ -1836,16 +1837,16 @@ msgstr "" msgid "Invalid old password" msgstr "" -#: modules/LoginModule/LoginModule.py:619 -#: modules/LoginModule/LoginModule.py:652 +#: modules/LoginModule/LoginModule.py:621 +#: modules/LoginModule/LoginModule.py:654 msgid "Disabled device" msgstr "" -#: modules/LoginModule/LoginModule.py:629 +#: modules/LoginModule/LoginModule.py:631 msgid "Invalid token" msgstr "" -#: modules/LoginModule/LoginModule.py:729 +#: modules/LoginModule/LoginModule.py:731 msgid "Invalid Token" msgstr "" @@ -2116,12 +2117,12 @@ msgid "Port" msgstr "" #: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:20 -#: templates/login.html:110 +#: templates/login.html:122 msgid "Username" msgstr "" #: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:38 -#: templates/login.html:116 +#: templates/login.html:128 msgid "Password" msgstr "" @@ -2298,6 +2299,10 @@ msgid "Site ID" msgstr "" #: opentera/forms/TeraSiteForm.py:18 +msgid "Users Require 2FA" +msgstr "" + +#: opentera/forms/TeraSiteForm.py:20 msgid "Site Role" msgstr "" @@ -2541,14 +2546,71 @@ msgstr "" msgid "OpenTera Login Page" msgstr "" -#: templates/login.html:76 +#: templates/login.html:86 msgid "Invalid username or password" msgstr "" -#: templates/login.html:124 +#: templates/login.html:136 msgid "Login" msgstr "" +#: templates/login_setup_2fa.html:107 +msgid "You need to setup multi-factor authentication before continuing." +msgstr "" + +#: templates/login_setup_2fa.html:121 +msgid "OTP configuration" +msgstr "" + +#: templates/login_setup_2fa.html:127 +msgid "Scan the QR code with your authenticator app" +msgstr "" + +#: templates/login_setup_2fa.html:128 +msgid "Enter the generated code:" +msgstr "" + +#: templates/login_setup_2fa.html:134 templates/login_setup_2fa.html:151 +#: templates/login_validate_2fa.html:162 +msgid "Validate" +msgstr "" + +#: templates/login_setup_2fa.html:144 +msgid "Use email for authentication" +msgstr "" + +#: templates/login_setup_2fa.html:149 +msgid "No email address configured. Please contact your administrator." +msgstr "" + +#: templates/login_validate_2fa.html:32 +msgid "Code validation timeout. Redirecting to login." +msgstr "" + +#: templates/login_validate_2fa.html:117 +msgid "Too many attempts - returning to login" +msgstr "" + +#: templates/login_validate_2fa.html:120 +msgid "Invalid code" +msgstr "" + +#: templates/login_validate_2fa.html:120 +msgid "attempts left" +msgstr "" + +#: templates/login_validate_2fa.html:144 +msgid "Multi Factor Authentication" +msgstr "" + +#: templates/login_validate_2fa.html:151 +msgid "Authentication code" +msgstr "" + +#: templates/login_validate_2fa.html:171 +msgid "Successfully authenticated" +msgstr "" + #~ msgid "Can't delete participant: please delete all sessions before deleting." #~ msgstr "" @@ -2579,3 +2641,6 @@ msgstr "" #~ msgid "Invalid client version handler" #~ msgstr "" +#~ msgid "2FA enabled but OTP not set for this user. Please setup 2FA." +#~ msgstr "" + diff --git a/teraserver/python/translations/fr/LC_MESSAGES/messages.po b/teraserver/python/translations/fr/LC_MESSAGES/messages.po index 3a449237..96efd978 100644 --- a/teraserver/python/translations/fr/LC_MESSAGES/messages.po +++ b/teraserver/python/translations/fr/LC_MESSAGES/messages.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-09-27 09:23-0400\n" -"PO-Revision-Date: 2024-09-27 09:30-0400\n" +"POT-Creation-Date: 2024-10-01 15:00-0400\n" +"PO-Revision-Date: 2024-10-01 15:04-0400\n" "Last-Translator: \n" "Language-Team: fr \n" "Language: fr\n" @@ -155,9 +155,9 @@ msgstr "Configuration manquante" #: modules/FlaskModule/API/user/UserQuerySessionTypes.py:138 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:187 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:275 -#: modules/FlaskModule/API/user/UserQuerySites.py:124 #: modules/FlaskModule/API/user/UserQuerySites.py:127 -#: modules/FlaskModule/API/user/UserQuerySites.py:176 +#: modules/FlaskModule/API/user/UserQuerySites.py:131 +#: modules/FlaskModule/API/user/UserQuerySites.py:180 #: modules/FlaskModule/API/user/UserQueryStats.py:52 #: modules/FlaskModule/API/user/UserQueryStats.py:57 #: modules/FlaskModule/API/user/UserQueryStats.py:62 @@ -283,9 +283,9 @@ msgstr "Accès refusé" #: modules/FlaskModule/API/user/UserQuerySessions.py:272 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:246 #: modules/FlaskModule/API/user/UserQuerySiteAccess.py:286 -#: modules/FlaskModule/API/user/UserQuerySites.py:140 -#: modules/FlaskModule/API/user/UserQuerySites.py:155 -#: modules/FlaskModule/API/user/UserQuerySites.py:194 +#: modules/FlaskModule/API/user/UserQuerySites.py:144 +#: modules/FlaskModule/API/user/UserQuerySites.py:159 +#: modules/FlaskModule/API/user/UserQuerySites.py:198 #: modules/FlaskModule/API/user/UserQueryTestType.py:222 #: modules/FlaskModule/API/user/UserQueryTestType.py:237 #: modules/FlaskModule/API/user/UserQueryTestType.py:345 @@ -327,7 +327,7 @@ msgstr "Erreur de base de données" #: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:101 #: modules/FlaskModule/API/user/UserQuerySessionTypes.py:79 #: modules/FlaskModule/API/user/UserQuerySessions.py:103 -#: modules/FlaskModule/API/user/UserQuerySites.py:97 +#: modules/FlaskModule/API/user/UserQuerySites.py:100 #: modules/FlaskModule/API/user/UserQueryTestType.py:112 #: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:99 #: modules/FlaskModule/API/user/UserQueryUserPreferences.py:67 @@ -380,10 +380,10 @@ msgstr "Arguments manquants" #: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:61 #: modules/FlaskModule/API/device/DeviceQuerySessions.py:137 #: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:164 -#: modules/LoginModule/LoginModule.py:584 modules/LoginModule/LoginModule.py:684 -#: modules/LoginModule/LoginModule.py:750 modules/LoginModule/LoginModule.py:777 -#: modules/LoginModule/LoginModule.py:796 modules/LoginModule/LoginModule.py:815 -#: modules/LoginModule/LoginModule.py:817 +#: modules/LoginModule/LoginModule.py:586 modules/LoginModule/LoginModule.py:686 +#: modules/LoginModule/LoginModule.py:752 modules/LoginModule/LoginModule.py:779 +#: modules/LoginModule/LoginModule.py:798 modules/LoginModule/LoginModule.py:817 +#: modules/LoginModule/LoginModule.py:819 msgid "Unauthorized" msgstr "Non autorisé" @@ -952,70 +952,71 @@ msgstr "Non encore implémenté" msgid "No answer from service." msgstr "Aucune réponse du service." -#: modules/FlaskModule/API/user/UserLogin.py:48 +#: modules/FlaskModule/API/user/UserLogin.py:46 msgid "2FA required for this user." msgstr "2FA requise pour cet utilisateur." -#: modules/FlaskModule/API/user/UserLogin.py:51 -msgid "2FA enabled but OTP not set for this user. Please setup 2FA." +#: modules/FlaskModule/API/user/UserLogin.py:50 +msgid "2FA enabled but OTP not set for this user.Please setup 2FA." msgstr "" -"Authentification double-facteur requise, mais non configurée pour cet " +"Authentification multi-facteurs requise, mais non configurée pour cet " "utilisateur. Veuillez configurer celle-ci." -#: modules/FlaskModule/API/user/UserLogin.py:70 -#: modules/FlaskModule/API/user/UserLogin2FA.py:84 -#: modules/FlaskModule/API/user/UserLoginBase.py:124 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:84 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:136 +#: modules/FlaskModule/API/user/UserLogin.py:71 +#: modules/FlaskModule/API/user/UserLogin2FA.py:101 +#: modules/FlaskModule/API/user/UserLoginBase.py:154 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:85 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:155 msgid "Client major version too old, not accepting login" msgstr "La version du client est trop vieille (major), accès refusé" -#: modules/FlaskModule/API/user/UserLogin.py:76 -#: modules/FlaskModule/API/user/UserLogin2FA.py:90 -#: modules/FlaskModule/API/user/UserLoginBase.py:74 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:90 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:142 -msgid "User already logged in." -msgstr "L'utilisateur est déjà connecté." - -#: modules/FlaskModule/API/user/UserLogin2FA.py:53 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:52 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:112 +#: modules/FlaskModule/API/user/UserLogin2FA.py:49 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:49 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:119 msgid "User does not have 2FA enabled" msgstr "L'utilisation n'a pas la double authentification d'activée" -#: modules/FlaskModule/API/user/UserLogin2FA.py:56 +#: modules/FlaskModule/API/user/UserLogin2FA.py:54 msgid "User does not have 2FA OTP enabled or secret set" msgstr "" "L'utilisateur n'a pas la double authentification par OTP d'activée ou de " -"configurée." +"configurée" -#: modules/FlaskModule/API/user/UserLogin2FA.py:62 +#: modules/FlaskModule/API/user/UserLogin2FA.py:74 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:133 msgid "Invalid OTP code" msgstr "Code OTP invalide" -#: modules/FlaskModule/API/user/UserLoginBase.py:72 +#: modules/FlaskModule/API/user/UserLoginBase.py:90 msgid "User already logged in :" msgstr "L'utilisateur est déjà connecté :" -#: modules/FlaskModule/API/user/UserLoginBase.py:104 +#: modules/FlaskModule/API/user/UserLoginBase.py:92 +msgid "User already logged in." +msgstr "L'utilisateur est déjà connecté." + +#: modules/FlaskModule/API/user/UserLoginBase.py:101 +msgid "Too many 2FA attempts. Please wait and try again." +msgstr "Trop d'essais d'authentification. Veuillez attendre et essayer à nouveau." + +#: modules/FlaskModule/API/user/UserLoginBase.py:134 msgid "Client major version mismatch" msgstr "La version majeure du client ne correspond pas" -#: modules/FlaskModule/API/user/UserLoginBase.py:121 +#: modules/FlaskModule/API/user/UserLoginBase.py:151 msgid "Client version mismatch" msgstr "La version du client ne correspond pas" -#: modules/FlaskModule/API/user/UserLoginBase.py:141 +#: modules/FlaskModule/API/user/UserLoginBase.py:171 msgid "Unknown client name :" msgstr "Nom du client inconnu :" -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:56 -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:116 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:53 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:123 msgid "User already has 2FA OTP secret set" msgstr "L'utilisateur a déjà configuré la double authentification par OTP" -#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:127 +#: modules/FlaskModule/API/user/UserLoginSetup2FA.py:146 msgid "2FA enabled for this user." msgstr "Double authentification activée pour cet utilisateur." @@ -1691,15 +1692,15 @@ msgstr "Nom de rôle ou id invalide(s) pour ce site" msgid "No site access to delete" msgstr "Pas d'accès à effacer" -#: modules/FlaskModule/API/user/UserQuerySites.py:112 +#: modules/FlaskModule/API/user/UserQuerySites.py:115 msgid "Missing site" msgstr "Site manquant" -#: modules/FlaskModule/API/user/UserQuerySites.py:119 +#: modules/FlaskModule/API/user/UserQuerySites.py:122 msgid "Missing id_site field" msgstr "Champ id_site manquant" -#: modules/FlaskModule/API/user/UserQuerySites.py:187 +#: modules/FlaskModule/API/user/UserQuerySites.py:191 msgid "" "Can't delete site: please delete all participants with sessions before deleting." msgstr "" @@ -1930,15 +1931,15 @@ msgstr "Le nouveau et l'ancien mot de passe ne correspondent pas" msgid "Invalid old password" msgstr "Ancien mot de passe incorrect" -#: modules/LoginModule/LoginModule.py:619 modules/LoginModule/LoginModule.py:652 +#: modules/LoginModule/LoginModule.py:621 modules/LoginModule/LoginModule.py:654 msgid "Disabled device" msgstr "Appareil désactivé" -#: modules/LoginModule/LoginModule.py:629 +#: modules/LoginModule/LoginModule.py:631 msgid "Invalid token" msgstr "Jeton invalide" -#: modules/LoginModule/LoginModule.py:729 +#: modules/LoginModule/LoginModule.py:731 msgid "Invalid Token" msgstr "Jeton invalide" @@ -2201,12 +2202,12 @@ msgid "Port" msgstr "Port" #: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:20 -#: templates/login.html:110 +#: templates/login.html:122 msgid "Username" msgstr "Code utilisateur" #: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:38 -#: templates/login.html:116 +#: templates/login.html:128 msgid "Password" msgstr "Mot de passe" @@ -2383,6 +2384,10 @@ msgid "Site ID" msgstr "ID Site" #: opentera/forms/TeraSiteForm.py:18 +msgid "Users Require 2FA" +msgstr "Exiger Authentification Multi-Facteurs (MFA)" + +#: opentera/forms/TeraSiteForm.py:20 msgid "Site Role" msgstr "Rôle du Site" @@ -2633,14 +2638,73 @@ msgstr "La documentation d’API est désactivée!" msgid "OpenTera Login Page" msgstr "OpenTera - Page de connexion" -#: templates/login.html:76 +#: templates/login.html:86 msgid "Invalid username or password" msgstr "Code utilisateur ou mot de passe incorrect" -#: templates/login.html:124 +#: templates/login.html:136 msgid "Login" msgstr "Connecter" +#: templates/login_setup_2fa.html:107 +msgid "You need to setup multi-factor authentication before continuing." +msgstr "" +"Vous devez configurer l'authentification multi-facteurs avant de poursuivre." + +#: templates/login_setup_2fa.html:121 +msgid "OTP configuration" +msgstr "Configuration OTP" + +#: templates/login_setup_2fa.html:127 +msgid "Scan the QR code with your authenticator app" +msgstr "Numérisez le code QR avec votre application d'authentification" + +#: templates/login_setup_2fa.html:128 +msgid "Enter the generated code:" +msgstr "Entrez le code généré:" + +#: templates/login_setup_2fa.html:134 templates/login_setup_2fa.html:151 +#: templates/login_validate_2fa.html:162 +msgid "Validate" +msgstr "Valider" + +#: templates/login_setup_2fa.html:144 +msgid "Use email for authentication" +msgstr "Utiliser courriel pour authentification" + +#: templates/login_setup_2fa.html:149 +msgid "No email address configured. Please contact your administrator." +msgstr "" +"Aucune adresse courriel configurée. Veuillez contacter votre administrateur." + +#: templates/login_validate_2fa.html:32 +msgid "Code validation timeout. Redirecting to login." +msgstr "Délai de validation expiré. Veuillez vous connecter à nouveau." + +#: templates/login_validate_2fa.html:117 +msgid "Too many attempts - returning to login" +msgstr "Trop d'essais - veuillez attendre et vous reconnecter à nouveau" + +#: templates/login_validate_2fa.html:120 +msgid "Invalid code" +msgstr "Code invalide" + +#: templates/login_validate_2fa.html:120 +msgid "attempts left" +msgstr "essais restants" + +#: templates/login_validate_2fa.html:144 +msgid "Multi Factor Authentication" +msgstr "Authentification Multi-Facteurs" + +#: templates/login_validate_2fa.html:151 +msgid "Authentication code" +msgstr "Code d'authentification" + +#: templates/login_validate_2fa.html:171 +msgid "Successfully authenticated" +msgstr "Authentification complétée" + #~ msgid "Missing id_project or id_site arguments" #~ msgstr "Champs : id_projet oou id_site manquants" From 2098597621a42e98844c576b0cb0f6786514b1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Wed, 2 Oct 2024 10:37:24 -0400 Subject: [PATCH 39/67] Refs #253, Avoid users to remove otp_enabled if secret is set. --- .../modules/DatabaseModule/DBManager.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 4f135d2f..3fd0f4a1 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -157,6 +157,28 @@ def user_user_group_updated_or_inserted(mapper, connection, target: TeraUserUser .values(user_2fa_enabled=True) ) + @event.listens_for(TeraUser, 'after_update') + @event.listens_for(TeraUser, 'after_insert') + def user_updated_or_inserted(mapper, connection, target: TeraUser): + # Check if 2FA is enabled for a related site + for group in target.user_user_groups: + for role in group.user_group_services_roles: + if role.id_site and role.service_role_site.site_2fa_required: + + otp_enabled = target.user_2fa_otp_enabled + + # Do not allow to change 2FA status if user has 2FA enabled + # and OTP set with secret + if target.user_2fa_otp_secret: + otp_enabled = True + + # Perform single update for user + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == target.id_user) + .values(user_2fa_enabled=True, user_2fa_otp_enabled=otp_enabled) + ) + def setup_events_for_class(self, cls, event_name): import json From 5dfa187dc566a25c20e9fb88c462091277c13bee Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Wed, 2 Oct 2024 14:51:44 -0400 Subject: [PATCH 40/67] Refs #253. Updated user form --- .../python/modules/LoginModule/LoginModule.py | 2 +- teraserver/python/opentera/forms/TeraUserForm.py | 14 +++++++++----- .../python/translations/fr/LC_MESSAGES/messages.po | 4 ++-- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/teraserver/python/modules/LoginModule/LoginModule.py b/teraserver/python/modules/LoginModule/LoginModule.py index 7284ec3f..f91c545d 100755 --- a/teraserver/python/modules/LoginModule/LoginModule.py +++ b/teraserver/python/modules/LoginModule/LoginModule.py @@ -212,7 +212,7 @@ def user_verify_password(self, username, password): g.current_user = logged_user # print('user_verify_password, found user: ', current_user) - # current_user.update_last_online() + current_user.update_last_online() # Clear attempts counter self.redisDelete(attempts_key) diff --git a/teraserver/python/opentera/forms/TeraUserForm.py b/teraserver/python/opentera/forms/TeraUserForm.py index e0a7127c..075ec2b5 100644 --- a/teraserver/python/opentera/forms/TeraUserForm.py +++ b/teraserver/python/opentera/forms/TeraUserForm.py @@ -26,9 +26,12 @@ def get_user_form(): False, item_default=False)) section.add_item(TeraFormItem("user_2fa_otp_enabled", gettext("2FA OTP Enabled"), "boolean", - False, item_default=False)) - section.add_item(TeraFormItem("user_2fa_email_enabled", gettext("2FA Email Enabled"), "boolean", - False, item_default=False)) + False, item_default=False, + item_condition=TeraFormItemCondition("user_2fa_enabled", "=", True))) + section.add_item(TeraFormItem("user_2fa_email_enabled", gettext("2FA Email Enabled"), "hidden", + False, item_default=False, + # item_condition = TeraFormItemCondition("user_2fa_enabled", "=", True) + )) # section.add_item(TeraFormItem("user_2fa_otp_secret", gettext("OTP Secret"), "hidden")) section.add_item(TeraFormItem("user_firstname", gettext("First Name"), "text", True)) @@ -37,9 +40,10 @@ def get_user_form(): section.add_item( TeraFormItem("user_password", gettext("Password"), "password", item_options={"confirm": True})) section.add_item(TeraFormItem("user_superadmin", gettext("User Is Super Administrator"), "boolean", True)) - section.add_item(TeraFormItem("user_notes", gettext("Notes"), "longtext")) - section.add_item(TeraFormItem("user_profile", gettext("Profile"), "hidden")) section.add_item(TeraFormItem("user_lastonline", gettext("Last Connection"), "datetime", item_options={"readonly": True})) + section.add_item(TeraFormItem("user_notes", gettext("Notes"), "longtext")) + section.add_item(TeraFormItem("user_profile", gettext("Profile"), "hidden")) + return form.to_dict() diff --git a/teraserver/python/translations/fr/LC_MESSAGES/messages.po b/teraserver/python/translations/fr/LC_MESSAGES/messages.po index 96efd978..25fab0b1 100644 --- a/teraserver/python/translations/fr/LC_MESSAGES/messages.po +++ b/teraserver/python/translations/fr/LC_MESSAGES/messages.po @@ -8,7 +8,7 @@ msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" "POT-Creation-Date: 2024-10-01 15:00-0400\n" -"PO-Revision-Date: 2024-10-01 15:04-0400\n" +"PO-Revision-Date: 2024-10-02 08:14-0400\n" "Last-Translator: \n" "Language-Team: fr \n" "Language: fr\n" @@ -2385,7 +2385,7 @@ msgstr "ID Site" #: opentera/forms/TeraSiteForm.py:18 msgid "Users Require 2FA" -msgstr "Exiger Authentification Multi-Facteurs (MFA)" +msgstr "Authentification Multi-Facteurs (MFA) requise" #: opentera/forms/TeraSiteForm.py:20 msgid "Site Role" From 8fac52b5206bb4f8c0fcaa89bd8eb25751d8c52f Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Thu, 3 Oct 2024 10:18:59 -0400 Subject: [PATCH 41/67] Refs #253. Protected endpoints from HTTP basic auth when 2FA enabled --- .../modules/FlaskModule/API/user/UserLogin.py | 16 +++++++++------- .../FlaskModule/API/user/UserQuerySites.py | 16 +++++----------- .../python/modules/FlaskModule/FlaskModule.py | 5 ++--- .../python/modules/LoginModule/LoginModule.py | 18 ++++++++++++++---- 4 files changed, 30 insertions(+), 25 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py index 95f6c9bb..d7f6e597 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py @@ -1,6 +1,6 @@ from flask_restx import inputs from flask_babel import gettext -from modules.LoginModule.LoginModule import current_user, user_http_auth +from modules.LoginModule.LoginModule import current_user, user_http_login_auth from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.FlaskModule.API.user.UserLoginBase import UserLoginBase from modules.FlaskModule.API.user.UserLoginBase import OutdatedClientVersionError, \ @@ -88,21 +88,23 @@ def _common_login_response(self, parser): return response, 200 - @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)') + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)', + security='basicAuth') @api.expect(get_parser) - @user_http_auth.login_required + @user_http_login_auth.login_required def get(self): """ - Login to the server using HTTP Basic Authentication (HTTPAuth) + Login to the server using HTTP Basic Authentication """ return self._common_login_response(get_parser) - @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)') + @api.doc(description='Login to the server using HTTP Basic Authentication (HTTPAuth)', + security='basicAuth') @api.expect(post_parser) - @user_http_auth.login_required + @user_http_login_auth.login_required def post(self): """ - Login to the server using HTTP Basic Authentication (HTTPAuth) + Login to the server using HTTP Basic Authentication """ return self._common_login_response(post_parser) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py index ba316485..2f430fe3 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py @@ -1,14 +1,11 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse from sqlalchemy import exc -from modules.LoginModule.LoginModule import user_multi_auth, current_user +from modules.LoginModule.LoginModule import user_multi_auth, current_user, user_token_auth from modules.FlaskModule.FlaskModule import user_api_ns as api from sqlalchemy.exc import InvalidRequestError from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraSite import TeraSite -from opentera.db.models.TeraUserGroup import TeraUserGroup -from opentera.db.models.TeraServiceAccess import TeraServiceAccess -from opentera.db.models.TeraServiceRole import TeraServiceRole from modules.DatabaseModule.DBManager import DBManager from flask_babel import gettext @@ -38,8 +35,7 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get site information. Only one of the ID parameter is supported and required at once', responses={200: 'Success - returns list of sites', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): @@ -105,8 +101,7 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified site', 400: 'Badly formed JSON or missing field(id_site) in the JSON body', - 500: 'Internal error when saving site'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving site'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): @@ -166,8 +161,7 @@ def post(self): @api.doc(description='Delete a specific site', responses={200: 'Success', 403: 'Logged user can\'t delete site (only super admin can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index bb65bd06..2211de20 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -42,12 +42,11 @@ def get_timezone(): 'basicAuth': { 'type': 'basic' }, - 'Token Authentication': { + 'tokenAuth': { 'type': 'apiKey', 'in': 'header', 'name': 'Authorization', - 'default': 'OpenTera', - 'bearerFormat': 'JWT' + 'description': 'Enter token with the `OpenTera` prefix, e.g. "OpenTera 12345"' } } diff --git a/teraserver/python/modules/LoginModule/LoginModule.py b/teraserver/python/modules/LoginModule/LoginModule.py index f91c545d..fe54e6c4 100755 --- a/teraserver/python/modules/LoginModule/LoginModule.py +++ b/teraserver/python/modules/LoginModule/LoginModule.py @@ -16,11 +16,11 @@ import datetime import redis -from flask import request, g, session +from flask import request, g, session, abort from flask_babel import gettext from werkzeug.local import LocalProxy from flask_restx import reqparse -from functools import wraps +from functools import wraps, partial from flask_httpauth import HTTPBasicAuth, HTTPTokenAuth, MultiAuth, Authorization @@ -43,6 +43,7 @@ # Authentication schemes for users user_http_auth = HTTPBasicAuth(realm='user') +user_http_login_auth = HTTPBasicAuth(realm='user') user_token_auth = HTTPTokenAuth("OpenTera") user_multi_auth = MultiAuth(user_http_auth, user_token_auth) @@ -129,9 +130,11 @@ def setup_login_manager(self): self.login_manager.user_loader(self.load_user) # Setup verify password function for users - user_http_auth.verify_password(self.user_verify_password) + user_http_auth.verify_password(partial(self.user_verify_password, verify_2fa=True)) + user_http_login_auth.verify_password(partial(self.user_verify_password, verify_2fa=False)) user_token_auth.verify_token(self.user_verify_token) user_http_auth.error_handler(self.auth_error) + user_http_login_auth.error_handler(self.auth_error) user_token_auth.error_handler(self.auth_error) # Setup verify password function for participants @@ -164,7 +167,7 @@ def load_user(self, user_id): return None - def user_verify_password(self, username, password): + def user_verify_password(self, username, password, verify_2fa): # print('LoginModule - user_verify_password ', username) tentative_user = TeraUser.get_user_by_username(username) if not tentative_user: @@ -209,6 +212,13 @@ def user_verify_password(self, username, password): logged_user = TeraUser.verify_password(username=username, password=password, user=tentative_user) if logged_user and logged_user.is_active(): + + if verify_2fa: + # Prevent API access with username/password if 2FA is enabled + if logged_user.user_2fa_enabled: + # return False + abort(401, gettext('Unauthorized - 2FA is enabled, must login first and use token')) + g.current_user = logged_user # print('user_verify_password, found user: ', current_user) From ef27bccff01f21d0ed481dd352a20b0d5b040a63 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Thu, 3 Oct 2024 10:31:13 -0400 Subject: [PATCH 42/67] Refs #253. Prevented User API endpoint access if user must change its password --- .../python/modules/LoginModule/LoginModule.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/teraserver/python/modules/LoginModule/LoginModule.py b/teraserver/python/modules/LoginModule/LoginModule.py index fe54e6c4..ee655d50 100755 --- a/teraserver/python/modules/LoginModule/LoginModule.py +++ b/teraserver/python/modules/LoginModule/LoginModule.py @@ -130,8 +130,8 @@ def setup_login_manager(self): self.login_manager.user_loader(self.load_user) # Setup verify password function for users - user_http_auth.verify_password(partial(self.user_verify_password, verify_2fa=True)) - user_http_login_auth.verify_password(partial(self.user_verify_password, verify_2fa=False)) + user_http_auth.verify_password(partial(self.user_verify_password, is_login=False)) + user_http_login_auth.verify_password(partial(self.user_verify_password, is_login=True)) user_token_auth.verify_token(self.user_verify_token) user_http_auth.error_handler(self.auth_error) user_http_login_auth.error_handler(self.auth_error) @@ -167,8 +167,8 @@ def load_user(self, user_id): return None - def user_verify_password(self, username, password, verify_2fa): - # print('LoginModule - user_verify_password ', username) + def user_verify_password(self, username, password, is_login): + # print('LoginModule - user_verify_password', username) tentative_user = TeraUser.get_user_by_username(username) if not tentative_user: # self.logger.log_warning(self.module_name, 'Invalid username', username) @@ -213,15 +213,17 @@ def user_verify_password(self, username, password, verify_2fa): if logged_user and logged_user.is_active(): - if verify_2fa: - # Prevent API access with username/password if 2FA is enabled + if not is_login: + if logged_user.user_force_password_change: + # Prevent API access if password change was requested for that user + abort(401, gettext('Unauthorized - User must login first to change password')) if logged_user.user_2fa_enabled: - # return False + # Prevent API access with username/password if 2FA is enabled abort(401, gettext('Unauthorized - 2FA is enabled, must login first and use token')) g.current_user = logged_user - # print('user_verify_password, found user: ', current_user) + # print('user_verify_password, found user:', current_user) current_user.update_last_online() # Clear attempts counter @@ -353,7 +355,7 @@ def user_verify_token(self, token_value): return False def participant_verify_password(self, username, password): - # print('LoginModule - participant_verify_password for ', username) + # print('LoginModule - participant_verify_password for', username) tentative_participant = TeraParticipant.get_participant_by_username(username) if not tentative_participant: From 3b36b6d9952cef5121a06558fd71fba5c8b8c9bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 3 Oct 2024 11:15:22 -0400 Subject: [PATCH 43/67] Refs #253, More robust checks for 2fa update/insert on models. --- .../modules/DatabaseModule/DBManager.py | 89 ++++++++++--------- 1 file changed, 47 insertions(+), 42 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 3fd0f4a1..6e1c612a 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -103,7 +103,7 @@ def setup_events_for_2fa_sites(self): @event.listens_for(TeraSite, 'after_insert') def site_updated_or_inserted(mapper, connection, target: TeraSite): # Check if 2FA is enabled for this site - if target.site_2fa_required: + if target and target.site_2fa_required: # Efficiently load all related users with joinedload service_roles = TeraServiceRole.query.options( joinedload(TeraServiceRole.service_role_user_groups).joinedload( @@ -114,9 +114,10 @@ def site_updated_or_inserted(mapper, connection, target: TeraSite): # Get all users user_ids = set() for role in service_roles: - for group in role.service_role_user_groups: - for user in group.user_group_users: - user_ids.add(user.id_user) + if role.service_role_user_groups: + for group in role.service_role_user_groups: + for user in group.user_group_users: + user_ids.add(user.id_user) # Perform a bulk update for all users at once if user_ids: @@ -129,55 +130,59 @@ def site_updated_or_inserted(mapper, connection, target: TeraSite): @event.listens_for(TeraUserGroup, 'after_insert') def user_group_updated_or_inserted(mapper, connection, target: TeraUserGroup): # Check if 2FA is enabled for a related site - for role in target.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: - # Efficiently load all related users with joinedload - user_ids = set() - for user in target.user_group_users: - user_ids.add(user.id_user) - - # Perform a bulk update for all users at once - if user_ids: - connection.execute( - update(TeraUser) - .where(TeraUser.id_user.in_(user_ids)) - .values(user_2fa_enabled=True) - ) + if target and target.user_group_services_roles: + for role in target.user_group_services_roles: + if role.id_site and role.service_role_site.site_2fa_required: + # Efficiently load all related users with joinedload + user_ids = set() + for user in target.user_group_users: + user_ids.add(user.id_user) + + # Perform a bulk update for all users at once + if user_ids: + connection.execute( + update(TeraUser) + .where(TeraUser.id_user.in_(user_ids)) + .values(user_2fa_enabled=True) + ) @event.listens_for(TeraUserUserGroup, 'after_update') @event.listens_for(TeraUserUserGroup, 'after_insert') def user_user_group_updated_or_inserted(mapper, connection, target: TeraUserUserGroup): # Check if 2FA is enabled for a related site - for role in target.user_user_group_user_group.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: - # Perform single update for user - connection.execute( - update(TeraUser) - .where(TeraUser.id_user == target.user_user_group_user.id_user) - .values(user_2fa_enabled=True) + if target and target.user_user_group_user_group and target.user_user_group_user_group.user_group_services_roles: + for role in target.user_user_group_user_group.user_group_services_roles: + if role.id_site and role.service_role_site.site_2fa_required: + # Perform single update for user + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == target.user_user_group_user.id_user) + .values(user_2fa_enabled=True) ) @event.listens_for(TeraUser, 'after_update') @event.listens_for(TeraUser, 'after_insert') def user_updated_or_inserted(mapper, connection, target: TeraUser): # Check if 2FA is enabled for a related site - for group in target.user_user_groups: - for role in group.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: - - otp_enabled = target.user_2fa_otp_enabled - - # Do not allow to change 2FA status if user has 2FA enabled - # and OTP set with secret - if target.user_2fa_otp_secret: - otp_enabled = True - - # Perform single update for user - connection.execute( - update(TeraUser) - .where(TeraUser.id_user == target.id_user) - .values(user_2fa_enabled=True, user_2fa_otp_enabled=otp_enabled) - ) + if target and target.user_user_groups: + for group in target.user_user_groups: + if group.user_group_services_roles: + for role in group.user_group_services_roles: + if role.id_site and role.service_role_site.site_2fa_required: + + otp_enabled = target.user_2fa_otp_enabled + + # Do not allow to change 2FA status if user has 2FA enabled + # and OTP set with secret + if target.user_2fa_otp_secret: + otp_enabled = True + + # Perform single update for user + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == target.id_user) + .values(user_2fa_enabled=True, user_2fa_otp_enabled=otp_enabled) + ) def setup_events_for_class(self, cls, event_name): import json From b89a28f6514d5872688a49db22418846c7b5d887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 3 Oct 2024 14:17:22 -0400 Subject: [PATCH 44/67] Refs #253, Fix test_UserLogin2FA. --- .../FlaskModule/API/user/test_UserLogin.py | 26 +- .../FlaskModule/API/user/test_UserLogin2FA.py | 268 +++++++++++------- 2 files changed, 180 insertions(+), 114 deletions(-) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py index e0d92089..019f4391 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py @@ -5,12 +5,6 @@ class UserLoginTest(BaseUserAPITest): test_endpoint = '/api/user/login' - def setUp(self): - super().setUp() - - def tearDown(self): - super().tearDown() - def test_get_endpoint_no_auth(self): with self._flask_app.app_context(): response = self.test_client.get(self.test_endpoint) @@ -21,10 +15,12 @@ def test_get_endpoint_invalid_token_auth(self): response = self._get_with_user_token_auth(self.test_client, 'invalid') self.assertEqual(401, response.status_code) - def test_get_endpoint_login_admin_user_http_auth(self): + def test_get_endpoint_login_admin_user_http_auth_with_websocket(self): with self._flask_app.app_context(): # Using default participant information - response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin') + response = self._get_with_user_http_auth(self.test_client, + 'admin', 'admin', + {'with_websocket': True}) self.assertEqual(200, response.status_code) self.assertEqual('application/json', response.headers['Content-Type']) @@ -35,6 +31,20 @@ def test_get_endpoint_login_admin_user_http_auth(self): self.assertTrue('user_uuid' in response.json) self.assertTrue('user_token' in response.json) + def test_get_endpoint_login_admin_user_http_auth_no_websocket(self): + with self._flask_app.app_context(): + # Using default participant information + response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin') + + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + + # Validate fields in json response + self.assertTrue('user_uuid' in response.json) + self.assertTrue('user_token' in response.json) + self.assertFalse('websocket_url' in response.json) + def test_get_endpoint_login_admin_user_http_auth_then_token_auth(self): with self._flask_app.app_context(): # Using default participant information diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index 3f5b94e4..98d600cf 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -1,6 +1,6 @@ +import pyotp from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest from opentera.db.models.TeraUser import TeraUser -import pyotp class UserLogin2FATest(BaseUserAPITest): @@ -8,10 +8,47 @@ class UserLogin2FATest(BaseUserAPITest): def setUp(self): super().setUp() + # Create users with 2fa enabled + with self._flask_app.app_context(): + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) def tearDown(self): + # Delete users with 2fa enabled + with self._flask_app.app_context(): + TeraUser.delete(self.user1['id_user'], hard_delete=True) + TeraUser.delete(self.user2['id_user'], hard_delete=True) super().tearDown() + + def _create_2fa_enabled_user(self, username, password, set_secret:bool = True): + user = TeraUser() + user.id_user = 0 # New user + user.user_username = username + user.user_password = password + user.user_firstname = username + user.user_lastname = username + user.user_email = f"{username}@test.com" + user.user_enabled = True + user.user_profile = {} + if set_secret: + user.enable_2fa_otp() + else: + user.user_2fa_enabled = True + user.user_2fa_otp_enabled = False + user.user_2fa_otp_secret = None + + TeraUser.insert(user) + return user.to_json(minimal=False) + + + def _login_user(self, username, password): + response = self._get_with_user_http_auth(self.test_client, username, password, endpoint='/api/user/login') + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + return response + def test_get_endpoint_no_auth(self): with self._flask_app.app_context(): response = self.test_client.get(self.test_endpoint) @@ -22,152 +59,171 @@ def test_get_endpoint_invalid_token_auth(self): response = self._get_with_user_token_auth(self.test_client, 'invalid') self.assertEqual(401, response.status_code) - def test_get_endpoint_login_admin_user_http_auth_no_code(self): + def test_get_endpoint_with_no_session(self): with self._flask_app.app_context(): - # Using default admin information - response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin') - self.assertEqual(400, response.status_code) + response = self.test_client.get(self.test_endpoint) + self.assertEqual(401, response.status_code) - def test_get_endpoint_login_admin_user_http_auth_invalid_code(self): + def test_get_endpoint_with_admin_without_2fa_enabled(self): with self._flask_app.app_context(): - # Using default admin information - # Admin account has no 2FA enabled by default - params = {'otp_code': 'invalid'} + user = TeraUser.get_user_by_username('admin') + self.assertIsNotNone(user) + self.assertFalse(user.user_2fa_enabled) + # Fist login + response = self._login_user('admin', 'admin') + self.assertEqual(200, response.status_code) + + # Now try to login with 2fa response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin', - params=params) + params={'otp_code': '123456'}, + endpoint=self.test_endpoint) self.assertEqual(403, response.status_code) - def test_get_endpoint_login_2fa_enabled_user_no_code(self): + def test_get_endpoint_login_user1_http_auth_no_code(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) - self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user - response = self._get_with_user_http_auth(self.test_client, username, password) + + # Fisrt login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + + # Using default admin information, http auth not used + response = self._get_with_user_http_auth(self.test_client) self.assertEqual(400, response.status_code) - def test_get_endpoint_login_2fa_enabled_user_wrong_code(self): + def test_get_endpoint_login_user1_http_auth_invalid_code(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) - self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user - params = {'otp_code': 'invalid'} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params) - self.assertEqual(403, response.status_code) - def test_get_endpoint_login_2fa_enabled_user_valid_code(self): + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + + # Then try to login with invalid code + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': '123456'}) + self.assertEqual(401, response.status_code) + + def test_get_endpoint_login_user1_http_auth_valid_code(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) + user = TeraUser.get_user_by_username('test_user_2fa_1') self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user + + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with valid code totp = pyotp.TOTP(user.user_2fa_otp_secret) - params = {'otp_code': totp.now()} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params) + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': totp.now()}) self.assertEqual(200, response.status_code) - self.assertEqual('application/json', response.headers['Content-Type']) - self.assertGreater(len(response.json), 0) self.assertTrue('user_uuid' in response.json) self.assertTrue('user_token' in response.json) + self.assertFalse('websocket_url'in response.json) - def test_get_endpoint_login_2fa_enabled_user_valid_code_with_websockets(self): + def test_get_endpoint_login_user1_http_auth_valid_code_with_websocket(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) + user = TeraUser.get_user_by_username('test_user_2fa_1') self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user + + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with valid code totp = pyotp.TOTP(user.user_2fa_otp_secret) - params = {'otp_code': totp.now(), 'with_websocket': True} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params) + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': totp.now(), + 'with_websocket': True}) self.assertEqual(200, response.status_code) - self.assertEqual('application/json', response.headers['Content-Type']) - self.assertGreater(len(response.json), 0) self.assertTrue('user_uuid' in response.json) self.assertTrue('user_token' in response.json) - self.assertTrue('websocket_url' in response.json) - self.assertIsNotNone(response.json['websocket_url']) + self.assertTrue('websocket_url'in response.json) - def test_get_endpoint_login_2fa_enabled_user_unknown_app_name_and_version(self): + def test_get_endpoint_login_user2_http_auth_invalid_code(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) + user = TeraUser.get_user_by_username('test_user_2fa_2') + self.assertIsNone(user.user_2fa_otp_secret) + + # First login to create session + response = self._login_user('test_user_2fa_2', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertFalse('login_validate_2fa' in response.json['redirect_url']) + self.assertTrue('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with invalid code + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': '123456'}) + self.assertEqual(403, response.status_code) + + def test_get_endpoint_login_user1_http_auth_valid_code_unknown_app_name(self): + with self._flask_app.app_context(): + user = TeraUser.get_user_by_username('test_user_2fa_1') self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user + + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with valid code totp = pyotp.TOTP(user.user_2fa_otp_secret) - params = {'otp_code': totp.now(), 'with_websocket': True} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': totp.now()}, opt_headers={'X-Client-Name': 'test', 'X-Client-Version': '0.0.0'}) self.assertEqual(200, response.status_code) + self.assertTrue('user_uuid' in response.json) + self.assertTrue('user_token' in response.json) + self.assertFalse('websocket_url'in response.json) - def test_get_endpoint_login_2fa_enabled_user_outdated_app_version(self): + def test_get_endpoint_login_user1_http_auth_valid_code_outdated_app(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) + user = TeraUser.get_user_by_username('test_user_2fa_1') self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user + + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with valid code totp = pyotp.TOTP(user.user_2fa_otp_secret) - params = {'otp_code': totp.now(), 'with_websocket': True} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': totp.now()}, opt_headers={'X-Client-Name': 'OpenTeraPlus', 'X-Client-Version': '0.0.0'}) - self.assertTrue('version_latest' in response.json) - self.assertTrue('version_error' in response.json) self.assertEqual(426, response.status_code) - def test_get_endpoint_login_2fa_enabled_user_valid_app_version(self): + def test_get_endpoint_login_user1_http_auth_valid_code_valid_app(self): with self._flask_app.app_context(): - # Create user with 2FA enabled - username = f'test_{pyotp.random_base32(32)}' - password = pyotp.random_base32(32) - user = self.create_user_with_2fa_enabled(username, password) + user = TeraUser.get_user_by_username('test_user_2fa_1') self.assertIsNotNone(user.user_2fa_otp_secret) - self.assertTrue(user.user_2fa_enabled) - self.assertTrue(user.user_2fa_otp_enabled) - # Login with user + + # First login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Then try to login with valid code totp = pyotp.TOTP(user.user_2fa_otp_secret) - params = {'otp_code': totp.now(), 'with_websocket': True} - response = self._get_with_user_http_auth(self.test_client, username, password, params=params, + response = self._get_with_user_http_auth(self.test_client, + params={'otp_code': totp.now()}, opt_headers={'X-Client-Name': 'OpenTeraPlus', 'X-Client-Version': '1.0.0'}) - self.assertTrue('version_latest' in response.json) - self.assertFalse('version_error' in response.json) self.assertEqual(200, response.status_code) - - def create_user_with_2fa_enabled(self, username='test', password='test') -> TeraUser: - # Create user with 2FA enabled - user = TeraUser() - user.user_firstname = 'Test' - user.user_lastname = 'Test' - user.user_email = f'{username}@hotmail.com' - user.user_username = username - user.user_password = password # Password will be hashed in insert - user.user_enabled = True - user.user_profile = {} - user.enable_2fa_otp() - TeraUser.insert(user) - return user From d5b3048f80501f6656f372e74179fe2b8399908e Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Thu, 3 Oct 2024 14:48:32 -0400 Subject: [PATCH 45/67] Refs #253. Updated API endpoints documentation --- .../FlaskModule/API/device/DeviceLogin.py | 4 +- .../FlaskModule/API/device/DeviceLogout.py | 7 ++- .../API/device/DeviceQueryAssets.py | 7 ++- .../API/device/DeviceQueryDevices.py | 16 ++++--- .../API/device/DeviceQueryParticipants.py | 12 ++--- .../API/device/DeviceQuerySessionEvents.py | 18 +++++--- .../API/device/DeviceQuerySessions.py | 16 +++++-- .../API/device/DeviceQueryStatus.py | 10 +++-- .../FlaskModule/API/device/DeviceRegister.py | 6 +++ .../API/participant/ParticipantLogin.py | 5 ++- .../API/participant/ParticipantLogout.py | 9 ++-- .../API/participant/ParticipantQueryAssets.py | 8 ++-- .../participant/ParticipantQueryDevices.py | 9 ++-- .../ParticipantQueryParticipants.py | 13 +++--- .../participant/ParticipantQuerySessions.py | 11 +++-- .../participant/ParticipantRefreshToken.py | 9 ++-- .../API/service/ServiceQueryAccess.py | 8 ++-- .../API/service/ServiceQueryAssets.py | 17 +++++-- .../API/service/ServiceQueryDevices.py | 15 ++++--- .../API/service/ServiceQueryDisconnect.py | 8 ++-- .../service/ServiceQueryParticipantGroups.py | 15 +++++-- .../API/service/ServiceQueryParticipants.py | 10 ++++- .../API/service/ServiceQueryProjects.py | 15 +++++-- .../API/service/ServiceQueryRoles.py | 15 +++++-- .../API/service/ServiceQueryServiceAccess.py | 15 +++++-- .../API/service/ServiceQueryServices.py | 3 ++ .../API/service/ServiceQuerySessionEvents.py | 13 ++++-- .../API/service/ServiceQuerySessionTypes.py | 5 ++- .../API/service/ServiceQuerySessions.py | 12 +++-- .../ServiceQuerySiteProjectAccessRoles.py | 5 ++- .../API/service/ServiceQuerySites.py | 5 ++- .../service/ServiceQueryTestTypeProjects.py | 15 +++++-- .../API/service/ServiceQueryTestTypes.py | 15 +++++-- .../API/service/ServiceQueryTests.py | 17 +++++-- .../API/service/ServiceQueryUserGroups.py | 16 +++++-- .../API/service/ServiceQueryUsers.py | 5 ++- .../API/service/ServiceSessionManager.py | 8 ++-- .../FlaskModule/API/user/UserLogout.py | 7 ++- .../FlaskModule/API/user/UserQueryAssets.py | 25 ++++++----- .../API/user/UserQueryAssetsArchive.py | 13 +++--- .../API/user/UserQueryDeviceParticipants.py | 21 +++++---- .../API/user/UserQueryDeviceProjects.py | 23 ++++++---- .../API/user/UserQueryDeviceSites.py | 21 +++++---- .../API/user/UserQueryDeviceSubTypes.py | 21 +++++---- .../API/user/UserQueryDeviceTypes.py | 24 +++++----- .../FlaskModule/API/user/UserQueryDevices.py | 21 +++++---- .../API/user/UserQueryDisconnect.py | 9 ++-- .../FlaskModule/API/user/UserQueryForms.py | 9 ++-- .../API/user/UserQueryOnlineDevices.py | 10 ++--- .../API/user/UserQueryOnlineParticipants.py | 5 ++- .../API/user/UserQueryOnlineUsers.py | 6 ++- .../API/user/UserQueryParticipantGroup.py | 21 +++++---- .../API/user/UserQueryParticipants.py | 18 +++++--- .../API/user/UserQueryProjectAccess.py | 23 ++++++---- .../FlaskModule/API/user/UserQueryProjects.py | 18 +++++--- .../API/user/UserQueryServerSettings.py | 6 ++- .../API/user/UserQueryServiceAccess.py | 22 ++++++---- .../API/user/UserQueryServiceAccessToken.py | 6 ++- .../API/user/UserQueryServiceConfigs.py | 23 ++++++---- .../API/user/UserQueryServiceProjects.py | 18 +++++--- .../API/user/UserQueryServiceRoles.py | 18 +++++--- .../API/user/UserQueryServiceSites.py | 24 ++++++---- .../FlaskModule/API/user/UserQueryServices.py | 21 +++++---- .../API/user/UserQuerySessionEvents.py | 21 +++++---- .../API/user/UserQuerySessionTypeProjects.py | 20 ++++++--- .../API/user/UserQuerySessionTypeSites.py | 18 +++++--- .../API/user/UserQuerySessionTypes.py | 17 ++++--- .../FlaskModule/API/user/UserQuerySessions.py | 20 ++++++--- .../API/user/UserQuerySiteAccess.py | 21 +++++---- .../FlaskModule/API/user/UserQuerySites.py | 11 ++++- .../FlaskModule/API/user/UserQueryStats.py | 7 +-- .../FlaskModule/API/user/UserQueryTestType.py | 20 ++++++--- .../API/user/UserQueryTestTypeProjects.py | 18 +++++--- .../API/user/UserQueryTestTypeSites.py | 20 ++++++--- .../FlaskModule/API/user/UserQueryTests.py | 17 ++++--- .../FlaskModule/API/user/UserQueryUndelete.py | 6 ++- .../API/user/UserQueryUserGroups.py | 21 +++++---- .../API/user/UserQueryUserPreferences.py | 17 ++++--- .../API/user/UserQueryUserUserGroups.py | 20 ++++++--- .../FlaskModule/API/user/UserQueryUsers.py | 44 +++++-------------- .../FlaskModule/API/user/UserQueryVersions.py | 14 +++--- .../FlaskModule/API/user/UserRefreshToken.py | 9 ++-- .../API/user/UserSessionManager.py | 9 ++-- 83 files changed, 763 insertions(+), 417 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceLogin.py b/teraserver/python/modules/FlaskModule/API/device/DeviceLogin.py index f6316493..0b6652c2 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceLogin.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceLogin.py @@ -3,7 +3,6 @@ from modules.LoginModule.LoginModule import LoginModule, current_device from modules.DatabaseModule.DBManager import DBManager from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraDevice import TeraDevice from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames from opentera.utils.UserAgentParser import UserAgentParser @@ -30,6 +29,9 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): + """ + Device login + """ # Redis key is handled in LoginModule server_name = self.module.config.server_config['hostname'] port = self.module.config.server_config['port'] diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceLogout.py b/teraserver/python/modules/FlaskModule/API/device/DeviceLogout.py index 3912707d..052f90e8 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceLogout.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceLogout.py @@ -1,4 +1,4 @@ -from flask import jsonify, session +from flask import session from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_device @@ -7,7 +7,7 @@ # Parser definition(s) get_parser = api.parser() -get_parser.add_argument('token', type=str, help='Secret Token') +get_parser.add_argument('token', type=str, help='Access Token') class DeviceLogout(Resource): @@ -22,6 +22,9 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): + """ + Device logout + """ if current_device: logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryAssets.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryAssets.py index c2ebb674..1d1ac831 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryAssets.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryAssets.py @@ -15,7 +15,6 @@ get_parser.add_argument('with_urls', type=inputs.boolean, help='Also include assets infos and download-upload url') get_parser.add_argument('with_only_token', type=inputs.boolean, help='Only includes the access token. ' 'Will ignore with_urls if specified.') -get_parser.add_argument('token', type=str, help='Secret Token') class DeviceQueryAssets(Resource): @@ -27,10 +26,14 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get device assets based specified session or asset ID or, if no parameters, get all assets', responses={200: 'Success', - 403: 'Device doesn\'t have access to the specified asset'}) + 403: 'Device doesn\'t have access to the specified asset'}, + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): + """ + Get device assets + """ args = get_parser.parse_args() device_access = DBManager.deviceAccess(current_device) diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryDevices.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryDevices.py index 871b0e11..3609912c 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryDevices.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryDevices.py @@ -1,5 +1,5 @@ -from flask import jsonify, session, request -from flask_restx import Resource, reqparse +from flask import request +from flask_restx import Resource from flask_babel import gettext from sqlalchemy import exc @@ -25,11 +25,14 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented', 403: 'Logged device doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): - args = get_parser.parse_args() + """ + Get connected device information + """ + # args = get_parser.parse_args() # Reply device information response = {'device_info': current_device.to_json(minimal=True)} @@ -62,9 +65,12 @@ def get(self): 403: 'Logged device can\'t update the specified device', 400: 'Badly formed JSON or missing fields(id_device) in the JSON body', 500: 'Internal error occurred when saving device'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @LoginModule.device_token_or_certificate_required def post(self): + """ + Update current device information + """ if 'device' not in request.json: return gettext('Missing device schema'), 400 diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryParticipants.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryParticipants.py index 0f925b7e..f5994fdc 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryParticipants.py @@ -1,10 +1,7 @@ -from flask import jsonify, session, request -from flask_restx import Resource, reqparse +from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_device -from modules.DatabaseModule.DBManager import DBManager from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraDevice import TeraDevice # Parser definition(s) get_parser = api.parser() @@ -22,11 +19,14 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented', 403: 'Logged device doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): - args = get_parser.parse_args() + """ + Get device associated participants information + """ + # args = get_parser.parse_args() # Device must have device_onlineable flag if current_device and current_device.device_onlineable: diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessionEvents.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessionEvents.py index 706e5337..02543aaf 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessionEvents.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessionEvents.py @@ -1,12 +1,11 @@ -from flask import jsonify, request, session -from flask_restx import Resource, reqparse +from flask import request +from flask_restx import Resource from flask_babel import gettext from opentera.db.models.TeraSessionEvent import TeraSessionEvent from modules.LoginModule.LoginModule import LoginModule, current_device from modules.DatabaseModule.DBManager import DBManager from sqlalchemy import exc from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraDevice import TeraDevice # Parser definition(s) get_parser = api.parser() @@ -24,10 +23,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get session events', responses={403: 'Forbidden for security reasons.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): + """ + Get events for a specific session + """ return gettext('Forbidden for security reasons'), 403 @api.doc(description='Update/Create session events', @@ -36,10 +38,13 @@ def get(self): 500: 'Internal server error', 501: 'Not implemented', 403: 'Logged device doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_parser) @LoginModule.device_token_or_certificate_required def post(self): + """ + Create / update session events + """ device_access = DBManager.deviceAccess(current_device) # Using request.json instead of parser, since parser messes up the json! @@ -94,4 +99,7 @@ def post(self): @LoginModule.device_token_or_certificate_required def delete(self): + """ + Delete session events + """ return gettext('Forbidden for security reasons'), 403 diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessions.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessions.py index f36df815..977cd994 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessions.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQuerySessions.py @@ -1,4 +1,4 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, inputs from flask_babel import gettext from opentera.db.models.TeraSession import TeraSession @@ -7,7 +7,6 @@ from modules.LoginModule.LoginModule import LoginModule, current_device from sqlalchemy import exc from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraDevice import TeraDevice import datetime # Parser definition(s) @@ -65,10 +64,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get session', responses={403: 'Forbidden for security reasons.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.device_token_or_certificate_required def get(self): + """ + Query device sessions + """ return gettext('Forbidden for security reasons'), 403 @api.doc(description='Update/Create session', @@ -77,10 +79,13 @@ def get(self): 500: 'Internal server error', 501: 'Not implemented', 403: 'Logged device doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(session_schema) @LoginModule.device_token_or_certificate_required def post(self): + """ + Update / create a session + """ # args = post_parser.parse_args() # Using request.json instead of parser, since parser messes up the json! if 'session' not in request.json: @@ -190,4 +195,7 @@ def post(self): @LoginModule.device_token_or_certificate_required def delete(self): + """ + Delete a session + """ return gettext('Forbidden for security reasons'), 403 diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryStatus.py b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryStatus.py index 4f34c609..fd3d9289 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceQueryStatus.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceQueryStatus.py @@ -1,9 +1,8 @@ -from flask import jsonify, session, request -from flask_restx import Resource, reqparse +from flask import request +from flask_restx import Resource from modules.LoginModule.LoginModule import LoginModule, current_device from flask_babel import gettext from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraDevice import TeraDevice from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames import json @@ -38,10 +37,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented', 403: 'Logged device doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(status_schema) @LoginModule.device_token_or_certificate_required def post(self): + """ + Update current device status + """ # status_schema.validate(request.json) # This should not be required since schema should be validated first. if 'status' not in request.json or 'timestamp' not in request.json: diff --git a/teraserver/python/modules/FlaskModule/API/device/DeviceRegister.py b/teraserver/python/modules/FlaskModule/API/device/DeviceRegister.py index 7b40596c..e8e7f95a 100644 --- a/teraserver/python/modules/FlaskModule/API/device/DeviceRegister.py +++ b/teraserver/python/modules/FlaskModule/API/device/DeviceRegister.py @@ -66,6 +66,9 @@ def __init__(self, _api, *args, **kwargs): 401: 'Unauthorized - provided registration key is invalid'}) @api.expect(api_parser) def get(self): + """ + Register a new device in the server (token based) + """ args = api_parser.parse_args(strict=True) # Check if provided registration key is ok @@ -92,6 +95,9 @@ def get(self): 400: 'Missing or invalid parameter', 401: 'Unauthorized - provided registration key is invalid'}) def post(self): + """ + Register a new device in the server (certificate based) + """ args = api_parser.parse_args(strict=True) # Check if provided registration key is ok diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogin.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogin.py index 0902b5b7..28fb4281 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogin.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogin.py @@ -37,10 +37,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @participant_multi_auth.login_required(role='limited') def get(self): + """ + Login a participant with username / password + """ if current_participant: args = get_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogout.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogout.py index cf7a9681..416b7130 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogout.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantLogout.py @@ -1,6 +1,6 @@ -from flask import jsonify, session, request +from flask import session, request from flask_login import logout_user -from flask_restx import Resource, reqparse, fields +from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import participant_multi_auth, current_participant, LoginModule from modules.FlaskModule.FlaskModule import participant_api_ns as api @@ -22,10 +22,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @participant_multi_auth.login_required def get(self): + """ + Participant logout + """ if current_participant: logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryAssets.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryAssets.py index 16dc80c1..b8144b5b 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryAssets.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryAssets.py @@ -1,10 +1,9 @@ -from flask import session, request +from flask import request from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import participant_multi_auth, current_participant from modules.DatabaseModule.DBManager import DBManager from modules.FlaskModule.FlaskModule import device_api_ns as api -from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraAsset import TeraAsset from opentera.redis.RedisVars import RedisVars @@ -29,10 +28,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get participant assets based on the ID or, if no parameters, get all assets', responses={200: 'Success', 403: 'Participant doesn\'t have access to the specified asset'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @participant_multi_auth.login_required(role='limited') def get(self): + """ + Get participant assets + """ args = get_parser.parse_args() participant_access = DBManager.participantAccess(current_participant) diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryDevices.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryDevices.py index 8944a818..c41566d3 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryDevices.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryDevices.py @@ -1,9 +1,6 @@ -from flask import session from flask_restx import Resource, inputs -from flask_babel import gettext from modules.LoginModule.LoginModule import participant_multi_auth, current_participant from modules.FlaskModule.FlaskModule import participant_api_ns as api -from opentera.db.models.TeraParticipant import TeraParticipant from modules.DatabaseModule.DBManager import DBManager # Parser definition(s) @@ -26,11 +23,13 @@ def __init__(self, _api, *args, **kwargs): responses={200: 'Success', 500: 'Required parameter is missing', 501: 'Not implemented.', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @participant_multi_auth.login_required(role='full') def get(self): + """ + Get associated participant devices + """ participant_access = DBManager.participantAccess(current_participant) args = get_parser.parse_args(strict=True) diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryParticipants.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryParticipants.py index 70020880..d7ee630a 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQueryParticipants.py @@ -1,9 +1,7 @@ -from flask import session from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import participant_multi_auth, current_participant from modules.FlaskModule.FlaskModule import participant_api_ns as api -from opentera.db.models.TeraParticipant import TeraParticipant from modules.DatabaseModule.DBManager import DBManager # Parser definition(s) @@ -26,10 +24,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @participant_multi_auth.login_required(role='limited') def get(self): + """ + Get current participant informations + """ participant_access = DBManager.participantAccess(current_participant) args = get_parser.parse_args(strict=True) @@ -47,9 +48,11 @@ def get(self): responses={200: 'Success - To be documented', 500: 'Required parameter is missing', 501: 'Not implemented.', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(post_parser) @participant_multi_auth.login_required(role='full') def post(self): + """ + Update current participant informations + """ return gettext('Not implemented'), 501 diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQuerySessions.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQuerySessions.py index de80ae44..fbf98eb7 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantQuerySessions.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantQuerySessions.py @@ -75,9 +75,11 @@ def __init__(self, _api, *args, **kwargs): 400: 'Bad request', 500: 'Required parameter is missing', 501: 'Not implemented.', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) def get(self): + """ + Get participant sessions + """ participant_access = DBManager.participantAccess(current_participant) args = get_parser.parse_args(strict=True) @@ -105,10 +107,13 @@ def get(self): 500: 'Internal server error', 501: 'Not implemented', 403: 'Logged participant doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(session_schema) @participant_multi_auth.login_required(role='limited') def post(self): + """ + Create / update a session + """ # args = post_parser.parse_args() # Using request.json instead of parser, since parser messes up the json! if 'session' not in request.json: diff --git a/teraserver/python/modules/FlaskModule/API/participant/ParticipantRefreshToken.py b/teraserver/python/modules/FlaskModule/API/participant/ParticipantRefreshToken.py index 79bf760a..bf9f0a2b 100644 --- a/teraserver/python/modules/FlaskModule/API/participant/ParticipantRefreshToken.py +++ b/teraserver/python/modules/FlaskModule/API/participant/ParticipantRefreshToken.py @@ -1,10 +1,9 @@ -from flask import session, request +from flask import request from flask_restx import Resource from modules.LoginModule.LoginModule import participant_token_auth, current_participant from modules.FlaskModule.FlaskModule import participant_api_ns as api from modules.LoginModule.LoginModule import LoginModule from opentera.redis.RedisVars import RedisVars -from opentera.db.models.TeraParticipant import TeraParticipant # Parser definition(s) get_parser = api.parser() @@ -19,11 +18,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Refresh token, old token needs to be passed in request headers.', responses={200: 'Success', - 500: 'Server error'}, - params={'token': 'Secret token'}) + 500: 'Server error'}) @api.expect(get_parser) @participant_token_auth.login_required(role='full') def get(self): + """ + Refresh participant dynamic token + """ # If we have made it this far, token passed in headers was valid. # Get user token key from redis diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAccess.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAccess.py index 16da654f..fb846588 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAccess.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAccess.py @@ -1,5 +1,4 @@ -from flask import request -from flask_restx import Resource, reqparse, inputs +from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api @@ -41,10 +40,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get current service access + """ service_access = DBManager.serviceAccess(current_service) args = get_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAssets.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAssets.py index 4d53f379..ea5d4c98 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAssets.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryAssets.py @@ -1,5 +1,5 @@ from flask import request -from flask_restx import Resource, reqparse, inputs +from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api @@ -48,10 +48,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get assets + """ service_access = DBManager.serviceAccess(current_service) args = get_parser.parse_args() @@ -151,10 +154,13 @@ def get(self): 400: 'Bad request - wrong or missing parameters in query', 500: 'Required parameter is missing', 403: 'Service doesn\'t have permission to post that asset'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_parser) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update an asset + """ args = post_parser.parse_args() service_access = DBManager.serviceAccess(current_service) @@ -254,10 +260,13 @@ def post(self): responses={200: 'Success', 403: 'Service can\'t delete asset', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a specific asset + """ service_access = DBManager.serviceAccess(current_service) parser = delete_parser diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDevices.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDevices.py index 6e378faf..b1c4d395 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDevices.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDevices.py @@ -4,11 +4,6 @@ from modules.LoginModule.LoginModule import LoginModule from modules.FlaskModule.FlaskModule import service_api_ns as api from opentera.db.models.TeraDevice import TeraDevice -from opentera.db.models.TeraDeviceType import TeraDeviceType -from opentera.db.models.TeraDeviceSubType import TeraDeviceSubType - -import uuid -from datetime import datetime # Parser definition(s) get_parser = api.parser() @@ -40,10 +35,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Query device information + """ args = get_parser.parse_args() # args['device_uuid'] Will be None if not specified in args if args['device_uuid']: @@ -77,10 +75,13 @@ def get(self): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(device_schema, validate=True) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update device + """ # args = post_parser.parse_args() # Using request.json instead of parser, since parser messes up the json! if 'device' not in request.json: diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDisconnect.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDisconnect.py index 5daab230..763b8947 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDisconnect.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryDisconnect.py @@ -1,5 +1,4 @@ -from flask import request -from flask_restx import Resource, inputs +from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api @@ -33,10 +32,13 @@ def __init__(self, _api, *args, **kwargs): 403: 'Forbidden access. Please check that the service has access to' ' the requested id/uuid.', 500: 'Database error'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Forcefully disconnect a user, participant or device + """ args = get_parser.parse_args() service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py index 27b5a4fb..21364dae 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py @@ -35,10 +35,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get participant groups + """ # Get service access manager, that allows to check for access service_access = DBManager.serviceAccess(current_service) @@ -80,10 +83,13 @@ def get(self): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update participant group + """ # Parse arguments args = post_parser.parse_args() @@ -163,10 +169,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user doesn\'t have permission to access the requested data', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete participant group + """ # Parse arguments args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipants.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipants.py index 6b7e8f0f..4f952a8b 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryParticipants.py @@ -58,10 +58,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get participant + """ args = get_parser.parse_args() service_access = DBManager.serviceAccess(current_service) @@ -108,10 +111,13 @@ def get(self): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(participant_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update a participant + """ args = post_parser.parse_args() # Using request.json instead of parser, since parser messes up the json! diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryProjects.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryProjects.py index 3c51fb3b..72ffd621 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryProjects.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryProjects.py @@ -35,10 +35,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get projects + """ args = get_parser.parse_args() service_access = DBManager.serviceAccess(current_service) @@ -77,10 +80,13 @@ def get(self): 403: 'Logged service can\'t create/update the specified project', 400: 'Badly formed JSON or missing fields(id_site or id_project) in the JSON body', 500: 'Internal error occured when saving project'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update projects + """ service_access = DBManager.serviceAccess(current_service) # Using request.json instead of parser, since parser messes up the json! if 'project' not in request.json: @@ -145,10 +151,13 @@ def post(self): responses={200: 'Success', 403: 'Logged service can\'t delete project (service not associated to)', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete project + """ service_access = DBManager.serviceAccess(current_service) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryRoles.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryRoles.py index a2f38f31..f99bc340 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryRoles.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryRoles.py @@ -27,10 +27,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get service roles for that service', responses={200: 'Success - returns list of roles', 500: 'Database error'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get service roles for the current service + """ args = get_parser.parse_args() roles = TeraServiceRole.get_service_roles(service_id=current_service.id_service) roles_list = [] @@ -43,10 +46,13 @@ def get(self): responses={200: 'Success', 400: 'Badly formed JSON or missing fields in the JSON body', 500: 'Internal error when saving roles'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update service roles for the current service + """ # Using request.json instead of parser, since parser messes up the json! if 'service_role' not in request.json: return gettext('Missing service_role field'), 400 @@ -99,10 +105,13 @@ def post(self): responses={200: 'Success', 403: 'Logged service can\'t delete role (not related to that service)', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a specific service role + """ args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServiceAccess.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServiceAccess.py index bf434ab0..661af342 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServiceAccess.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServiceAccess.py @@ -36,10 +36,13 @@ def __init__(self, _api, *args, **kwargs): responses={200: 'Success - returns list of access roles', 400: 'Required parameter is missing (must have at least one id)', 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get access roles for a specific item + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) args = get_parser.parse_args() @@ -72,10 +75,13 @@ def get(self): 403: 'Logged service can\'t modify association (only self access can be modified)', 400: 'Badly formed JSON or missing fields in the JSON body', 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update service - access association + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) # Using request.json instead of parser, since parser messes up the json! @@ -177,10 +183,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete association (not related to this service)', 500: 'Association not found or database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a specific service access + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServices.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServices.py index 50d6b0a0..e453d25a 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServices.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryServices.py @@ -30,6 +30,9 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get service information + """ args = get_parser.parse_args() services = [] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionEvents.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionEvents.py index afc33a51..38dfa396 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionEvents.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionEvents.py @@ -1,9 +1,8 @@ -from flask import jsonify, session, request +from flask import request from flask_restx import Resource, reqparse from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraSessionEvent import TeraSessionEvent from opentera.db.models.TeraSession import TeraSession from modules.DatabaseModule.DBManager import DBManager @@ -34,10 +33,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'Required parameter is missing (id_session)', 403: 'Service doesn\'t have permission to access the requested data', 500: 'Database error'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get events for a session + """ args = get_parser.parse_args() sessions_events = [] @@ -67,10 +69,13 @@ def get(self): 403: 'Logged user can\'t create/update the specified event', 400: 'Badly formed JSON or missing fields(id_session_event or id_session) in the JSON body', 500: 'Internal error when saving device'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update session events + """ # Using request.json instead of parser, since parser messes up the json! if 'session_event' not in request.json: return gettext('Missing session_event field'), 400 diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionTypes.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionTypes.py index 07e0034a..82bb3967 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionTypes.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessionTypes.py @@ -27,10 +27,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get session types associated to that service + """ args = get_parser.parse_args() service_access = DBManager.serviceAccess(current_service) diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessions.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessions.py index 64a9cc58..b45452c0 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessions.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySessions.py @@ -1,5 +1,5 @@ from flask import request -from flask_restx import Resource, inputs # , reqparse +from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api @@ -52,10 +52,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get sessions + """ args = get_parser.parse_args() service_access = DBManager.serviceAccess(current_service) @@ -128,10 +131,13 @@ def get(self): 400: 'Badly formed JSON or missing fields(session, id_session, session_participants_ids and/or ' 'session_users_ids[for new sessions]) in the JSON body', 500: 'Internal error when saving session'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update session + """ if 'session' not in request.json: return gettext('Missing session'), 400 diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py index c7135959..cd688ea6 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py @@ -24,10 +24,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get access roles for a specific user and/or project/site + """ args = get_parser.parse_args(strict=True) service_access = DBManager.serviceAccess(current_service) diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySites.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySites.py index 0714bfd4..25a09c3d 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySites.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQuerySites.py @@ -23,10 +23,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get sites + """ args = get_parser.parse_args(strict=True) service_access = DBManager.serviceAccess(current_service) diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py index 9847e29a..7ca1b847 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py @@ -42,10 +42,13 @@ def __init__(self, _api, *args, **kwargs): responses={200: 'Success - returns list of test-types - projects association', 400: 'Required parameter is missing (must have at least one id)', 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get test types associated with a project + """ service_access = DBManager.serviceAccess(current_service) args = get_parser.parse_args() @@ -102,10 +105,13 @@ def get(self): 403: 'Logged service can\'t modify association (not associated to project or test type)', 400: 'Badly formed JSON or missing fields in the JSON body', 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update test types -> project association + """ service_access = DBManager.serviceAccess(current_service) accessible_projects_ids = service_access.get_accessible_projects_ids(admin_only=True) @@ -238,10 +244,13 @@ def post(self): responses={200: 'Success', 403: 'Logged service can\'t delete association (no access to test-type or project)', 400: 'Association not found (invalid id?)'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a specific test type -> project association + """ service_access = DBManager.serviceAccess(current_service) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypes.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypes.py index ecbeaf6b..55a497c5 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypes.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTestTypes.py @@ -39,10 +39,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get test types + """ args = get_parser.parse_args(strict=True) service_access = DBManager.serviceAccess(current_service) @@ -78,10 +81,13 @@ def get(self): 403: 'Service can\'t create/update the specified test type', 400: 'Badly formed JSON or missing field in the JSON body', 500: 'Internal error when saving test type'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update test types + """ # Using request.json instead of parser, since parser messes up the json! if 'test_type' not in request.json: return gettext('Missing test_type'), 400 @@ -139,10 +145,13 @@ def post(self): responses={200: 'Success', 403: 'Service can\'t delete test type', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a test type + """ args = delete_parser.parse_args() uuid_todel = args['uuid'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTests.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTests.py index 400e1cb6..7a76e708 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTests.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryTests.py @@ -1,5 +1,5 @@ from flask import request -from flask_restx import Resource, reqparse, inputs +from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import service_api_ns as api @@ -44,10 +44,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get tests + """ service_access = DBManager.serviceAccess(current_service) args = get_parser.parse_args() @@ -120,10 +123,13 @@ def get(self): 400: 'Bad request - wrong or missing parameters in query', 500: 'Required parameter is missing', 403: 'Service doesn\'t have permission to post that test'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_parser) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update test + """ service_access = DBManager.serviceAccess(current_service) # Using request.json instead of parser, since parser messes up the json! @@ -249,10 +255,13 @@ def post(self): responses={200: 'Success', 403: 'Service can\'t delete test', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete specific test + """ service_access = DBManager.serviceAccess(current_service) args = delete_parser.parse_args() uuid_todel = args['uuid'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUserGroups.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUserGroups.py index faf0455f..d2d67e22 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUserGroups.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUserGroups.py @@ -8,7 +8,6 @@ from opentera.db.models.TeraUserGroup import TeraUserGroup from flask_babel import gettext from modules.DatabaseModule.DBManager import DBManager, DBManagerTeraServiceAccess -import modules.Globals as Globals # Parser definition(s) get_parser = api.parser() @@ -34,10 +33,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get user group information. If no id specified, returns all accessible users groups', responses={200: 'Success', 500: 'Database error'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get usergroups + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) args = get_parser.parse_args() user_groups = [] @@ -71,10 +73,13 @@ def get(self): 403: 'Logged service can\'t create/update the specified user group', 400: 'Badly formed JSON or missing field(id_user_group) in the JSON body', 500: 'Internal error when saving user group'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(post_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Create / update usergroup + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) if 'user_group' not in request.json: @@ -193,10 +198,13 @@ def post(self): responses={200: 'Success', 403: 'Service can\'t delete user group (no access to it)', 500: 'Database error.'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(delete_parser) @LoginModule.service_token_or_certificate_required def delete(self): + """ + Delete a specific usergroup + """ service_access: DBManagerTeraServiceAccess = DBManager.serviceAccess(current_service) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUsers.py b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUsers.py index 5af700fc..7743ef5b 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUsers.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceQueryUsers.py @@ -23,10 +23,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Required parameter is missing', 501: 'Not implemented.', 403: 'Service doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(get_parser) @LoginModule.service_token_or_certificate_required def get(self): + """ + Get specific user information + """ args = get_parser.parse_args() # args['user_id'] Will be None if not specified in args diff --git a/teraserver/python/modules/FlaskModule/API/service/ServiceSessionManager.py b/teraserver/python/modules/FlaskModule/API/service/ServiceSessionManager.py index 8acee8dd..89735f4e 100644 --- a/teraserver/python/modules/FlaskModule/API/service/ServiceSessionManager.py +++ b/teraserver/python/modules/FlaskModule/API/service/ServiceSessionManager.py @@ -1,8 +1,7 @@ -from flask import session, request +from flask import request from flask_restx import Resource from modules.LoginModule.LoginModule import LoginModule, current_service from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraSession import TeraSession from flask_babel import gettext @@ -99,10 +98,13 @@ def __init__(self, _api, *args, **kwargs): 500: 'Internal server error', 501: 'Not implemented', 403: 'Service doesn\'t have enough permission'}, - params={'token': 'Secret token'}) + params={'token': 'Access token'}) @api.expect(session_manager_schema) @LoginModule.service_token_or_certificate_required def post(self): + """ + Starts / stop a session related to a service + """ args = post_parser.parse_args() service_access = DBManager.serviceAccess(current_service) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogout.py b/teraserver/python/modules/FlaskModule/API/user/UserLogout.py index 597953fb..ae2a8279 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogout.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogout.py @@ -1,5 +1,5 @@ from flask_login import logout_user -from flask_restx import Resource, reqparse +from flask_restx import Resource from flask_babel import gettext from flask import session, request from modules.FlaskModule.FlaskModule import user_api_ns as api @@ -15,10 +15,13 @@ def __init__(self, _api, *args, **kwargs): self.module = kwargs.get('flaskModule', None) self.test = kwargs.get('test', False) - @api.doc(description='Logout from the server', params={'token': 'Secret token'}) + @api.doc(description='Logout from the server') @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Logout the user + """ if current_user: logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryAssets.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryAssets.py index 8d980e61..d144c2c8 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryAssets.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryAssets.py @@ -1,9 +1,8 @@ -from flask import session, request +from flask import request from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraAsset import TeraAsset from opentera.db.models.TeraService import TeraService @@ -46,11 +45,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get asset information. Only one of the ID parameter is supported at once', responses={200: 'Success - returns list of assets', 400: 'Required parameter is missing', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get asset information + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -166,18 +167,22 @@ def get(self): # else: return assets_list - @api.doc(description='Delete asset.', - responses={501: 'Unable to update asset information from here'}, - params={'token': 'Secret token'}) + @api.doc(description='Update asset information', + responses={501: 'Unable to update asset information from here'}) @user_multi_auth.login_required def post(self): + """ + Update asset information + """ return gettext('Asset information update and creation must be done directly into a service (such as ' 'Filetransfer service)'), 501 - @api.doc(description='Delete asset.', - responses={501: 'Unable to delete asset information from here'}, - params={'token': 'Secret token'}) + @api.doc(description='Delete asset information', + responses={501: 'Unable to delete asset information from here'}) @user_multi_auth.login_required def delete(self): + """ + Delete asset information + """ return gettext('Asset information deletion must be done directly into a service (such as ' 'Filetransfer service)'), 501 diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryAssetsArchive.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryAssetsArchive.py index 2997c022..866a27d0 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryAssetsArchive.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryAssetsArchive.py @@ -4,17 +4,14 @@ import json import datetime import threading -import zipfile -from io import BytesIO -from flask import session, request, Response -from flask_restx import Resource, inputs +from flask import request +from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.FlaskModule import FlaskModule -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraAsset import TeraAsset from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraProject import TeraProject @@ -46,11 +43,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get asset archive. Only one of the ID parameter is supported at once.', responses={200: 'Success - returns list of assets', 400: 'Required parameter is missing', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get asset archive + """ # Get parameters args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceParticipants.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceParticipants.py index 92855b9c..12ff9198 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceParticipants.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, fields, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDeviceParticipant import TeraDeviceParticipant from opentera.db.models.TeraParticipant import TeraParticipant from opentera.db.models.TeraDeviceProject import TeraDeviceProject @@ -55,11 +54,13 @@ def __init__(self, _api, *args, **kwargs): ' at once.', responses={200: 'Success - returns list of devices - participants association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occurred when loading devices for participant'}, - params={'token': 'Secret token'}) + 500: 'Error occurred when loading devices for participant'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get devices assigned to a participant + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -107,11 +108,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify device association', 400: 'Badly formed JSON or missing fields(id_participant or id_device) in the JSON body', - 500: 'Internal error occured when saving device association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Assign / remove devices from a participant + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! @@ -187,11 +190,13 @@ def post(self): @api.doc(description='Delete a specific device-participant association.', responses={200: 'Success', 403: 'Logged user can\'t delete device association', - 500: 'Device-participant association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Device-participant association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Remove a specific device - participant association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceProjects.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceProjects.py index 72ca569a..7e88a050 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceProjects.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceProjects.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request -from flask_restx import Resource, reqparse, inputs +from flask import request +from flask_restx import Resource, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDeviceProject import TeraDeviceProject from opentera.db.models.TeraDeviceSite import TeraDeviceSite from opentera.db.models.TeraDevice import TeraDevice @@ -51,11 +50,13 @@ def __init__(self, _api, *args, **kwargs): 'supported at once.', responses={200: 'Success - returns list of devices - project association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occurred when loading devices for projects'}, - params={'token': 'Secret token'}) + 500: 'Error occurred when loading devices for projects'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get devices associated with a project + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -127,11 +128,13 @@ def get(self): 403: 'Logged user can\'t modify device association - the user isn\'t admin ' 'of the project or current user can\'t access the device.', 400: 'Badly formed JSON or missing fields(id_project or id_device) in the JSON body', - 500: 'Internal error occured when saving device association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create/update devices associated with a project + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! @@ -281,11 +284,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete device association (no admin access to project or one of the ' 'device\'s site)', - 500: 'Device-project association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Device-project association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific device-project association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSites.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSites.py index 4bb04dfc..7c5b37d9 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSites.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request +from flask import request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraDeviceSite import TeraDeviceSite @@ -47,11 +46,13 @@ def __init__(self, _api, *args, **kwargs): ' at once.', responses={200: 'Success - returns list of devices - sites association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occurred when loading devices for sites'}, - params={'token': 'Secret token'}) + 500: 'Error occurred when loading devices for sites'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get devices associated to a site + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -112,11 +113,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify device association', 400: 'Badly formed JSON or missing fields(id_site or id_device) in the JSON body', - 500: 'Internal error occured when saving device association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create/update devices association with a site + """ # Only super admins can change service - site associations if not current_user.user_superadmin: return gettext('Forbidden'), 403 @@ -236,11 +239,13 @@ def post(self): @api.doc(description='Delete a specific device-site association.', responses={200: 'Success', 403: 'Logged user can\'t delete device association (no admin access to site)', - 500: 'Device-site association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Device-site association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific device-site association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py index 03463e12..d77a3115 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py @@ -1,9 +1,8 @@ -from flask import session, request +from flask import request from flask_restx import Resource, reqparse, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDeviceSubType import TeraDeviceSubType from sqlalchemy.exc import InvalidRequestError from sqlalchemy import exc @@ -36,11 +35,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'No parameters specified at least one id must be used', 403: 'Forbidden access to the device type specified. Please check that the user has access to a' ' session type containing that device type.', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get devices subtypes + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() has_list = args.pop('list') @@ -92,11 +93,13 @@ def get(self): 403: 'Logged user can\'t create/update the specified device subtype', 400: 'Badly formed JSON or missing fields(id_device_subtype or id_device_type) in the JSON ' 'body', - 500: 'Internal error occured when saving device subtype'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device subtype'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update devices subtypes + """ user_access = DBManager.userAccess(current_user) if 'device_subtype' not in request.json: return gettext('Missing device_subtype'), 400 @@ -151,11 +154,13 @@ def post(self): @api.doc(description='Delete a specific device subtype', responses={200: 'Success', 403: 'Logged user can\'t delete device subtype (can delete if site admin)', - 500: 'Device subtype not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Device subtype not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific device subtype + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceTypes.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceTypes.py index 6c439a5d..a075bf03 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceTypes.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDeviceTypes.py @@ -1,12 +1,10 @@ -from flask import session, request +from flask import request from flask_babel import gettext from flask_restx import Resource, reqparse, inputs from sqlalchemy import exc from sqlalchemy.exc import InvalidRequestError -from opentera.db.models import TeraDeviceType from opentera.db.models.TeraDeviceType import TeraDeviceType -from opentera.db.models.TeraUser import TeraUser from modules.DatabaseModule.DBManager import DBManager from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.LoginModule.LoginModule import user_multi_auth, current_user @@ -40,11 +38,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'No parameters specified at least one id must be used', 403: 'Forbidden access to the device type specified. Please check that the user has access to a' ' session type containing that device type.', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get devices types + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() device_type = [] @@ -84,12 +84,14 @@ def get(self): 403: 'Logged user can\'t create/update the specified device type', 400: 'Badly formed JSON or missing fields(id_device_name or id_device_type) in the JSON ' 'body', - 500: 'Internal error occured when saving device type'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device type'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): - user_access = DBManager.userAccess(current_user) + """ + Create / update devices types + """ + # user_access = DBManager.userAccess(current_user) if 'device_type' not in request.json: return gettext('Missing device type'), 400 @@ -144,11 +146,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete device type (can delete if site admin)', 500: 'Device type not found or database error.', - 501: 'Tried to delete with 2 parameters'}, - params={'token': 'Secret token'}) + 501: 'Tried to delete with 2 parameters'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific device type + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() # To accommodate the 'delete_with_http_auth' function which uses id as args, the id_device_type is rename as id diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDevices.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDevices.py index 740685e9..d0d4cf58 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDevices.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDevices.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDevice import TeraDevice from opentera.db.models.TeraProject import TeraProject from opentera.db.models.TeraSite import TeraSite @@ -75,11 +74,13 @@ def __init__(self, _api, *args, **kwargs): responses={200: 'Success - returns list of devices', 400: 'User Error : Too Many IDs', 403: 'Forbidden access', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get device information + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -242,11 +243,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified device', 400: 'Badly formed JSON or missing fields(id_device) in the JSON body', - 500: 'Internal error occurred when saving device'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving device'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update devices + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'device' not in request.json: @@ -395,11 +398,13 @@ def post(self): responses={200: 'Success', 400: 'Wrong ID/ No ID', 403: 'Logged user can\'t delete device (can delete if superadmin)', - 500: 'Device not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Device not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific device + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryDisconnect.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryDisconnect.py index b3a1e9a7..4a30a210 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryDisconnect.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryDisconnect.py @@ -1,5 +1,4 @@ -from flask import jsonify, session, request -from flask_restx import Resource, reqparse, inputs +from flask_restx import Resource from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.LoginModule.LoginModule import user_multi_auth, current_user from opentera.db.models.TeraUser import TeraUser @@ -33,11 +32,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'No parameters specified, at least one id / uuid must be used', 403: 'Forbidden access. Please check that the user has access to' ' the requested id/uuid.', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Force disconnect a specific user / participant / device from server + """ args = get_parser.parse_args() user_access: DBManagerTeraUserAccess = DBManager.userAccess(current_user) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py index 5e21fa09..badbf226 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py @@ -1,5 +1,4 @@ -from flask import jsonify, session -from flask_restx import Resource, reqparse +from flask_restx import Resource from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.DatabaseModule.DBManager import DBManager @@ -66,11 +65,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get json description of standard input form for the specified data type.', responses={200: 'Success', 400: 'Missing required parameter', - 500: 'Unknown or unsupported data type'}, - params={'token': 'Secret token'}) + 500: 'Unknown or unsupported data type'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get json description of form to display to edit a specific data type + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineDevices.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineDevices.py index ec7ff8eb..a0daefb4 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineDevices.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineDevices.py @@ -1,10 +1,8 @@ -from flask import session from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from sqlalchemy.exc import InvalidRequestError -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraDevice import TeraDevice from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames @@ -20,12 +18,14 @@ def __init__(self, _api, *args, **kwargs): self.test = kwargs.get('test', False) @api.doc(description='Get online devices uuids.', - responses={200: 'Success'}, - params={'token': 'Secret token'}) + responses={200: 'Success'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): - args = get_parser.parse_args() + """ + Get online devices + """ + # args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) try: diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineParticipants.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineParticipants.py index c645852e..9c8bca75 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineParticipants.py @@ -1,10 +1,8 @@ -from flask import session from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from sqlalchemy.exc import InvalidRequestError -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraParticipant import TeraParticipant from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames @@ -27,6 +25,9 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get online participants + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineUsers.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineUsers.py index e49c9c09..0286a95b 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineUsers.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryOnlineUsers.py @@ -1,4 +1,3 @@ -from flask import session from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user @@ -24,7 +23,10 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @user_multi_auth.login_required def get(self): - args = get_parser.parse_args() + """ + Get online users + """ + # args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) try: diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipantGroup.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipantGroup.py index 63909a3f..be3542f6 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipantGroup.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipantGroup.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraParticipantGroup import TeraParticipantGroup from modules.DatabaseModule.DBManager import DBManager from sqlalchemy.exc import InvalidRequestError @@ -36,11 +35,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get participant groups information. Only one of the ID parameter is supported at once. ' 'If no ID is specified, returns all accessible groups for the logged user', responses={200: 'Success - returns list of participant groups', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get participant groups + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -82,11 +83,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified device', 400: 'Badly formed JSON or missing fields(id_participant_group or id_project) in the JSON body', - 500: 'Internal error occurred when saving device'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving device'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update participant groups + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'group' not in request.json and 'participant_group' not in request.json: @@ -141,11 +144,13 @@ def post(self): @api.doc(description='Delete a specific participant group', responses={200: 'Success', 403: 'Logged user can\'t delete participant group (only project admin can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a participant group + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipants.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipants.py index 04e556b2..683d4938 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipants.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryParticipants.py @@ -57,11 +57,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get participants information. Only one of the ID parameter is supported and required at once', responses={200: 'Success - returns list of participants', 400: 'No parameters specified at least one id must be used', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get participants + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -208,11 +210,13 @@ def get(self): 403: 'Logged user can\'t create/update the specified participant', 400: 'Badly formed JSON or missing fields(id_participant or id_project/id_group [only one of ' 'them]) in the JSON body, or mismatch between id_project and participant group project', - 500: 'Internal error when saving participant'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving participant'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update participant + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'participant' not in request.json: @@ -345,11 +349,13 @@ def post(self): @api.doc(description='Delete a specific participant', responses={200: 'Success', 403: 'Logged user can\'t delete participant (only project admin can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a participant + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryProjectAccess.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryProjectAccess.py index 9d31ad88..cd42dcb7 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryProjectAccess.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryProjectAccess.py @@ -1,10 +1,9 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from flask_babel import gettext from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceAccess import TeraServiceAccess from opentera.db.models.TeraServiceRole import TeraServiceRole from opentera.db.models.TeraProject import TeraProject @@ -67,11 +66,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get user roles for projects. Only one ID parameter required and supported at once.', responses={200: 'Success - returns list of users roles in projects', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occured when loading project roles'}, - params={'token': 'Secret token'}) + 500: 'Error occured when loading project roles'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get roles for users / user groupes in a project + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -173,15 +174,17 @@ def get(self): # No access, but still fine return [], 200 - @api.doc(description='Create/update project access for an user.', + @api.doc(description='Create/update project access for an user / usergroup.', responses={200: 'Success', 403: 'Logged user can\'t modify this project or user access (project admin access required)', 400: 'Badly formed JSON or missing fields(id_user_group or id_project) in the JSON body', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update project roles for users / usergroups + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! json_projects = request.json['project_access'] @@ -256,11 +259,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete project access(only user who is admin in that project can ' 'remove it)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete specific user / usergroup role in a project + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryProjects.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryProjects.py index f1d1670d..a73a4ee4 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryProjects.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryProjects.py @@ -43,11 +43,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get projects information. Only one of the ID parameter is supported and required at once', responses={200: 'Success - returns list of participants', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get projects + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -121,11 +123,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified project', 400: 'Badly formed JSON or missing fields(id_site or id_project) in the JSON body', - 500: 'Internal error occured when saving project'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving project'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update project + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'project' not in request.json: @@ -247,11 +251,13 @@ def post(self): @api.doc(description='Delete a specific project', responses={200: 'Success', 403: 'Logged user can\'t delete project (only site admin can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete project + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServerSettings.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServerSettings.py index ac10146c..e35a1108 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServerSettings.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServerSettings.py @@ -19,11 +19,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get server setting key', responses={200: 'Success - returns setting value', - 401: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 401: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get server settings + """ # As soon as we are authorized, we can output the server versions args = get_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccess.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccess.py index 285bfc62..f33cd965 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccess.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccess.py @@ -1,5 +1,5 @@ -from flask import jsonify, request -from flask_restx import Resource, reqparse, inputs +from flask import request +from flask_restx import Resource, reqparse from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from opentera.db.models.TeraServiceAccess import TeraServiceAccess @@ -39,11 +39,13 @@ def __init__(self, _api, *args, **kwargs): 'supported at once.', responses={200: 'Success - returns list of access roles', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get service access roles for a specific item + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -88,11 +90,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify association (only site admin can modify association)', 400: 'Badly formed JSON or missing fields(id_project or id_service) in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update service - access association + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! @@ -194,11 +198,13 @@ def post(self): @api.doc(description='Delete a specific service access.', responses={200: 'Success', 403: 'Logged user can\'t delete association (not admin of the associated elements)', - 500: 'Association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific service access for an item + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccessToken.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccessToken.py index e38ae3e0..22abc3fa 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccessToken.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceAccessToken.py @@ -23,11 +23,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get access token for a specific service.', responses={200: 'Success - returns access token', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get service access token + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceConfigs.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceConfigs.py index efeeba66..18a06b8c 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceConfigs.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceConfigs.py @@ -1,8 +1,7 @@ -from flask import jsonify, session, request +from flask import session, request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceConfig import TeraServiceConfig from modules.DatabaseModule.DBManager import DBManager from sqlalchemy.exc import InvalidRequestError @@ -47,11 +46,13 @@ def __init__(self, _api, *args, **kwargs): 'config the current user.', responses={200: 'Success - returns list of configurations', 400: 'No parameters specified - id_service is at least required', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get specific service configuration for an item + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -110,11 +111,13 @@ def get(self): 403: 'Logged user can\'t create/update the specified session', 400: 'Badly formed JSON or missing fields(service_config, id_service_config, id_service) in the' ' JSON body', - 500: 'Internal error when saving service config'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving service config'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update service configuration for an item + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'service_config' not in request.json: @@ -220,15 +223,17 @@ def post(self): else: return [update_config.to_json()] - @api.doc(description='Delete a specific session', + @api.doc(description='Delete a specific service configuration', responses={200: 'Success', 403: 'Logged user can\'t delete config (must have admin access to the related object - user,' 'device or participant, or be its own config)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific service configuration for an item + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceProjects.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceProjects.py index 512cdc31..3f0456f2 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceProjects.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceProjects.py @@ -49,11 +49,13 @@ def __init__(self, _api, *args, **kwargs): 'supported at once.', responses={200: 'Success - returns list of services - projects association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get services associated with a project + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -128,11 +130,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify association (only site admin can modify association)', 400: 'Badly formed JSON or missing fields(id_project or id_service) in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update service-project association + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! @@ -293,11 +297,13 @@ def post(self): @api.doc(description='Delete a specific service - project association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (not site admin of the associated project)', - 500: 'Association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific service - project association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceRoles.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceRoles.py index 06724799..7d9fa30e 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceRoles.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceRoles.py @@ -33,11 +33,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get service roles for either a specific service or for all available services.', responses={200: 'Success - returns list of service roles', - 500: 'Error when getting roles'}, - params={'token': 'Secret token'}) + 500: 'Error when getting roles'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get service roles + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -70,11 +72,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify service role (only super admin)', 400: 'Badly formed JSON or missing fields(id_service or id_service_role) in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update service roles + """ if not current_user.user_superadmin: return gettext('Forbidden'), 403 @@ -123,11 +127,13 @@ def post(self): @api.doc(description='Delete a specific service role.', responses={200: 'Success', 403: 'Logged user can\'t delete role (not super admin)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete service role + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceSites.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceSites.py index 55d48709..c0459993 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceSites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServiceSites.py @@ -1,4 +1,4 @@ -from flask import jsonify, request +from flask import request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api @@ -46,11 +46,13 @@ def __init__(self, _api, *args, **kwargs): 'supported at once.', responses={200: 'Success - returns list of services - sites association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get services associated with a site + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -117,12 +119,14 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify association (only super admin can modify association)', 400: 'Badly formed JSON or missing fields(id_project or id_service) in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): - user_access = DBManager.userAccess(current_user) + """ + Create / update service-site association + """ + # user_access = DBManager.userAccess(current_user) # Only super admins can change service - site associations if not current_user.user_superadmin: @@ -248,12 +252,14 @@ def post(self): @api.doc(description='Delete a specific service - site association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (only super admins can)', - 500: 'Association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): - user_access = DBManager.userAccess(current_user) + """ + Delete service-site association + """ + # user_access = DBManager.userAccess(current_user) if not current_user.user_superadmin: return gettext('Forbidden'), 403 diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryServices.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryServices.py index 2c830618..dbe85513 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryServices.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryServices.py @@ -1,10 +1,9 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from sqlalchemy.exc import InvalidRequestError from sqlalchemy import exc -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraServiceRole import TeraServiceRole from modules.DatabaseModule.DBManager import DBManager @@ -41,11 +40,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get services information. Only one of the ID parameter is supported and required at once.', responses={200: 'Success - returns list of services', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get services + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -109,11 +110,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified service', 400: 'Badly formed JSON or missing fields(id_service) in the JSON body', - 500: 'Internal error occured when saving service'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving service'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update services + """ user_access = DBManager.userAccess(current_user) # Check if user is a super admin @@ -217,11 +220,13 @@ def post(self): 400: 'Service doesn\'t exists', 403: 'Logged user can\'t delete service (only super admins can delete) or service is a system ' 'service', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete service + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionEvents.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionEvents.py index 76ca8aa2..b2270df8 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionEvents.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionEvents.py @@ -1,9 +1,8 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraSessionEvent import TeraSessionEvent from modules.DatabaseModule.DBManager import DBManager from sqlalchemy.exc import InvalidRequestError @@ -32,11 +31,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get events for a specific session', responses={200: 'Success - returns list of events', 400: 'Required parameter is missing (id_session)', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get events for a session + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -66,11 +67,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified event', 400: 'Badly formed JSON or missing fields(id_session_event or id_session) in the JSON body', - 500: 'Internal error when saving device'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving device'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update session events + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'session_event' not in request.json: @@ -126,11 +129,13 @@ def post(self): @api.doc(description='Delete a specific session event', responses={200: 'Success', 403: 'Logged user can\'t delete event (no access to that session)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a session event + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py index 83ec3685..d0427392 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py @@ -44,15 +44,17 @@ def __init__(self, _api, *args, **kwargs): self.module = kwargs.get('flaskModule', None) self.test = kwargs.get('test', False) - @api.doc(description='Get devices types that are associated with a project. Only one "ID" parameter required and ' + @api.doc(description='Get session types that are associated with a project. Only one "ID" parameter required and ' 'supported at once.', responses={200: 'Success - returns list of devices-types - projects association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get session types associated with a project + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -115,11 +117,13 @@ def get(self): 403: 'Logged user can\'t modify association (session type must be accessible from project ' 'access)', 400: 'Badly formed JSON or missing fields(id_project or id_session_type) in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update session types for a project + """ user_access = DBManager.userAccess(current_user) accessible_projects_ids = user_access.get_accessible_projects_ids(admin_only=True) @@ -255,11 +259,13 @@ def post(self): @api.doc(description='Delete a specific session-type - project association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (no access to session-type or project)', - 400: 'Association not found (invalid id?)'}, - params={'token': 'Secret token'}) + 400: 'Association not found (invalid id?)'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete specific session type - project association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeSites.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeSites.py index d43e0b57..49075376 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeSites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypeSites.py @@ -45,11 +45,13 @@ def __init__(self, _api, *args, **kwargs): ' at once.', responses={200: 'Success - returns list of session types - sites association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occurred when loading devices for sites'}, - params={'token': 'Secret token'}) + 500: 'Error occurred when loading devices for sites'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get session types associated with a site + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -104,11 +106,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify device association', 400: 'Badly formed JSON or missing fields(id_site or id_device) in the JSON body', - 500: 'Internal error occured when saving device association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occured when saving device association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update session types associated with a site + """ user_access = DBManager.userAccess(current_user) # Only super admins can change session type - site associations @@ -248,11 +252,13 @@ def post(self): @api.doc(description='Delete a specific session type-site association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (no admin access to site)', - 500: 'Session type - site association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Session type - site association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific session type - site association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypes.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypes.py index df9c1b2e..80ddf24b 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypes.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessionTypes.py @@ -1,4 +1,4 @@ -from flask import session, request +from flask import request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api @@ -37,11 +37,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get session type information. If no id_session_type specified, returns all available ' 'session types', responses={200: 'Success - returns list of session types', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get session type + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -84,11 +86,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified session type', 400: 'Badly formed JSON or missing field(id_session_type) in the JSON body', - 500: 'Internal error when saving session type'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving session type'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update session type + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'session_type' not in request.json: @@ -302,6 +306,9 @@ def post(self): @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete session type + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessions.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessions.py index f6c4d6aa..dea61725 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySessions.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySessions.py @@ -1,4 +1,4 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api @@ -45,11 +45,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get sessions information. Only one of the ID parameter is supported and required at once', responses={200: 'Success - returns list of sessions', 400: 'No parameters specified at least one id must be used', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get session + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -109,11 +111,13 @@ def get(self): 403: 'Logged user can\'t create/update the specified session', 400: 'Badly formed JSON or missing fields(session, id_session, session_participants_ids and/or ' 'session_users_ids[for new sessions]) in the JSON body', - 500: 'Internal error when saving session'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving session'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update session + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'session' not in request.json: @@ -225,11 +229,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete session (must have access to all participants and users in the ' 'session to delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete session + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySiteAccess.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySiteAccess.py index 45d21a8e..c8890c63 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySiteAccess.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySiteAccess.py @@ -1,10 +1,9 @@ -from flask import jsonify, session, request +from flask import request from flask_restx import Resource, reqparse, inputs from flask_babel import gettext from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceAccess import TeraServiceAccess from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraServiceRole import TeraServiceRole @@ -66,11 +65,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get user roles for sites. Only one parameter required and supported at once.', responses={200: 'Success - returns list of users roles in sites', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occurred when loading sites roles'}, - params={'token': 'Secret token'}) + 500: 'Error occurred when loading sites roles'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get access role to site for user / usergroup + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -160,11 +161,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify this site or user access (site admin access required)', 400: 'Badly formed JSON or missing fields(id_user or id_site) in the JSON body', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update site access for an user / usergroup + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! json_sites = request.json['site_access'] @@ -257,11 +260,13 @@ def post(self): @api.doc(description='Delete a specific site access', responses={200: 'Success', 403: 'Logged user can\'t delete site access(only user who is admin in that site can remove it)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific site access + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py b/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py index 2f430fe3..7d48f485 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQuerySites.py @@ -1,7 +1,7 @@ from flask import jsonify, request from flask_restx import Resource, reqparse from sqlalchemy import exc -from modules.LoginModule.LoginModule import user_multi_auth, current_user, user_token_auth +from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from sqlalchemy.exc import InvalidRequestError from opentera.db.models.TeraUser import TeraUser @@ -39,6 +39,9 @@ def __init__(self, _api, *args, **kwargs): @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get site + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -105,6 +108,9 @@ def get(self): @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update site + """ user_access = DBManager.userAccess(current_user) if 'site' not in request.json: return gettext('Missing site'), 400 @@ -165,6 +171,9 @@ def post(self): @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a site + """ args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryStats.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryStats.py index b51f3b71..8da3e5f6 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryStats.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryStats.py @@ -39,11 +39,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get stats for the specified item.', responses={200: 'Success', 400: 'Missing parameter - one id must be specified.', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get stats for the specified item + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) @@ -238,7 +240,6 @@ def get_site_stats(user_access: DBManagerTeraUserAccess, item_id: int, with_part @staticmethod def get_project_stats(user_access: DBManagerTeraUserAccess, item_id: int, with_parts: bool) -> dict: - from opentera.db.models.TeraSessionParticipants import TeraSessionParticipants from opentera.db.models.TeraProject import TeraProject project_users = user_access.query_users_for_project(project_id=item_id) project_users_enabled = user_access.query_users_for_project(project_id=item_id, enabled_only=True) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestType.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestType.py index fe4e96f9..2d7f8dab 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestType.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestType.py @@ -1,4 +1,4 @@ -from flask import session, request +from flask import request from flask_restx import Resource, reqparse, inputs from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api @@ -41,11 +41,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get test type information. If no id_test_type specified, returns all available test types', responses={200: 'Success - returns list of test types', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get test types + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -117,11 +119,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified test type', 400: 'Badly formed JSON or missing field in the JSON body', - 500: 'Internal error when saving test type'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving test type'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update test types + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! if 'test_type' not in request.json: @@ -311,11 +315,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete test type (no admin access to project related to that type ' 'or tests of that type exists in the system somewhere)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete test type + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeProjects.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeProjects.py index ccb64505..7181f2ce 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeProjects.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeProjects.py @@ -48,11 +48,13 @@ def __init__(self, _api, *args, **kwargs): 'supported at once.', responses={200: 'Success - returns list of test-types - projects association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error when getting association'}, - params={'token': 'Secret token'}) + 500: 'Error when getting association'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get test types - project association + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -113,11 +115,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify association (project admin access required)', 400: 'Badly formed JSON or missing fields in the JSON body', - 500: 'Internal error occurred when saving association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update test-type -> project association + """ user_access = DBManager.userAccess(current_user) accessible_projects_ids = user_access.get_accessible_projects_ids(admin_only=True) @@ -256,11 +260,13 @@ def post(self): @api.doc(description='Delete a specific test-type - project association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (no access to test-type or project)', - 400: 'Association not found (invalid id?)'}, - params={'token': 'Secret token'}) + 400: 'Association not found (invalid id?)'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific test type - project association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeSites.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeSites.py index 2339a5dc..f39521fa 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeSites.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryTestTypeSites.py @@ -42,15 +42,17 @@ def __init__(self, _api, *args, **kwargs): self.module = kwargs.get('flaskModule', None) self.test = kwargs.get('test', False) - @api.doc(description='Get session types that are related to a site. Only one "ID" parameter required and supported' + @api.doc(description='Get test types that are related to a site. Only one "ID" parameter required and supported' ' at once.', responses={200: 'Success - returns list of session types - sites association', 400: 'Required parameter is missing (must have at least one id)', - 500: 'Error occured when loading devices for sites'}, - params={'token': 'Secret token'}) + 500: 'Error occured when loading devices for sites'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get test types associated to a site + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -102,11 +104,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t modify association', 400: 'Badly formed JSON or missing fields(id_site or id_test_type) in the JSON body', - 500: 'Internal error occurred when saving device association'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving device association'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update test types associated to a site + """ user_access = DBManager.userAccess(current_user) # Only super admins can change session type - site associations @@ -237,11 +241,13 @@ def post(self): @api.doc(description='Delete a specific test type-site association.', responses={200: 'Success', 403: 'Logged user can\'t delete association (no admin access to site)', - 500: 'Session type - site association not found or database error.'}, - params={'token': 'Secret token'}) + 500: 'Session type - site association not found or database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete specific test type - site association + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryTests.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryTests.py index dc4a0d3b..4edbf3c8 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryTests.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryTests.py @@ -1,4 +1,4 @@ -from flask import session, request +from flask import request from flask_restx import Resource, inputs from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user @@ -41,11 +41,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get test information. Only one of the ID parameter is supported at once', responses={200: 'Success - returns list of assets', 400: 'Required parameter is missing', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get test information + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -115,11 +117,13 @@ def get(self): return tests_list @api.doc(description='Delete test.', - responses={501: 'Unable to update test from here - use service!'}, - params={'token': 'Secret token'}) + responses={501: 'Unable to update test from here - use service!'}) @api.expect(post_parser) @user_multi_auth.login_required def post(self): + """ + Create / update test + """ return gettext('Test information update and creation must be done directly into a service (such as ' 'Test service)'), 501 @@ -131,6 +135,9 @@ def post(self): @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete test + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args(strict=True) id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUndelete.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUndelete.py index a28fd6e2..fbe09992 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUndelete.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUndelete.py @@ -21,11 +21,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'Required parameter is missing', 401: 'Requested item not found or is undeletable', 403: 'Access level insufficient to access that API or the item to undelete', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Undelete an item + """ if not current_user.user_superadmin: return gettext('No access to this API'), 403 diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserGroups.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserGroups.py index 500f4dfd..0a0e85bd 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserGroups.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserGroups.py @@ -1,9 +1,8 @@ -from flask import jsonify, session, request +from flask import jsonify, request from flask_restx import Resource, reqparse, inputs from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraServiceAccess import TeraServiceAccess from opentera.db.models.TeraServiceRole import TeraServiceRole from opentera.db.models.TeraUserGroup import TeraUserGroup @@ -72,11 +71,13 @@ def get_sites_roles_json(user_access, user_group_id: int): @api.doc(description='Get user group information. If no id specified, returns all accessible users groups', responses={200: 'Success', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get usergroup + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) @@ -121,11 +122,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified user group', 400: 'Badly formed JSON or missing field(id_user_group) in the JSON body', - 500: 'Internal error when saving user group'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving user group'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update usergroup + """ user_access = DBManager.userAccess(current_user) if 'user_group' not in request.json: @@ -274,11 +277,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete user group (only a site admin that includes that user group in ' 'their site can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete an usergroup + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserPreferences.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserPreferences.py index b8fec30a..b1da1ff6 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserPreferences.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserPreferences.py @@ -1,9 +1,8 @@ -from flask import session, request -from flask_restx import Resource, reqparse, inputs +from flask import request +from flask_restx import Resource from flask_babel import gettext from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraUserPreference import TeraUserPreference from sqlalchemy.exc import InvalidRequestError from sqlalchemy import exc @@ -31,11 +30,13 @@ def __init__(self, _api, *args, **kwargs): responses={200: 'Success - returns list of user preferences', 400: 'Missing parameter or bad app_tag', 403: 'Forbidden access to that user.', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get user preferences (for a specific app) + """ user_access = DBManager.userAccess(current_user) args = get_parser.parse_args() @@ -72,11 +73,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the user linked to that preference', 400: 'Badly formed JSON or missing fields(app_tag) in the JSON body', - 500: 'Internal error occurred when saving user preference'}, - params={'token': 'Secret token'}) + 500: 'Internal error occurred when saving user preference'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update user preferences + """ user_access = DBManager.userAccess(current_user) # Using request.json instead of parser, since parser messes up the json! json_user_pref = request.json['user_preference'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserUserGroups.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserUserGroups.py index 1f866078..cb17256f 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUserUserGroups.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUserUserGroups.py @@ -1,4 +1,4 @@ -from flask import jsonify, session, request +from flask import request from flask_restx import Resource, reqparse, inputs from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user @@ -35,11 +35,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get user - user group information. At least one "id" field must be specified', responses={200: 'Success', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get user usergroups + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) @@ -78,11 +80,13 @@ def get(self): responses={200: 'Success', 403: 'Logged user can\'t create/update the specified user group', 400: 'Badly formed JSON or missing field(id_user_group) in the JSON body', - 500: 'Internal error when saving user group'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving user group'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update user's usergroups + """ user_access = DBManager.userAccess(current_user) if not 'user_user_group' in request.json: @@ -143,11 +147,13 @@ def post(self): responses={200: 'Success', 403: 'Logged user can\'t delete user group (only a site admin that includes that user group in ' 'their site can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete a specific user-usergroup assocation + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py index 93ccf307..fa8a3c3b 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py @@ -45,11 +45,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get user information. If no id specified, returns all accessible users', responses={200: 'Success', - 500: 'Database error'}, - params={'token': 'Secret token'}) + 500: 'Database error'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get users + """ args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) @@ -147,14 +149,6 @@ def get(self): return jsonify(users_list) return [], 200 - # try: - # users = TeraUser.query_data(my_args) - # users_list = [] - # for user in users: - # users_list.append(user.to_json()) - # return jsonify(users_list) - # except InvalidRequestError: - # return '', 500 @api.doc(description='Create / update user. id_user must be set to "0" to create a new user. User can be modified ' 'if: current user is super admin or user is part of a project which the current user is admin.' @@ -165,11 +159,13 @@ def get(self): 400: 'Badly formed JSON or missing field(id_user or missing password when new user) in the ' 'JSON body', 409: 'Username is already taken', - 500: 'Internal error when saving user'}, - params={'token': 'Secret token'}) + 500: 'Internal error when saving user'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Create / update an user + """ user_access = DBManager.userAccess(current_user) if 'user' not in request.json: @@ -299,34 +295,18 @@ def post(self): update_user.commit() - # Check if there's some user groups for the updated user that we need to delete - # id_groups_to_delete = set([group.id_user_group for group in update_user.user_user_groups])\ - # .difference(user_user_groups_ids) - # - # for id_to_del in id_groups_to_delete: - # uug_to_del = TeraUserUserGroup.query_user_user_group_for_user_user_group(user_id=update_user.id_user, - # user_group_id=id_to_del) - # TeraUserUserGroup.delete(id_todel=uug_to_del.id_user_user_group) - # - # # Update / insert user groups - # for user_group in user_user_groups: - # if not TeraUserUserGroup.query_user_user_group_for_user_user_group(user_id=update_user.id_user, - # user_group_id= - # user_group['id_user_group']): - # # Group not already associated - associates! - # TeraUserUserGroup.insert_user_user_group(id_user_group=user_group['id_user_group'], - # id_user=update_user.id_user) - return [update_user.to_json()] @api.doc(description='Delete a specific user', responses={200: 'Success', 403: 'Logged user can\'t delete user (only super admin can delete)', - 500: 'Database error.'}, - params={'token': 'Secret token'}) + 500: 'Database error.'}) @api.expect(delete_parser) @user_multi_auth.login_required def delete(self): + """ + Delete an user + """ user_access = DBManager.userAccess(current_user) args = delete_parser.parse_args() id_todel = args['id'] diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryVersions.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryVersions.py index 12152247..cf88672f 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryVersions.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryVersions.py @@ -27,13 +27,15 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Get server versions', responses={200: 'Success - returns versions information', 400: 'Required parameter is missing', - 403: 'Logged user doesn\'t have permission to access the requested data'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have permission to access the requested data'}) @api.expect(get_parser) @user_multi_auth.login_required def get(self): + """ + Get server versions + """ # As soon as we are authorized, we can output the server versions - args = get_parser.parse_args() + # args = get_parser.parse_args() current_settings = TeraServerSettings.get_server_setting_value(TeraServerSettings.ServerVersions) if not current_settings: @@ -45,11 +47,13 @@ def get(self): responses={200: 'Success - asset posted', 500: 'Database error occurred', 403: 'Logged user doesn\'t have permission to delete the requested asset (must be an user of' - 'the related project)'}, - params={'token': 'Secret token'}) + 'the related project)'}) @api.expect(post_schema) @user_multi_auth.login_required def post(self): + """ + Update server versions + """ # Only superuser can change the versions settings # Only some fields can be changed. if current_user.user_superadmin: diff --git a/teraserver/python/modules/FlaskModule/API/user/UserRefreshToken.py b/teraserver/python/modules/FlaskModule/API/user/UserRefreshToken.py index 6edf0e94..e699365f 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserRefreshToken.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserRefreshToken.py @@ -1,6 +1,5 @@ from flask import session, request -from flask_restx import Resource, reqparse, inputs -from flask_babel import gettext +from flask_restx import Resource, inputs from modules.LoginModule.LoginModule import user_token_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.LoginModule.LoginModule import LoginModule @@ -23,11 +22,13 @@ def __init__(self, _api, *args, **kwargs): @api.doc(description='Refresh token, old token needs to be passed in request headers.', responses={200: 'Success', - 500: 'Server error'}, - params={'token': 'Secret token'}) + 500: 'Server error'}) @api.expect(get_parser) @user_token_auth.login_required def get(self): + """ + Refresh token for current user + """ # If we have made it this far, token passed in headers was valid. # Get user token key from redis token_key = self.module.redisGet(RedisVars.RedisVar_UserTokenAPIKey) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserSessionManager.py b/teraserver/python/modules/FlaskModule/API/user/UserSessionManager.py index ad9c498a..3f98bb4e 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserSessionManager.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserSessionManager.py @@ -1,8 +1,7 @@ -from flask import session, request +from flask import request from flask_restx import Resource from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraSession import TeraSession from flask_babel import gettext @@ -89,11 +88,13 @@ def __init__(self, _api, *args, **kwargs): 400: 'Required parameter is missing', 500: 'Internal server error', 501: 'Not implemented', - 403: 'Logged user doesn\'t have enough permission'}, - params={'token': 'Secret token'}) + 403: 'Logged user doesn\'t have enough permission'}) @api.expect(session_manager_schema) @user_multi_auth.login_required def post(self): + """ + Starts / stop a session related to a service + """ args = post_parser.parse_args() user_access = DBManager.userAccess(current_user) From fa528a7f42b97897b6e47a083cd9c44a4df4c58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Thu, 3 Oct 2024 15:42:54 -0400 Subject: [PATCH 46/67] Refs #253, Added tests for UserLoginSetup2FA API. --- .../API/user/test_UserLoginSetup2FA.py | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py new file mode 100644 index 00000000..793551c0 --- /dev/null +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py @@ -0,0 +1,186 @@ +import pyotp +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest +from opentera.db.models.TeraUser import TeraUser + + +class UserLoginSetup2FATest(BaseUserAPITest): + test_endpoint = '/api/user/login_setup_2fa' + + def setUp(self): + super().setUp() + # Create users with 2fa enabled + with self._flask_app.app_context(): + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) + + def tearDown(self): + # Delete users with 2fa enabled + with self._flask_app.app_context(): + TeraUser.delete(self.user1['id_user'], hard_delete=True) + TeraUser.delete(self.user2['id_user'], hard_delete=True) + super().tearDown() + + + def _create_2fa_enabled_user(self, username, password, set_secret:bool = True): + user = TeraUser() + user.id_user = 0 # New user + user.user_username = username + user.user_password = password + user.user_firstname = username + user.user_lastname = username + user.user_email = f"{username}@test.com" + user.user_enabled = True + user.user_profile = {} + if set_secret: + user.enable_2fa_otp() + else: + user.user_2fa_enabled = True + user.user_2fa_otp_enabled = False + user.user_2fa_otp_secret = None + + TeraUser.insert(user) + return user.to_json(minimal=False) + + + def _login_user(self, username, password): + response = self._get_with_user_http_auth(self.test_client, username, + password, endpoint='/api/user/login') + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + return response + + def test_get_endpoint_invalid_token_auth(self): + with self._flask_app.app_context(): + response = self._get_with_user_token_auth(self.test_client, 'invalid') + self.assertEqual(401, response.status_code) + + def test_get_endpoint_with_no_session(self): + with self._flask_app.app_context(): + response = self.test_client.get(self.test_endpoint) + self.assertEqual(401, response.status_code) + + def test_get_endpoint_with_admin_without_2fa_enabled(self): + with self._flask_app.app_context(): + user = TeraUser.get_user_by_username('admin') + self.assertIsNotNone(user) + self.assertFalse(user.user_2fa_enabled) + # Fist login + response = self._login_user('admin', 'admin') + self.assertEqual(200, response.status_code) + + # Now try to setup 2fa + response = self._get_with_user_http_auth(self.test_client, 'admin', 'admin', + endpoint=self.test_endpoint) + + self.assertEqual(403, response.status_code) + + def test_get_endpoint_login_user1_2fa_already_setup(self): + with self._flask_app.app_context(): + + # Fisrt login to create session + response = self._login_user('test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + + # Using default admin information, http auth not used + response = self._get_with_user_http_auth(self.test_client) + self.assertEqual(403, response.status_code) + + def test_get_endpoint_login_user2_http_auth_should_work_but_and_modify_user_after_post(self): + with self._flask_app.app_context(): + + # First login to create session + response = self._login_user('test_user_2fa_2', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_setup_2fa' in response.json['redirect_url']) + + # Then try to setup 2fa + response = self._get_with_user_http_auth(self.test_client) + self.assertEqual(200, response.status_code) + + self.assertTrue('qr_code' in response.json) + self.assertTrue('otp_secret' in response.json) + + user = TeraUser.get_user_by_username('test_user_2fa_2') + self.assertIsNotNone(user) + self.assertTrue(user.user_2fa_enabled) + self.assertFalse(user.user_2fa_otp_enabled) + self.assertIsNone(user.user_2fa_otp_secret) + + # Post will enable 2fa + otp_secret = response.json['otp_secret'] + otp_code = pyotp.TOTP(response.json['otp_secret']).now() + params = {'otp_secret': response.json['otp_secret'], 'otp_code': otp_code} + response = self.test_client.post(self.test_endpoint, json=params) + self.assertEqual(200, response.status_code) + + # Verify response + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + + # Reload user and verify 2fa is enabled properly + user = TeraUser.get_user_by_username('test_user_2fa_2') + self.assertIsNotNone(user) + self.assertTrue(user.user_2fa_enabled) + self.assertTrue(user.user_2fa_otp_enabled) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertEqual(otp_secret, user.user_2fa_otp_secret) + + def test_get_endpoint_login_user2_http_auth_should_fail_after_post_with_wrong_code(self): + with self._flask_app.app_context(): + + # First login to create session + response = self._login_user('test_user_2fa_2', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_setup_2fa' in response.json['redirect_url']) + + # Then try to setup 2fa + response = self._get_with_user_http_auth(self.test_client) + self.assertEqual(200, response.status_code) + + self.assertTrue('qr_code' in response.json) + self.assertTrue('otp_secret' in response.json) + + user = TeraUser.get_user_by_username('test_user_2fa_2') + self.assertIsNotNone(user) + self.assertTrue(user.user_2fa_enabled) + self.assertFalse(user.user_2fa_otp_enabled) + self.assertIsNone(user.user_2fa_otp_secret) + + # Post will fail with wrong code + params = {'otp_secret': response.json['otp_secret'], 'otp_code': '123456'} + response = self.test_client.post(self.test_endpoint, json=params) + self.assertEqual(401, response.status_code) + + def test_get_endpoint_login_user2_http_auth_should_fail_after_post_with_wrong_secret(self): + with self._flask_app.app_context(): + + # First login to create session + response = self._login_user('test_user_2fa_2', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_setup_2fa' in response.json['redirect_url']) + + # Then try to setup 2fa + response = self._get_with_user_http_auth(self.test_client) + self.assertEqual(200, response.status_code) + + self.assertTrue('qr_code' in response.json) + self.assertTrue('otp_secret' in response.json) + + user = TeraUser.get_user_by_username('test_user_2fa_2') + self.assertIsNotNone(user) + self.assertTrue(user.user_2fa_enabled) + self.assertFalse(user.user_2fa_otp_enabled) + self.assertIsNone(user.user_2fa_otp_secret) + + # Post will fail with wrong secret + otp_secret = pyotp.random_base32() + otp_code = pyotp.TOTP(otp_secret).now() + params = {'otp_secret': response.json['otp_secret'], 'otp_code': otp_code} + response = self.test_client.post(self.test_endpoint, json=params) + self.assertEqual(401, response.status_code) From b6d4d61077e7adc8404460926d525048df6d2463 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Fri, 4 Oct 2024 09:59:57 -0400 Subject: [PATCH 47/67] Refs #253, Added tests for 2FA configuration on login. --- .../FlaskModule/API/user/test_UserLogin.py | 65 +++++++++++++++++++ 1 file changed, 65 insertions(+) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py index 019f4391..04c736f2 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py @@ -5,6 +5,41 @@ class UserLoginTest(BaseUserAPITest): test_endpoint = '/api/user/login' + def setUp(self): + super().setUp() + # Create users with 2fa enabled + with self._flask_app.app_context(): + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) + + def tearDown(self): + # Delete users with 2fa enabled + with self._flask_app.app_context(): + TeraUser.delete(self.user1['id_user'], hard_delete=True) + TeraUser.delete(self.user2['id_user'], hard_delete=True) + super().tearDown() + + def _create_2fa_enabled_user(self, username, password, set_secret:bool = True): + user = TeraUser() + user.id_user = 0 # New user + user.user_username = username + user.user_password = password + user.user_firstname = username + user.user_lastname = username + user.user_email = f"{username}@test.com" + user.user_enabled = True + user.user_profile = {} + if set_secret: + user.enable_2fa_otp() + else: + user.user_2fa_enabled = True + user.user_2fa_otp_enabled = False + user.user_2fa_otp_secret = None + + TeraUser.insert(user) + return user.to_json(minimal=False) + + def test_get_endpoint_no_auth(self): with self._flask_app.app_context(): response = self.test_client.get(self.test_endpoint) @@ -62,3 +97,33 @@ def test_get_endpoint_login_admin_user_http_auth_then_token_auth(self): # Not allowed for this endpoint response = self._get_with_user_token_auth(self.test_client, token=token) self.assertEqual(401, response.status_code) + + def test_get_endpoint_login_user1_2fa_already_setup(self): + with self._flask_app.app_context(): + + # Login should redirect to 2fa verification + response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_1', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertFalse('login_setup_2fa' in response.json['redirect_url']) + self.assertTrue('login_validate_2fa' in response.json['redirect_url']) + + # Answer should not provide login information + self.assertFalse('websocket_url' in response.json) + self.assertFalse('user_uuid' in response.json) + self.assertFalse('user_token' in response.json) + + def test_get_endpoint_login_user2_2fa_not_setup(self): + with self._flask_app.app_context(): + + # Login should redirect to 2fa verification + response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_2', 'password') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_setup_2fa' in response.json['redirect_url']) + self.assertFalse('login_validate_2fa' in response.json['redirect_url']) + + # Answer should not provide login information + self.assertFalse('websocket_url' in response.json) + self.assertFalse('user_uuid' in response.json) + self.assertFalse('user_token' in response.json) From b4f4fafba8db762c00c84280dca0c1fd0939549a Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Fri, 4 Oct 2024 11:43:37 -0400 Subject: [PATCH 48/67] Refs #253. Completed password change view. Added password strength requirements on TeraUser. --- .../modules/FlaskModule/API/user/UserLogin.py | 8 +- .../FlaskModule/API/user/UserLoginBase.py | 3 + .../FlaskModule/API/user/UserQueryUsers.py | 27 +++- .../python/modules/FlaskModule/FlaskUtils.py | 23 ++++ .../Views/LoginChangePasswordView.py | 32 +++-- .../opentera/db/models/TeraSessionType.py | 2 +- .../python/opentera/db/models/TeraUser.py | 52 +++++++- teraserver/python/templates/login.html | 3 + .../templates/login_change_password.html | 119 +++++++++++++++--- .../FlaskModule/API/user/test_UserLogin2FA.py | 20 +-- .../API/user/test_UserQueryUsers.py | 88 ++++++++++++- 11 files changed, 333 insertions(+), 44 deletions(-) create mode 100644 teraserver/python/modules/FlaskModule/FlaskUtils.py diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py index d7f6e597..d2a4876d 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py @@ -36,8 +36,14 @@ def _common_login_response(self, parser): if version_info: response.update(version_info) + # User needs to change password? + if current_user.user_force_password_change: + response['message'] = gettext('Password change required for this user.') + response['reason'] = 'password_change' + response['redirect_url'] = self._generate_password_change_url() + # 2FA enabled? Client will need to proceed to 2FA login step first - if current_user.user_2fa_enabled: + if current_user.user_2fa_enabled and not current_user.user_force_password_change: # If user had too many 2FA login failures, stop login process self._verify_2fa_login_attempts(current_user.user_uuid) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py index 8a6fbc60..3984abf4 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginBase.py @@ -193,6 +193,9 @@ def _generate_2fa_setup_url(self) -> str: def _generate_login_url(self) -> str: return "/login" + def _generate_password_change_url(self) -> str: + return "/login_change_password" + def _user_logout(self): logout_user() session.clear() diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py index fa8a3c3b..72bb4084 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py @@ -3,13 +3,15 @@ from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser +from opentera.db.models.TeraUser import TeraUser, UserPasswordInsecure from opentera.db.models.TeraUserGroup import TeraUserGroup from flask_babel import gettext from modules.DatabaseModule.DBManager import DBManager from opentera.redis.RedisRPCClient import RedisRPCClient from opentera.modules.BaseModule import ModuleNames +from modules.FlaskModule.FlaskUtils import FlaskUtils + # Parser definition(s) get_parser = api.parser() get_parser.add_argument('id', type=int, help='ID of the user to query') @@ -43,6 +45,23 @@ def __init__(self, _api, *args, **kwargs): self.module = kwargs.get('flaskModule', None) self.test = kwargs.get('test', False) + @staticmethod + def get_password_weaknesses_text(weaknesses: list) -> str: + text_list = [] + for weakness in weaknesses: + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_SPECIAL: + text_list.append(gettext('Password missing special character')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_NUMERIC: + text_list.append(gettext('Password missing numeric character')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.BAD_LENGTH: + text_list.append(gettext('Password not long enough')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_LOWER_CASE: + text_list.append(gettext('Password missing lower case letter')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_UPPER_CASE: + text_list.append(gettext('Password missing upper case letter')) + + return ",".join(text for text in text_list) + @api.doc(description='Get user information. If no id specified, returns all accessible users', responses={200: 'Success', 500: 'Database error'}) @@ -234,6 +253,9 @@ def post(self): UserQueryUsers.__name__, 'post', 500, 'Database error', str(e)) return gettext('Database error'), 500 + except UserPasswordInsecure as e: + return (gettext('Password not strong enough') + ': ' + + FlaskUtils.get_password_weaknesses_text(e.weaknesses), 400) else: # New user, check if password is set # if 'user_password' not in json_user: @@ -266,6 +288,9 @@ def post(self): UserQueryUsers.__name__, 'post', 500, 'Database error', str(e)) return gettext('Database error'), 500 + except UserPasswordInsecure as e: + return (gettext('Password not strong enough') + ': ' + + FlaskUtils.get_password_weaknesses_text(e.weaknesses), 400) update_user = TeraUser.get_user_by_id(json_user['id_user']) diff --git a/teraserver/python/modules/FlaskModule/FlaskUtils.py b/teraserver/python/modules/FlaskModule/FlaskUtils.py new file mode 100644 index 00000000..42e0be40 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/FlaskUtils.py @@ -0,0 +1,23 @@ +from flask_babel import gettext +from opentera.db.models.TeraUser import UserPasswordInsecure + + +class FlaskUtils: + + @staticmethod + def get_password_weaknesses_text(weaknesses: list, separator=',') -> str: + from flask_babel import gettext + text_list = [] + for weakness in weaknesses: + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_SPECIAL: + text_list.append(gettext('Password missing special character')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_NUMERIC: + text_list.append(gettext('Password missing numeric character')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.BAD_LENGTH: + text_list.append(gettext('Password not long enough (10 characters)')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_LOWER_CASE: + text_list.append(gettext('Password missing lower case letter')) + if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_UPPER_CASE: + text_list.append(gettext('Password missing upper case letter')) + + return separator.join(text for text in text_list) \ No newline at end of file diff --git a/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py b/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py index b8f305d5..13893422 100644 --- a/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py +++ b/teraserver/python/modules/FlaskModule/Views/LoginChangePasswordView.py @@ -1,11 +1,11 @@ from flask.views import MethodView from flask import render_template, request, redirect, url_for -from flask_login import login_user, logout_user +from flask_login import logout_user from opentera.utils.TeraVersions import TeraVersions -from opentera.db.models.TeraUser import TeraUser -from modules.LoginModule.LoginModule import LoginModule +from opentera.db.models.TeraUser import TeraUser, UserPasswordInsecure from flask_babel import gettext from modules.LoginModule.LoginModule import current_user, LoginModule +from modules.FlaskModule.FlaskUtils import FlaskUtils class LoginChangePasswordView(MethodView): @@ -34,12 +34,11 @@ def get(self): def post(self): # Verify if form is complete - if ('old_password' not in request.form or 'new_password' not in request.form - or 'confirm_password' not in request.form): + if 'new_password' not in request.form or 'confirm_password' not in request.form: return gettext('Missing information'), 400 # Get form information - old_password = request.form['old_password'] + # old_password = request.form['old_password'] new_password = request.form['new_password'] confirm_password = request.form['confirm_password'] @@ -48,16 +47,25 @@ def post(self): logout_user() return gettext('New password and confirm password do not match'), 400 + # Validate that new password is different from current + if TeraUser.verify_password(current_user.user_username, new_password) is not None: + # logout_user() + return gettext('New password must be different from current'), 400 + + # Validate if old password is correct - if TeraUser.verify_password(current_user.user_username, old_password) is None: - logout_user() - return gettext('Invalid old password'), 400 + # if TeraUser.verify_password(current_user.user_username, old_password) is None: + # logout_user() + # return gettext('Invalid old password'), 400 # Change password, will be encrypted # Will also reset force password change flag - TeraUser.update(current_user.id_user, {'user_password': new_password, - 'user_force_password_change': False }) + try: + TeraUser.update(current_user.id_user, {'user_password': new_password, + 'user_force_password_change': False }) + except UserPasswordInsecure as e: + return FlaskUtils.get_password_weaknesses_text(e.weaknesses, '
'), 400 - logout_user() + # logout_user() return redirect(url_for('login')) diff --git a/teraserver/python/opentera/db/models/TeraSessionType.py b/teraserver/python/opentera/db/models/TeraSessionType.py index b48e8769..02ef5599 100644 --- a/teraserver/python/opentera/db/models/TeraSessionType.py +++ b/teraserver/python/opentera/db/models/TeraSessionType.py @@ -1,7 +1,7 @@ from opentera.db.Base import BaseModel from opentera.db.SoftDeleteMixin import SoftDeleteMixin from opentera.db.models.TeraSession import TeraSession -from sqlalchemy import Column, ForeignKey, Integer, String, Sequence, Boolean, TIMESTAMP +from sqlalchemy import Column, ForeignKey, Integer, String, Sequence, Boolean from sqlalchemy.orm import relationship from sqlalchemy.exc import IntegrityError from enum import Enum, unique diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index a62879a5..aa640e61 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -13,11 +13,13 @@ from passlib.hash import bcrypt +from enum import Enum, unique import uuid import datetime import json import time import jwt +import re import pyotp @@ -328,6 +330,11 @@ def update(cls, id_user: int, values: dict): if values['user_password'] == '': del values['user_password'] else: + # Check password strength + password_errors = TeraUser.validate_password_strength(str(values['user_password'])) + if len(password_errors) > 0: + raise UserPasswordInsecure("User password insufficient strength", password_errors) + # Forcing password to string values['user_password'] = TeraUser.encrypt_password(str(values['user_password'])) @@ -344,8 +351,12 @@ def update(cls, id_user: int, values: dict): @classmethod def insert(cls, user): + # Check password strength + password_errors = TeraUser.validate_password_strength(str(user.user_password)) + if len(password_errors) > 0: + raise UserPasswordInsecure("User password insufficient strength", password_errors) + # Encrypts password - # Forcing password to string user.user_password = TeraUser.encrypt_password(str(user.user_password)) # Generate UUID @@ -375,6 +386,27 @@ def delete_check_integrity(self, with_deleted: bool = False) -> IntegrityError | return None + @staticmethod + def validate_password_strength(password: str) -> list: + errors = [] + + if len(password) < 10: + errors.append(UserPasswordInsecure.PasswordWeaknesses.BAD_LENGTH) + + if re.search(r"\d", password) is None: + errors.append(UserPasswordInsecure.PasswordWeaknesses.NO_NUMERIC) + + if re.search(r"[A-Z]", password) is None: + errors.append(UserPasswordInsecure.PasswordWeaknesses.NO_UPPER_CASE) + + if re.search(r"[a-z]", password) is None: + errors.append(UserPasswordInsecure.PasswordWeaknesses.NO_LOWER_CASE) + + if re.search(r"[ !#$%&'()*+,-./[\\\]^_`{|}~"+r'"]', password) is None: + errors.append(UserPasswordInsecure.PasswordWeaknesses.NO_SPECIAL) + + return errors + @staticmethod def create_defaults(test=False): # Admin @@ -459,3 +491,21 @@ def create_defaults(test=False): def get_undelete_cascade_relations(self) -> list: return ['user_service_config'] + + +class UserPasswordInsecure(Exception): + """ + Raised when the user password doesn't meet minimal requirements + """ + + @unique + class PasswordWeaknesses(Enum): + BAD_LENGTH = 1 + NO_LOWER_CASE = 2 + NO_UPPER_CASE = 3 + NO_NUMERIC = 4 + NO_SPECIAL = 5 + + def __init__(self, message, weaknesses: list): + super().__init__(message) + self.weaknesses = weaknesses \ No newline at end of file diff --git a/teraserver/python/templates/login.html b/teraserver/python/templates/login.html index abb24ed5..48a76954 100644 --- a/teraserver/python/templates/login.html +++ b/teraserver/python/templates/login.html @@ -71,6 +71,9 @@ if (response.reason === '2fa_setup'){ qtObject.sendSetupInProgress(); } + if (response.reason === 'password_change'){ + qtObject.sendPasswordChangeInProgress(); + } } } window.location.href = response.redirect_url; diff --git a/teraserver/python/templates/login_change_password.html b/teraserver/python/templates/login_change_password.html index 1777b163..15fc1daf 100644 --- a/teraserver/python/templates/login_change_password.html +++ b/teraserver/python/templates/login_change_password.html @@ -2,42 +2,127 @@ - OpenTera Login Change Password + {{ gettext("OpenTera - Change Password") }} + - + + + + +
-
Change password for user: {{ username }}
+
+
+ {{ gettext('Password successfully changed!') }}
+ {{ gettext('Redirecting to login screen...') }} +
+
+
+ +
+
+
{{ gettext('Password change required') }} - {{ username }}
+
5:00
+
-
-
- -
- -
- - + +
+
- +
- -
+ +
-
- +
+
+ +
+
+
+
diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index 98d600cf..1827c518 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -10,8 +10,8 @@ def setUp(self): super().setUp() # Create users with 2fa enabled with self._flask_app.app_context(): - self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) - self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'Password12345!', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'Password12345!', set_secret=False) def tearDown(self): # Delete users with 2fa enabled @@ -83,7 +83,7 @@ def test_get_endpoint_login_user1_http_auth_no_code(self): with self._flask_app.app_context(): # Fisrt login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -96,7 +96,7 @@ def test_get_endpoint_login_user1_http_auth_invalid_code(self): with self._flask_app.app_context(): # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -112,7 +112,7 @@ def test_get_endpoint_login_user1_http_auth_valid_code(self): self.assertIsNotNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -133,7 +133,7 @@ def test_get_endpoint_login_user1_http_auth_valid_code_with_websocket(self): self.assertIsNotNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -155,7 +155,7 @@ def test_get_endpoint_login_user2_http_auth_invalid_code(self): self.assertIsNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_2', 'password') + response = self._login_user('test_user_2fa_2', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertFalse('login_validate_2fa' in response.json['redirect_url']) @@ -172,7 +172,7 @@ def test_get_endpoint_login_user1_http_auth_valid_code_unknown_app_name(self): self.assertIsNotNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -194,7 +194,7 @@ def test_get_endpoint_login_user1_http_auth_valid_code_outdated_app(self): self.assertIsNotNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) @@ -214,7 +214,7 @@ def test_get_endpoint_login_user1_http_auth_valid_code_valid_app(self): self.assertIsNotNone(user.user_2fa_otp_secret) # First login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_validate_2fa' in response.json['redirect_url']) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py index 255be07d..48ee5ff8 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py @@ -333,6 +333,92 @@ def test_query_self(self): self.assertTrue(data_item.__contains__('projects')) self.assertTrue(data_item.__contains__('sites')) + def test_password_strength(self): + with self._flask_app.app_context(): + json_data = { + 'user': { + 'id_user': 0, + 'user_username': 'new_test_user', + 'user_enabled': True, + 'user_firstname': 'Test', + 'user_lastname': 'Test', + 'user_profile': '', + 'user_user_groups': [{'id_user_group': 3}], + 'user_password': 'password' + } + } + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password not long enough") + + json_data['user']['user_password'] = 'password12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without capital letters") + + json_data['user']['user_password'] = 'PASSWORD12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without lower case letters") + + json_data['user']['user_password'] = 'Password12345' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without special characters") + + json_data['user']['user_password'] = 'Password!!!!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without numbers") + + json_data['user']['user_password'] = 'Password12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(200, response.status_code, msg="Password OK") + self.assertGreater(len(response.json), 0) + json_data = response.json[0] + self._checkJson(json_data) + current_id = json_data['id_user'] + + # Modify password + json_data = { + 'user': { + 'id_user': current_id, + 'user_password': 'password' + } + } + + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password not long enough") + + json_data['user']['user_password'] = 'password12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without capital letters") + + json_data['user']['user_password'] = 'PASSWORD12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without lower case letters") + + json_data['user']['user_password'] = 'Password12345' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without special characters") + + json_data['user']['user_password'] = 'Password!!!!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password without numbers") + + json_data['user']['user_password'] = 'Password12345!' + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(200, response.status_code, msg="Password OK") + + TeraUser.delete(current_id) + def test_post_and_delete(self): with self._flask_app.app_context(): json_data = { @@ -376,7 +462,7 @@ def test_post_and_delete(self): json=json_data) self.assertEqual(400, response.status_code, msg="Invalid password") - json_data['user']['user_password'] = 'testtest' + json_data['user']['user_password'] = 'testTest11*&' response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', json=json_data) self.assertEqual(response.status_code, 409, msg="Username unavailable") From 317d66c29ced5dddf3dab875623e6ce45a709704 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Fri, 4 Oct 2024 14:53:24 -0400 Subject: [PATCH 49/67] Refs #253, Reimplementation if after_update, after_insert events not using relationships. --- .../modules/DatabaseModule/DBManager.py | 61 +++++++------- .../tests/opentera/db/models/test_TeraSite.py | 79 ++++++++++++++++++- 2 files changed, 111 insertions(+), 29 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 6e1c612a..e824ad11 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -1,6 +1,6 @@ from flask_sqlalchemy import SQLAlchemy from sqlalchemy.orm import joinedload -from sqlalchemy import event, inspect, update +from sqlalchemy import event, inspect, update, select from sqlalchemy.engine import Engine from sqlalchemy.engine.reflection import Inspector from sqlite3 import Connection as SQLite3Connection @@ -149,40 +149,47 @@ def user_group_updated_or_inserted(mapper, connection, target: TeraUserGroup): @event.listens_for(TeraUserUserGroup, 'after_update') @event.listens_for(TeraUserUserGroup, 'after_insert') def user_user_group_updated_or_inserted(mapper, connection, target: TeraUserUserGroup): - # Check if 2FA is enabled for a related site - if target and target.user_user_group_user_group and target.user_user_group_user_group.user_group_services_roles: - for role in target.user_user_group_user_group.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: + # If the user in the usergroup has access to a site with 2FA enabled, enable 2FA for the user + if target: + sites = TeraServiceAccess.query.join(TeraServiceRole, TeraServiceAccess.id_service_role == + TeraServiceRole.id_service_role) \ + .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ + .filter(TeraServiceAccess.id_user_group == target.id_user_group) \ + .with_entities(TeraSite).all() # Return the site information only + + for site in sites: + if site.site_2fa_required: # Perform single update for user connection.execute( update(TeraUser) - .where(TeraUser.id_user == target.user_user_group_user.id_user) + .where(TeraUser.id_user == target.id_user) .values(user_2fa_enabled=True) - ) + ) + break @event.listens_for(TeraUser, 'after_update') @event.listens_for(TeraUser, 'after_insert') def user_updated_or_inserted(mapper, connection, target: TeraUser): - # Check if 2FA is enabled for a related site - if target and target.user_user_groups: - for group in target.user_user_groups: - if group.user_group_services_roles: - for role in group.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: - - otp_enabled = target.user_2fa_otp_enabled - - # Do not allow to change 2FA status if user has 2FA enabled - # and OTP set with secret - if target.user_2fa_otp_secret: - otp_enabled = True - - # Perform single update for user - connection.execute( - update(TeraUser) - .where(TeraUser.id_user == target.id_user) - .values(user_2fa_enabled=True, user_2fa_otp_enabled=otp_enabled) - ) + # Check if 2FA is enabled for a related site through user groups + if target: + sites = TeraServiceAccess.query.join(TeraUserUserGroup, TeraServiceAccess.id_user_group == TeraUserUserGroup.id_user_group) \ + .join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ + .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ + .filter(TeraUserUserGroup.id_user == target.id_user) \ + .with_entities(TeraSite).all() # Return the site information only + + + for site in sites: + if site.site_2fa_required: + # Perform single update for user + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == target.id_user) + .values(user_2fa_enabled=True) + ) + break + + def setup_events_for_class(self, cls, event_name): import json diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index d5921588..865efc7d 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -10,7 +10,10 @@ from opentera.db.models.TeraSessionTypeSite import TeraSessionTypeSite from opentera.db.models.TeraTestTypeSite import TeraTestTypeSite from opentera.db.models.TeraDevice import TeraDevice - +from opentera.db.models.TeraUser import TeraUser +from opentera.db.models.TeraUserGroup import TeraUserGroup +from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup +from opentera.db.models.TeraServiceAccess import TeraServiceAccess class TeraSiteTest(BaseModelsTest): @@ -233,9 +236,81 @@ def test_undelete(self): self.assertIsNotNone(TeraSessionTypeSite.get_session_type_site_by_id(id_session_type)) self.assertIsNotNone(TeraTestTypeSite.get_test_type_site_by_id(id_test_type)) + def test_2fa_required_site(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertTrue(site.site_2fa_required) + self.db.session.add(site) + self.db.session.commit() + id_site = site.id_site + self.db.session.rollback() + same_site = TeraSite.get_site_by_id(id_site) + self.assertTrue(same_site.site_2fa_required) + + def test_enable_2fa_in_site_should_enable_in_users(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertIsNotNone(site) + group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) + self.assertIsNotNone(group) + user = TeraSiteTest.new_test_user('test_user', 'password', group.id_user_group) + self.assertIsNotNone(user) + + # User should be updated automatically with 2fa + self.assertTrue(user.user_2fa_enabled) + + + + + @staticmethod - def new_test_site(name: str = 'Test Site') -> TeraSite: + def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> TeraSite: site = TeraSite() site.site_name = name + site.site_2fa_required = site_2fa_required TeraSite.insert(site) return site + + @staticmethod + def new_test_user_group(name: str, id_site: int ) -> TeraUserGroup: + + # Create Service Role first + service_role = TeraServiceRole() + service_role.service_role_name = 'Test Site Role' + service_role.id_service = 1 # TeraServer by default + service_role.id_site = id_site + TeraServiceRole.insert(service_role) + + # Create User Group + group: TeraUserGroup = TeraUserGroup() + group.user_group_name = name + TeraUserGroup.insert(group) + + # Update Service Access + service_access = TeraServiceAccess() + service_access.id_service_role = service_role.id_service_role + service_access.id_user_group = group.id_user_group + TeraServiceAccess.insert(service_access) + + return group + + + @staticmethod + def new_test_user(username: str, password: str, id_user_group: int) -> TeraUser: + user = TeraUser() + user.user_username = username + user.user_password = password + user.user_firstname = username + user.user_lastname = username + user.user_email = f"{username}@test.com" + user.user_enabled = True + user.user_profile = {} + TeraUser.insert(user) + + # Update user group + user_user_group = TeraUserUserGroup() + user_user_group.id_user = user.id_user + user_user_group.id_user_group = id_user_group + TeraUserUserGroup.insert(user_user_group) + + return user From b7a8f8d2568cbf3517522bc50748422cf79beb3d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Fri, 4 Oct 2024 16:13:38 -0400 Subject: [PATCH 50/67] Refs #253, Reimplementation if after_update, after_insert events not using relationships. To be validated. --- .../modules/DatabaseModule/DBManager.py | 65 +++++++++---------- .../tests/opentera/db/models/test_TeraSite.py | 8 ++- 2 files changed, 38 insertions(+), 35 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index e824ad11..1bb9e14a 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -104,47 +104,44 @@ def setup_events_for_2fa_sites(self): def site_updated_or_inserted(mapper, connection, target: TeraSite): # Check if 2FA is enabled for this site if target and target.site_2fa_required: - # Efficiently load all related users with joinedload - service_roles = TeraServiceRole.query.options( - joinedload(TeraServiceRole.service_role_user_groups).joinedload( - TeraUserGroup.user_group_users - ) - ).filter(TeraServiceRole.id_site == target.id_site).all() - - # Get all users - user_ids = set() - for role in service_roles: - if role.service_role_user_groups: - for group in role.service_role_user_groups: - for user in group.user_group_users: - user_ids.add(user.id_user) - - # Perform a bulk update for all users at once - if user_ids: + # Get all users that have access to this site + users = TeraServiceAccess.query.join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ + .join(TeraUserUserGroup, TeraServiceAccess.id_user_group == TeraUserUserGroup.id_user_group) \ + .join(TeraUser, TeraUserUserGroup.id_user == TeraUser.id_user) \ + .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ + .filter(TeraSite.id_site == target.id_site) \ + .with_entities(TeraUser).all() # Return the user information only + + # Enable 2FA for all users found + for user in users: connection.execute( update(TeraUser) - .where(TeraUser.id_user.in_(user_ids)) + .where(TeraUser.id_user == user.id_user) .values(user_2fa_enabled=True) ) + @event.listens_for(TeraUserGroup, 'after_update') @event.listens_for(TeraUserGroup, 'after_insert') def user_group_updated_or_inserted(mapper, connection, target: TeraUserGroup): - # Check if 2FA is enabled for a related site - if target and target.user_group_services_roles: - for role in target.user_group_services_roles: - if role.id_site and role.service_role_site.site_2fa_required: - # Efficiently load all related users with joinedload - user_ids = set() - for user in target.user_group_users: - user_ids.add(user.id_user) - - # Perform a bulk update for all users at once - if user_ids: - connection.execute( - update(TeraUser) - .where(TeraUser.id_user.in_(user_ids)) - .values(user_2fa_enabled=True) - ) + + # Check if 2FA is enabled for a related site in a single sql query + if target: + # Get users from the group that have access to a site with 2FA enabled + users = TeraUser.query.join(TeraUserUserGroup, TeraUser.id_user == TeraUserUserGroup.id_user) \ + .join(TeraServiceAccess, TeraUserUserGroup.id_user_group == TeraServiceAccess.id_user_group) \ + .join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ + .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ + .filter(TeraUserUserGroup.id_user_group == target.id_user_group) \ + .filter(TeraSite.site_2fa_required == True) \ + .with_entities(TeraUser).all() # Return the user information only + + # Enable 2FA for all users found + for user in users: + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == user.id_user) + .values(user_2fa_enabled=True) + ) @event.listens_for(TeraUserUserGroup, 'after_update') @event.listens_for(TeraUserUserGroup, 'after_insert') diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index 865efc7d..d5c5857e 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -249,13 +249,18 @@ def test_2fa_required_site(self): def test_enable_2fa_in_site_should_enable_in_users(self): with self._flask_app.app_context(): - site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=False) self.assertIsNotNone(site) group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) self.assertIsNotNone(group) user = TeraSiteTest.new_test_user('test_user', 'password', group.id_user_group) self.assertIsNotNone(user) + # Enable 2fa in site + site.site_2fa_required = True + self.db.session.add(site) + self.db.session.commit() + # User should be updated automatically with 2fa self.assertTrue(user.user_2fa_enabled) @@ -263,6 +268,7 @@ def test_enable_2fa_in_site_should_enable_in_users(self): + @staticmethod def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> TeraSite: site = TeraSite() From a97650f08889b298ebb67051e4011e364a1a6d8d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 09:50:57 -0400 Subject: [PATCH 51/67] Refs #253, cleanup DBManager, fix tests. --- .../modules/DatabaseModule/DBManager.py | 127 +++--------------- .../tests/opentera/db/models/test_TeraSite.py | 2 +- 2 files changed, 22 insertions(+), 107 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 1bb9e14a..3b4e6fe0 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -1,12 +1,12 @@ +from sqlite3 import Connection as SQLite3Connection +import datetime +import json + from flask_sqlalchemy import SQLAlchemy -from sqlalchemy.orm import joinedload -from sqlalchemy import event, inspect, update, select +from sqlalchemy import event, inspect, update from sqlalchemy.engine import Engine from sqlalchemy.engine.reflection import Inspector -from sqlite3 import Connection as SQLite3Connection - from twisted.internet import task, reactor -import datetime import opentera.messages.python as messages # Must include all Database objects here to be properly initialized and created if needed @@ -44,8 +44,10 @@ from opentera.db.models.TeraTestTypeSite import TeraTestTypeSite from opentera.db.models.TeraTestTypeProject import TeraTestTypeProject from opentera.db.models.TeraTest import TeraTest -from opentera.db.models.TeraSessionDevices import TeraSessionDevices +# from opentera.db.models.TeraSessionDevices import TeraSessionDevices from opentera.db.Base import BaseModel +from opentera.db.models import EventNameClassMap + from opentera.config.ConfigManager import ConfigManager from modules.FlaskModule.FlaskModule import flask_app @@ -55,7 +57,6 @@ from modules.DatabaseModule.DBManagerTeraDeviceAccess import DBManagerTeraDeviceAccess from modules.DatabaseModule.DBManagerTeraParticipantAccess import DBManagerTeraParticipantAccess from modules.DatabaseModule.DBManagerTeraServiceAccess import DBManagerTeraServiceAccess - # Alembic from alembic.config import Config from alembic import command @@ -132,7 +133,7 @@ def user_group_updated_or_inserted(mapper, connection, target: TeraUserGroup): .join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ .filter(TeraUserUserGroup.id_user_group == target.id_user_group) \ - .filter(TeraSite.site_2fa_required == True) \ + .filter(TeraSite.site_2fa_required == bool(True)) \ .with_entities(TeraUser).all() # Return the user information only # Enable 2FA for all users found @@ -187,18 +188,17 @@ def user_updated_or_inserted(mapper, connection, target: TeraUser): break - def setup_events_for_class(self, cls, event_name): - import json + """ + Setup events for a specific class. This will allow to send events through redis when a specific + event occurs on a specific class. This is useful to trace changes in the database. + The list of classes that produce events is defined in the EventNameClassMap in opentera/db/models/__init__.py. + :param cls: Class to setup events for + :param event_name: Name of the event + """ @event.listens_for(cls, 'after_update') def base_model_updated(mapper, connection, target): - # Handle soft deletion - # if getattr(target, 'soft_delete', None): - # if target.deleted_at: - # # Updated target with a deleted date - trigger the deleted handler instead - # base_model_deleted(mapper, connection, target) - # return json_update_event = target.to_json_update_event() if json_update_event: database_event = messages.DatabaseEvent() @@ -218,7 +218,6 @@ def base_model_updated(mapper, connection, target): # Send the event before we delete, so we can trace it... @event.listens_for(cls, 'after_delete') def base_model_deleted(mapper, connection, target): - # print(mapper, connection, target, event_name) json_delete_event = target.to_json_delete_event() if json_delete_event: database_event = messages.DatabaseEvent() @@ -237,7 +236,6 @@ def base_model_deleted(mapper, connection, target): @event.listens_for(cls, 'after_insert') def base_model_inserted(mapper, connection, target): - # print(mapper, connection, target, event_name) json_create_event = target.to_json_create_event() if json_create_event: database_event = messages.DatabaseEvent() @@ -371,12 +369,11 @@ def create_defaults(self, config: ConfigManager, test=False): TeraTest.create_defaults(test) def setup_events(self): - # TODO Add events that need to be sent through redis - # TODO Useful to specify event name, always get_model_name() ? - - from opentera.db.models import EventNameClassMap - for name in EventNameClassMap: - self.setup_events_for_class(EventNameClassMap[name], name) + """ + Called after the database is opened. This will setup events for all classes that need to be monitored. + """ + for event_name, model_class in EventNameClassMap.items(): + self.setup_events_for_class(model_class, event_name) # Setup events for 2FA sites self.setup_events_for_2fa_sites() @@ -530,88 +527,6 @@ def _set_sqlite_pragma(dbapi_connection, connection_record): cursor.execute("PRAGMA foreign_keys=ON;") cursor.close() -# @event.listens_for(db.session, 'after_flush') -# def receive_after_flush(session, flush_context): -# from modules.Globals import db_man -# import json -# -# if db_man: -# events = list() -# # Updated objects -# for obj in session.dirty: -# # json_update_event = obj.to_json_update_event() -# # if json_update_event: -# # database_event = messages.DatabaseEvent() -# # database_event.type = messages.DatabaseEvent.DB_UPDATE -# # database_event.object_type = str(obj.get_model_name()) -# # database_event.object_value = json.dumps(json_update_event) -# # events.append(database_event) -# -# if isinstance(obj, TeraUser): -# new_event = messages.UserEvent() -# new_event.user_uuid = str(obj.user_uuid) -# new_event.type = messages.UserEvent.USER_UPDATED -# events.append(new_event) -# -# # Inserted objects -# for obj in session.new: -# # database_event = messages.DatabaseEvent() -# # database_event.type = messages.DatabaseEvent.DB_CREATE -# # database_event.object_type = str(obj.get_model_name()) -# # database_event.object_value = json.dumps(obj.to_json()) -# # events.append(database_event) -# -# if isinstance(obj, TeraUser): -# new_event = messages.UserEvent() -# new_event.user_uuid = str(obj.user_uuid) -# new_event.type = messages.UserEvent.USER_ADDED -# events.append(new_event) -# -# # Deleted objects -# for obj in session.deleted: -# # database_event = messages.DatabaseEvent() -# # database_event.type = messages.DatabaseEvent.DB_DELETE -# # database_event.object_type = str(obj.get_model_name()) -# # database_event.object_value = json.dumps(obj.to_json()) -# # events.append(database_event) -# -# if isinstance(obj, TeraUser): -# new_event = messages.UserEvent() -# new_event.user_uuid = str(obj.user_uuid) -# new_event.type = messages.UserEvent.USER_DELETED -# events.append(new_event) -# -# # Create event message -# if len(events) > 0: -# tera_message = db_man.create_event_message( -# create_module_event_topic_from_name(ModuleNames.DATABASE_MODULE_NAME)) -# any_events = list() -# for db_event in events: -# any_message = messages.Any() -# any_message.Pack(db_event) -# tera_message.events.append(any_message) -# -# db_man.publish(create_module_event_topic_from_name(ModuleNames.DATABASE_MODULE_NAME), -# tera_message.SerializeToString()) - -# @event.listens_for(TeraUser, 'after_update') -# def user_updated(mapper, connection, target): -# from modules.Globals import db_man -# # Publish event message -# # Advertise that we have a new user -# tera_message = db_man.create_event_message(create_module_event_topic_from_name(ModuleNames.DATABASE_MODULE_NAME)) -# user_event = messages.UserEvent() -# user_event.user_uuid = str(target.user_uuid) -# user_event.type = messages.UserEvent.USER_UPDATED -# # Need to use Any container -# any_message = messages.Any() -# any_message.Pack(user_event) -# tera_message.events.extend([any_message]) -# -# # Publish -# db_man.publish(create_module_event_topic_from_name(ModuleNames.DATABASE_MODULE_NAME), -# tera_message.SerializeToString()) - if __name__ == '__main__': with flask_app.app_context(): diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index d5c5857e..604ce865 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -253,7 +253,7 @@ def test_enable_2fa_in_site_should_enable_in_users(self): self.assertIsNotNone(site) group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) self.assertIsNotNone(group) - user = TeraSiteTest.new_test_user('test_user', 'password', group.id_user_group) + user = TeraSiteTest.new_test_user('test_user', 'Password12345!', group.id_user_group) self.assertIsNotNone(user) # Enable 2fa in site From 27defa91891f6d82f75081388fcbff5fe971bd7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 09:57:09 -0400 Subject: [PATCH 52/67] Refs #253, force reset password for admin on first login. --- teraserver/python/opentera/db/models/TeraUser.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index aa640e61..ed8c244a 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -416,6 +416,8 @@ def create_defaults(test=False): admin.user_lastname = "Admin" admin.user_profile = "" admin.user_password = TeraUser.encrypt_password("admin") + # Force reset password for admin on first login + admin.user_force_password_change = not test admin.user_superadmin = True admin.user_username = "admin" admin.user_uuid = str(uuid.uuid4()) @@ -508,4 +510,4 @@ class PasswordWeaknesses(Enum): def __init__(self, message, weaknesses: list): super().__init__(message) - self.weaknesses = weaknesses \ No newline at end of file + self.weaknesses = weaknesses From f85126164c0dc5a6e9aaf819b880fa304416aaf8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 10:02:50 -0400 Subject: [PATCH 53/67] Refs #253, fix tests. --- .../modules/FlaskModule/API/user/test_UserLogin.py | 8 ++++---- .../FlaskModule/API/user/test_UserLoginSetup2FA.py | 12 ++++++------ 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py index 04c736f2..76fb490b 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin.py @@ -9,8 +9,8 @@ def setUp(self): super().setUp() # Create users with 2fa enabled with self._flask_app.app_context(): - self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) - self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'Password12345!', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'Password12345!', set_secret=False) def tearDown(self): # Delete users with 2fa enabled @@ -102,7 +102,7 @@ def test_get_endpoint_login_user1_2fa_already_setup(self): with self._flask_app.app_context(): # Login should redirect to 2fa verification - response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_1', 'password') + response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertFalse('login_setup_2fa' in response.json['redirect_url']) @@ -117,7 +117,7 @@ def test_get_endpoint_login_user2_2fa_not_setup(self): with self._flask_app.app_context(): # Login should redirect to 2fa verification - response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_2', 'password') + response = self._get_with_user_http_auth(self.test_client, 'test_user_2fa_2', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_setup_2fa' in response.json['redirect_url']) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py index 793551c0..f9ec2c9a 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py @@ -10,8 +10,8 @@ def setUp(self): super().setUp() # Create users with 2fa enabled with self._flask_app.app_context(): - self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'password', set_secret=True) - self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'password', set_secret=False) + self.user1: dict = self._create_2fa_enabled_user('test_user_2fa_1', 'Password12345!', set_secret=True) + self.user2: dict = self._create_2fa_enabled_user('test_user_2fa_2', 'Password12345!', set_secret=False) def tearDown(self): # Delete users with 2fa enabled @@ -79,7 +79,7 @@ def test_get_endpoint_login_user1_2fa_already_setup(self): with self._flask_app.app_context(): # Fisrt login to create session - response = self._login_user('test_user_2fa_1', 'password') + response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertFalse('login_setup_2fa' in response.json['redirect_url']) @@ -92,7 +92,7 @@ def test_get_endpoint_login_user2_http_auth_should_work_but_and_modify_user_afte with self._flask_app.app_context(): # First login to create session - response = self._login_user('test_user_2fa_2', 'password') + response = self._login_user('test_user_2fa_2', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_setup_2fa' in response.json['redirect_url']) @@ -133,7 +133,7 @@ def test_get_endpoint_login_user2_http_auth_should_fail_after_post_with_wrong_co with self._flask_app.app_context(): # First login to create session - response = self._login_user('test_user_2fa_2', 'password') + response = self._login_user('test_user_2fa_2', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_setup_2fa' in response.json['redirect_url']) @@ -160,7 +160,7 @@ def test_get_endpoint_login_user2_http_auth_should_fail_after_post_with_wrong_se with self._flask_app.app_context(): # First login to create session - response = self._login_user('test_user_2fa_2', 'password') + response = self._login_user('test_user_2fa_2', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) self.assertTrue('login_setup_2fa' in response.json['redirect_url']) From c53173987c8e0a8e08d7a10881af97437eb62e4b Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 7 Oct 2024 10:06:47 -0400 Subject: [PATCH 54/67] Refs #253. Updated login 2fa API paths in views. Work started on login - change password user API. --- .../API/user/UserLoginChangePassword.py | 50 +++++++++++++++++++ .../FlaskModule/API/user/UserQueryUsers.py | 5 +- .../python/modules/FlaskModule/FlaskModule.py | 6 ++- .../python/opentera/db/models/TeraUser.py | 20 +++++++- .../python/templates/login_setup_2fa.html | 4 +- .../python/templates/login_validate_2fa.html | 2 +- .../API/user/test_UserQueryUsers.py | 6 ++- 7 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py new file mode 100644 index 00000000..78eb2463 --- /dev/null +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py @@ -0,0 +1,50 @@ +from modules.FlaskModule.API.user.UserLoginBase import UserLoginBase +from modules.FlaskModule.FlaskModule import user_api_ns as api +from modules.LoginModule.LoginModule import LoginModule, current_user +from opentera.db.models.TeraUser import TeraUser, UserPasswordInsecure, UserNewPasswordSameAsOld +from modules.FlaskModule.FlaskUtils import FlaskUtils + +from flask_babel import gettext +from flask import redirect + +post_parser = api.parser() +post_parser.add_argument('new_password', type=str, required=True, help='New password for the user') +post_parser.add_argument('confirm_password', type=str, required=True, help='Password confirmation for the user') + +class UserLoginChangePassword(UserLoginBase): + """ + UserLoginChangePassword endpoint resource. + """ + + @api.doc(description='Change password for the user. This API will only work if forced change is required on login. ' + 'Otherwise, use the standard \'api/user\' endpoint.') + @api.expect(post_parser, validate=True) + @LoginModule.user_session_required + def post(self): + """ + Change password for a user on login (forced change) + """ + try: + args = post_parser.parse_args(strict=True) + new_password = args['new_password'] + confirm_password = args['confirm_password'] + + # Validate if new password and confirm password are the same + if new_password != confirm_password: + return gettext('New password and confirm password do not match'), 400 + + # Change password, will be encrypted + # Will also reset force password change flag + try: + TeraUser.update(current_user.id_user, {'user_password': new_password, + 'user_force_password_change': False}) + except UserPasswordInsecure as e: + return FlaskUtils.get_password_weaknesses_text(e.weaknesses, '
'), 400 + except UserNewPasswordSameAsOld: + return gettext('New password same as old password'), 400 + + return redirect(self._generate_login_url()) + except Exception as e: + # Something went wrong, logout user + self._user_logout() + raise e diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py index 72bb4084..0e42a4eb 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryUsers.py @@ -3,7 +3,7 @@ from sqlalchemy import exc from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api -from opentera.db.models.TeraUser import TeraUser, UserPasswordInsecure +from opentera.db.models.TeraUser import TeraUser, UserPasswordInsecure, UserNewPasswordSameAsOld from opentera.db.models.TeraUserGroup import TeraUserGroup from flask_babel import gettext from modules.DatabaseModule.DBManager import DBManager @@ -256,6 +256,9 @@ def post(self): except UserPasswordInsecure as e: return (gettext('Password not strong enough') + ': ' + FlaskUtils.get_password_weaknesses_text(e.weaknesses), 400) + except UserNewPasswordSameAsOld: + return gettext('New password same as old password'), 400 + else: # New user, check if password is set # if 'user_password' not in json_user: diff --git a/teraserver/python/modules/FlaskModule/FlaskModule.py b/teraserver/python/modules/FlaskModule/FlaskModule.py index 2211de20..6c1d1b86 100755 --- a/teraserver/python/modules/FlaskModule/FlaskModule.py +++ b/teraserver/python/modules/FlaskModule/FlaskModule.py @@ -142,6 +142,7 @@ def init_user_api(module: object, namespace: Namespace, additional_args: dict = from modules.FlaskModule.API.user.UserLogin import UserLogin from modules.FlaskModule.API.user.UserLogin2FA import UserLogin2FA from modules.FlaskModule.API.user.UserLoginSetup2FA import UserLoginSetup2FA + from modules.FlaskModule.API.user.UserLoginChangePassword import UserLoginChangePassword from modules.FlaskModule.API.user.UserLogout import UserLogout from modules.FlaskModule.API.user.UserQueryUsers import UserQueryUsers from modules.FlaskModule.API.user.UserQueryUserPreferences import UserQueryUserPreferences @@ -204,8 +205,9 @@ def init_user_api(module: object, namespace: Namespace, additional_args: dict = namespace.add_resource(UserQueryForms, '/forms', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryParticipantGroup, '/groups', resource_class_kwargs=kwargs) namespace.add_resource(UserLogin, '/login', resource_class_kwargs=kwargs) - namespace.add_resource(UserLogin2FA, '/login_2fa', resource_class_kwargs=kwargs) - namespace.add_resource(UserLoginSetup2FA, '/login_setup_2fa', resource_class_kwargs=kwargs) + namespace.add_resource(UserLogin2FA, '/login/2fa', resource_class_kwargs=kwargs) + namespace.add_resource(UserLoginSetup2FA, '/login/setup_2fa', resource_class_kwargs=kwargs) + namespace.add_resource(UserLoginChangePassword, '/login/change_password', resource_class_kwargs=kwargs) namespace.add_resource(UserLogout, '/logout', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryParticipants, '/participants', resource_class_kwargs=kwargs) namespace.add_resource(UserQueryOnlineParticipants, '/participants/online', resource_class_kwargs=kwargs) diff --git a/teraserver/python/opentera/db/models/TeraUser.py b/teraserver/python/opentera/db/models/TeraUser.py index aa640e61..5c3b75e6 100755 --- a/teraserver/python/opentera/db/models/TeraUser.py +++ b/teraserver/python/opentera/db/models/TeraUser.py @@ -328,13 +328,20 @@ def update(cls, id_user: int, values: dict): # Remove the password field is present and if empty if 'user_password' in values: if values['user_password'] == '': - del values['user_password'] + del values['user_password'] # Don't change password if empty else: # Check password strength password_errors = TeraUser.validate_password_strength(str(values['user_password'])) if len(password_errors) > 0: raise UserPasswordInsecure("User password insufficient strength", password_errors) + # Check that old password != new password + current_user = TeraUser.get_user_by_id(id_user) + if current_user: + if TeraUser.verify_password('', values['user_password'], current_user): + # Same password as before + raise UserNewPasswordSameAsOld("New password same as old") + # Forcing password to string values['user_password'] = TeraUser.encrypt_password(str(values['user_password'])) @@ -508,4 +515,13 @@ class PasswordWeaknesses(Enum): def __init__(self, message, weaknesses: list): super().__init__(message) - self.weaknesses = weaknesses \ No newline at end of file + self.weaknesses = weaknesses + + +class UserNewPasswordSameAsOld(Exception): + """ + Raised when the new password is equal to the old one + """ + def __init__(self, message): + super().__init__(message) + diff --git a/teraserver/python/templates/login_setup_2fa.html b/teraserver/python/templates/login_setup_2fa.html index 7b0bc8d3..7f1c4962 100644 --- a/teraserver/python/templates/login_setup_2fa.html +++ b/teraserver/python/templates/login_setup_2fa.html @@ -56,7 +56,7 @@ // Get the QR Code from the server $.ajax({ type: "GET", - url: "/api/user/login_setup_2fa", + url: "/api/user/login/setup_2fa", success: function(response) { console.log("QR Code received"); // Display the QR Code @@ -81,7 +81,7 @@ // Send the form data to the backend with a post request $.ajax({ type: "POST", - url: "/api/user/login_setup_2fa", + url: "/api/user/login/setup_2fa", data: form.serialize(), success: function(response) { console.log("2FA setup success"); diff --git a/teraserver/python/templates/login_validate_2fa.html b/teraserver/python/templates/login_validate_2fa.html index 2f312301..d976c484 100644 --- a/teraserver/python/templates/login_validate_2fa.html +++ b/teraserver/python/templates/login_validate_2fa.html @@ -90,7 +90,7 @@ // Use the login API for this purpose $.ajax({ type: "POST", - url: "/api/user/login_2fa", + url: "/api/user/login/2fa", data: { otp_code: otp_code, with_websocket: true diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py index 48ee5ff8..8e029bb9 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserQueryUsers.py @@ -412,11 +412,15 @@ def test_password_strength(self): json=json_data) self.assertEqual(400, response.status_code, msg="Password without numbers") - json_data['user']['user_password'] = 'Password12345!' + json_data['user']['user_password'] = 'Password12345!!' response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', json=json_data) self.assertEqual(200, response.status_code, msg="Password OK") + response = self._post_with_user_http_auth(self.test_client, username='admin', password='admin', + json=json_data) + self.assertEqual(400, response.status_code, msg="Password same as old") + TeraUser.delete(current_id) def test_post_and_delete(self): From a64e99a64dda54a07a18093a5f6bf13b0437fabb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 10:22:19 -0400 Subject: [PATCH 55/67] Refs #253, cleanup test. --- .../tests/opentera/db/models/test_TeraSite.py | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index 604ce865..56511086 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -1,4 +1,3 @@ -from tests.opentera.db.models.BaseModelsTest import BaseModelsTest from sqlalchemy import exc from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraProject import TeraProject @@ -15,6 +14,17 @@ from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup from opentera.db.models.TeraServiceAccess import TeraServiceAccess +from tests.opentera.db.models.BaseModelsTest import BaseModelsTest +from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest +from tests.opentera.db.models.test_TeraProject import TeraProjectTest +from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest +from tests.opentera.db.models.test_TeraSession import TeraSessionTest +from tests.opentera.db.models.test_TeraDeviceSite import TeraDeviceSiteTest +from tests.opentera.db.models.test_TeraServiceSite import TeraServiceSiteTest +from tests.opentera.db.models.test_TeraServiceRole import TeraServiceRoleTest +from tests.opentera.db.models.test_TeraSessionTypeSite import TeraSessionTypeSiteTest +from tests.opentera.db.models.test_TeraTestTypeSite import TeraTestTypeSiteTest + class TeraSiteTest(BaseModelsTest): def test_nullable_args(self): @@ -139,17 +149,14 @@ def test_hard_delete(self): site = TeraSiteTest.new_test_site() id_site = site.id_site - from tests.opentera.db.models.test_TeraProject import TeraProjectTest project = TeraProjectTest.new_test_project(id_site=id_site) self.assertIsNotNone(project.id_project) id_project = project.id_project - from tests.opentera.db.models.test_TeraParticipant import TeraParticipantTest participant = TeraParticipantTest.new_test_participant(id_project=id_project) self.assertIsNotNone(participant.id_participant) id_participant = participant.id_participant - from tests.opentera.db.models.test_TeraSession import TeraSessionTest ses = TeraSessionTest.new_test_session(id_session_type=1, id_creator_participant=1, participants=[participant]) id_session = ses.id_session @@ -186,31 +193,25 @@ def test_undelete(self): id_site = site.id_site # Associate device - from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest device = TeraDeviceTest.new_test_device() id_device = device.id_device - from tests.opentera.db.models.test_TeraDeviceSite import TeraDeviceSiteTest device = TeraDeviceSiteTest.new_test_device_site(id_device=id_device, id_site=id_site) id_device_site = device.id_device_site # ... and service - from tests.opentera.db.models.test_TeraServiceSite import TeraServiceSiteTest service_site = TeraServiceSiteTest.new_test_service_site(id_site=id_site, id_service=3) id_service_site = service_site.id_service_site # ... and roles - from tests.opentera.db.models.test_TeraServiceRole import TeraServiceRoleTest role = TeraServiceRoleTest.new_test_service_role(id_service=3, id_site=id_site, role_name='Test') id_role = role.id_service_role # ... and session type - from tests.opentera.db.models.test_TeraSessionTypeSite import TeraSessionTypeSiteTest ses_type = TeraSessionTypeSiteTest.new_test_session_type_site(id_site=id_site, id_session_type=1) id_session_type = ses_type.id_session_type_site # ... and test type - from tests.opentera.db.models.test_TeraTestTypeSite import TeraTestTypeSiteTest test_type = TeraTestTypeSiteTest.new_test_test_type_site(id_site=id_site, id_test_type=1) id_test_type = test_type.id_test_type_site @@ -264,11 +265,6 @@ def test_enable_2fa_in_site_should_enable_in_users(self): # User should be updated automatically with 2fa self.assertTrue(user.user_2fa_enabled) - - - - - @staticmethod def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> TeraSite: site = TeraSite() From 3c46086a942a3b57776949dba13c65a3747582cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 11:36:13 -0400 Subject: [PATCH 56/67] Refs #253, added site tests and user tests with 2fa enabled. --- .../modules/DatabaseModule/DBManager.py | 25 ++- .../tests/opentera/db/models/test_TeraSite.py | 164 ++++++++++++++++-- .../tests/opentera/db/models/test_TeraUser.py | 26 +++ 3 files changed, 197 insertions(+), 18 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 3b4e6fe0..c4939347 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -176,16 +176,29 @@ def user_updated_or_inserted(mapper, connection, target: TeraUser): .filter(TeraUserUserGroup.id_user == target.id_user) \ .with_entities(TeraSite).all() # Return the site information only - - for site in sites: - if site.site_2fa_required: - # Perform single update for user + if not sites: + # User is not in any user group related to a 2FA site + # If 2FA is disabled, make sure other 2FA fields are reset + if not target.user_2fa_enabled: connection.execute( update(TeraUser) .where(TeraUser.id_user == target.id_user) - .values(user_2fa_enabled=True) + .values( + user_2fa_otp_enabled=False, + user_2fa_otp_secret=None, + user_2fa_email_enabled=False + ) ) - break + else: + for site in sites: + if site.site_2fa_required: + # Perform single update for user + connection.execute( + update(TeraUser) + .where(TeraUser.id_user == target.id_user) + .values(user_2fa_enabled=True) + ) + break def setup_events_for_class(self, cls, event_name): diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index 56511086..185c3d00 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -13,6 +13,7 @@ from opentera.db.models.TeraUserGroup import TeraUserGroup from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup from opentera.db.models.TeraServiceAccess import TeraServiceAccess +from opentera.db.models.TeraService import TeraService from tests.opentera.db.models.BaseModelsTest import BaseModelsTest from tests.opentera.db.models.test_TeraDevice import TeraDeviceTest @@ -247,23 +248,146 @@ def test_2fa_required_site(self): self.db.session.rollback() same_site = TeraSite.get_site_by_id(id_site) self.assertTrue(same_site.site_2fa_required) + TeraSiteTest.delete_site(site.id_site) - def test_enable_2fa_in_site_should_enable_in_users(self): + def test_enable_2fa_in_site_should_enable_2fa_for_users(self): with self._flask_app.app_context(): site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=False) self.assertIsNotNone(site) group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) self.assertIsNotNone(group) - user = TeraSiteTest.new_test_user('test_user', 'Password12345!', group.id_user_group) - self.assertIsNotNone(user) + user1 = TeraSiteTest.new_test_user('test_user1', 'Password12345!', group.id_user_group) + self.assertIsNotNone(user1) + + user2 = TeraSiteTest.new_test_user('test_user2', 'Password12345!', None) + self.assertIsNotNone(user2) # Enable 2fa in site site.site_2fa_required = True self.db.session.add(site) self.db.session.commit() - # User should be updated automatically with 2fa - self.assertTrue(user.user_2fa_enabled) + # User should be updated automatically with 2fa if group is associated with site + self.assertTrue(user1.user_2fa_enabled) + # Else user should not be updated + self.assertFalse(user2.user_2fa_enabled) + # Delete everything + TeraSiteTest.delete_site(site.id_site) + TeraSiteTest.delete_user(user1.id_user) + TeraSiteTest.delete_user(user2.id_user) + TeraSiteTest.delete_user_group(group.id_user_group) + + + def test_disable_2fa_in_site_should_not_disable_2fa_for_users(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertIsNotNone(site) + group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) + self.assertIsNotNone(group) + user1 = TeraSiteTest.new_test_user('test_user1', 'Password12345!', group.id_user_group) + self.assertIsNotNone(user1) + + user2 = TeraSiteTest.new_test_user('test_user2', 'Password12345!', None) + self.assertIsNotNone(user2) + + # Site should have 2fa enabled + self.db.session.add(site) + self.db.session.commit() + + # User should have 2fa enabled if group have access to site + self.assertTrue(user1.user_2fa_enabled) + # Else user should not be updated + self.assertFalse(user2.user_2fa_enabled) + + # Disable 2fa in site + site.site_2fa_required = False + self.db.session.add(site) + self.db.session.commit() + + # User should still have 2fa enabled (not changed) + self.assertTrue(user1.user_2fa_enabled) + # Else user should not be updated + self.assertFalse(user2.user_2fa_enabled) + + # Delete everything + TeraSiteTest.delete_site(site.id_site) + TeraSiteTest.delete_user(user1.id_user) + TeraSiteTest.delete_user(user2.id_user) + TeraSiteTest.delete_user_group(group.id_user_group) + + def test_add_group_to_2fa_enabled_site_should_enable_2fa_for_all_users(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertIsNotNone(site) + + # No group associated to the user + user1 = TeraSiteTest.new_test_user('test_user1', 'Password12345!', None) + self.assertIsNotNone(user1) + + user2 = TeraSiteTest.new_test_user('test_user2', 'Password12345!', None) + self.assertIsNotNone(user2) + + # Site should have 2fa enabled + self.db.session.add(site) + self.db.session.commit() + + # User should have 2fa enabled if group have access to site + self.assertFalse(user1.user_2fa_enabled) + self.assertFalse(user2.user_2fa_enabled) + + # Add group to site + group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) + self.assertIsNotNone(group) + + # Add users to group + user_user_group = TeraUserUserGroup() + user_user_group.id_user = user1.id_user + user_user_group.id_user_group = group.id_user_group + TeraUserUserGroup.insert(user_user_group) + + user_user_group = TeraUserUserGroup() + user_user_group.id_user = user2.id_user + user_user_group.id_user_group = group.id_user_group + TeraUserUserGroup.insert(user_user_group) + + # User should have 2fa enabled if group have access to site + self.assertTrue(user1.user_2fa_enabled) + self.assertTrue(user2.user_2fa_enabled) + + # Delete everything + TeraSiteTest.delete_site(site.id_site) + TeraSiteTest.delete_user(user1.id_user) + TeraSiteTest.delete_user(user2.id_user) + TeraSiteTest.delete_user_group(group.id_user_group) + + def test_disable_2fa_for_user_in_a_2fa_site_should_not_change_2fa_enabled(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertIsNotNone(site) + group = TeraSiteTest.new_test_user_group('Test Group', site.id_site) + self.assertIsNotNone(group) + user1 = TeraSiteTest.new_test_user('test_user1', 'Password12345!', group.id_user_group) + self.assertIsNotNone(user1) + + # Site should have 2fa enabled + self.db.session.add(site) + self.db.session.commit() + + # User should have 2fa enabled if group have access to site + self.assertTrue(user1.user_2fa_enabled) + + # Disable 2fa for user + user1.user_2fa_enabled = False + self.db.session.add(user1) + self.db.session.commit() + + self.assertTrue(user1.user_2fa_enabled) + + # Delete everything + TeraSiteTest.delete_site(site.id_site) + TeraSiteTest.delete_user(user1.id_user) + TeraSiteTest.delete_user_group(group.id_user_group) + @staticmethod def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> TeraSite: @@ -277,9 +401,11 @@ def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> T def new_test_user_group(name: str, id_site: int ) -> TeraUserGroup: # Create Service Role first + tera_server_service = TeraService.get_openteraserver_service() + service_role = TeraServiceRole() service_role.service_role_name = 'Test Site Role' - service_role.id_service = 1 # TeraServer by default + service_role.id_service = tera_server_service.id_service service_role.id_site = id_site TeraServiceRole.insert(service_role) @@ -298,7 +424,7 @@ def new_test_user_group(name: str, id_site: int ) -> TeraUserGroup: @staticmethod - def new_test_user(username: str, password: str, id_user_group: int) -> TeraUser: + def new_test_user(username: str, password: str, id_user_group: int | None) -> TeraUser: user = TeraUser() user.user_username = username user.user_password = password @@ -309,10 +435,24 @@ def new_test_user(username: str, password: str, id_user_group: int) -> TeraUser: user.user_profile = {} TeraUser.insert(user) - # Update user group - user_user_group = TeraUserUserGroup() - user_user_group.id_user = user.id_user - user_user_group.id_user_group = id_user_group - TeraUserUserGroup.insert(user_user_group) + # Update user group if not none + if id_user_group is not None: + user_user_group = TeraUserUserGroup() + user_user_group.id_user = user.id_user + user_user_group.id_user_group = id_user_group + TeraUserUserGroup.insert(user_user_group) return user + + + @staticmethod + def delete_site(id: int): + TeraSite.delete(id, hard_delete=True) + + @staticmethod + def delete_user_group(id: int): + TeraUserGroup.delete(id, hard_delete=True) + + @staticmethod + def delete_user(id: int): + TeraUser.delete(id, hard_delete=True) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUser.py b/teraserver/python/tests/opentera/db/models/test_TeraUser.py index 7083a767..454e689a 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUser.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUser.py @@ -282,6 +282,32 @@ def test_token_for_siteadmin_should_have_valid_service_access(self): # TeraServiceAccess.delete(service_access.id_service_access) # TeraServiceRole.delete(role.id_service_role) + def test_disable_2fa_on_2fa_enabled_user_should_reset_secret_email_and_otp_states(self): + with self._flask_app.app_context(): + user: TeraUser = TeraUserTest.new_test_user(user_name="user_2fa", user_groups=None) + self.assertIsNotNone(user) + self.assertFalse(user.user_2fa_enabled) + # Setup 2FA + user.enable_2fa_otp() + self.assertTrue(user.user_2fa_enabled) + self.assertIsNotNone(user.user_2fa_otp_secret) + self.assertTrue(user.user_2fa_otp_enabled) + # Commit user + self.db.session.add(user) + self.db.session.commit() + # Disable 2FA + user.user_2fa_enabled = False + self.db.session.add(user) + self.db.session.commit() + # Check + self.assertFalse(user.user_2fa_enabled) + self.assertIsNone(user.user_2fa_otp_secret) + self.assertFalse(user.user_2fa_otp_enabled) + self.assertFalse(user.user_2fa_email_enabled) + # Delete user + TeraUser.delete(user.id_user, hard_delete=True) + + @staticmethod def new_test_user(user_name: str, user_groups: list | None = None) -> TeraUser: user = TeraUser() From a1840a115a4dd957b9e4a5a29240e62e8b490e6a Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 7 Oct 2024 11:41:44 -0400 Subject: [PATCH 57/67] Refs #253. Added tests for User Login Change Password --- .../API/user/UserLoginChangePassword.py | 5 +- .../templates/login_change_password.html | 7 +- .../FlaskModule/API/user/test_UserLogin2FA.py | 2 - .../API/user/test_UserLoginChangePassword.py | 179 ++++++++++++++++++ .../API/user/test_UserLoginSetup2FA.py | 2 +- 5 files changed, 188 insertions(+), 7 deletions(-) create mode 100644 teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginChangePassword.py diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py b/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py index 78eb2463..c4b58e87 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLoginChangePassword.py @@ -33,6 +33,9 @@ def post(self): if new_password != confirm_password: return gettext('New password and confirm password do not match'), 400 + if not current_user.user_force_password_change: + return gettext('User not required to change password'), 400 + # Change password, will be encrypted # Will also reset force password change flag try: @@ -43,7 +46,7 @@ def post(self): except UserNewPasswordSameAsOld: return gettext('New password same as old password'), 400 - return redirect(self._generate_login_url()) + return 200 except Exception as e: # Something went wrong, logout user self._user_logout() diff --git a/teraserver/python/templates/login_change_password.html b/teraserver/python/templates/login_change_password.html index 15fc1daf..787db778 100644 --- a/teraserver/python/templates/login_change_password.html +++ b/teraserver/python/templates/login_change_password.html @@ -68,7 +68,7 @@ // Send the form data to the backend with a post request $.ajax({ type: "POST", - url: $(this).action, + url: 'api/user/login/change_password', data: form.serialize(), success: function(response) { $('#dlgRedirect').removeClass("d-none").addClass("d-flex"); @@ -77,7 +77,8 @@ error: function(response) { if (response.status === 401) redirectToLogin(); - $('#error_message')[0].innerHTML = response.responseText; + $('#error_message')[0].innerHTML = response.responseText.substring(1, response.responseText.length-2); + //$('#error_message').text(response.responseJSON); $('#error_message').show(); } }); @@ -104,7 +105,7 @@
5:00
-
+
diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py index 16f5ef6a..0f9a1a13 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLogin2FA.py @@ -20,7 +20,6 @@ def tearDown(self): TeraUser.delete(self.user2['id_user'], hard_delete=True) super().tearDown() - def _create_2fa_enabled_user(self, username, password, set_secret:bool = True): user = TeraUser() user.id_user = 0 # New user @@ -41,7 +40,6 @@ def _create_2fa_enabled_user(self, username, password, set_secret:bool = True): TeraUser.insert(user) return user.to_json(minimal=False) - def _login_user(self, username, password): response = self._get_with_user_http_auth(self.test_client, username, password, endpoint='/api/user/login') self.assertEqual(200, response.status_code) diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginChangePassword.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginChangePassword.py new file mode 100644 index 00000000..6346a86d --- /dev/null +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginChangePassword.py @@ -0,0 +1,179 @@ +from tests.modules.FlaskModule.API.user.BaseUserAPITest import BaseUserAPITest +from opentera.db.models.TeraUser import TeraUser + + +class UserLoginChangePassword(BaseUserAPITest): + test_endpoint = '/api/user/login/change_password' + + def setUp(self): + super().setUp() + # Create users with password change needed + with self._flask_app.app_context(): + self.user1: dict = self._create_change_password_user('test_user_1', 'Password12345!') + + def tearDown(self): + # Delete users with 2fa enabled + with self._flask_app.app_context(): + TeraUser.delete(self.user1['id_user'], hard_delete=True) + super().tearDown() + + @staticmethod + def _create_change_password_user(username, password): + user = TeraUser() + user.id_user = 0 # New user + user.user_username = username + user.user_password = password + user.user_firstname = username + user.user_lastname = username + user.user_email = f"{username}@test.com" + user.user_enabled = True + user.user_profile = {} + user.user_force_password_change = True + + TeraUser.insert(user) + return user.to_json(minimal=False) + + def _login_user(self, username, password): + response = self._get_with_user_http_auth(self.test_client, username, password, endpoint='/api/user/login') + self.assertEqual(200, response.status_code) + self.assertEqual('application/json', response.headers['Content-Type']) + self.assertGreater(len(response.json), 0) + return response + + def test_post_endpoint_no_auth(self): + with self._flask_app.app_context(): + response = self.test_client.post(self.test_endpoint) + self.assertEqual(401, response.status_code) + + def test_post_endpoint_invalid_token_auth(self): + with self._flask_app.app_context(): + response = self._post_with_user_token_auth(self.test_client, 'invalid') + self.assertEqual(401, response.status_code) + + def test_post_endpoint_with_no_session(self): + with self._flask_app.app_context(): + response = self.test_client.post(self.test_endpoint) + self.assertEqual(401, response.status_code) + + def test_get_endpoint(self): + with self._flask_app.app_context(): + response = self.test_client.get(self.test_endpoint) + self.assertEqual(405, response.status_code) + + def test_post_password_without_force_required(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('admin', 'admin') + self.assertEqual(200, response.status_code) + self.assertFalse('redirect_url' in response.json) + + def test_post_password_change_mismatched(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('test_user_1', 'Password12345!') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_change_password' in response.json['redirect_url']) + + params = {'new_password': 'NewPassword12345!', + 'confirm_password': 'NotNewPassword12345!'} + response = self.test_client.post(self.test_endpoint, json=params) + self.assertEqual(400, response.status_code) + + def test_post_password_change_same_as_old(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('test_user_1', 'Password12345!') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_change_password' in response.json['redirect_url']) + + params = {'new_password': 'Password12345!', + 'confirm_password': 'Password12345!'} + response = self.test_client.post(self.test_endpoint, json=params) + self.assertEqual(400, response.status_code) + + def test_post_password_change_insecure(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('test_user_1', 'Password12345!') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_change_password' in response.json['redirect_url']) + + json_data = {'new_password': 'password', 'confirm_password': 'password'} + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password not long enough") + + json_data['new_password'] = 'password12345!' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password without capital letters") + + json_data['new_password'] = 'PASSWORD12345!' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password without lower case letters") + + json_data['new_password'] = 'Password12345' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password without special characters") + + json_data['new_password'] = 'Password!!!!' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password without numbers") + + json_data['new_password'] = 'Password12345!!' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(200, response.status_code, msg="Password OK") + + # Reset to original password + user = TeraUser.get_user_by_id(self.user1['id_user']) + user.user_force_password_change = True + user.db().session.commit() + + json_data['new_password'] = 'Password12345!' + json_data['confirm_password'] = json_data['new_password'] + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(200, response.status_code, msg="Password back to last") + + def test_post_password_change_not_required(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('test_user_1', 'Password12345!') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_change_password' in response.json['redirect_url']) + + user = TeraUser.get_user_by_id(self.user1['id_user']) + user.user_force_password_change = False + user.db().session.commit() + + json_data = {'new_password': 'Password12345!!', 'confirm_password': 'Password12345!!'} + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(400, response.status_code, msg="Password not required to be changed") + + user = TeraUser.get_user_by_id(self.user1['id_user']) + user.user_force_password_change = True + user.db().session.commit() + + def test_post_password_change_ok(self): + with self._flask_app.app_context(): + # First login to create session + response = self._login_user('test_user_1', 'Password12345!') + self.assertEqual(200, response.status_code) + self.assertTrue('redirect_url' in response.json) + self.assertTrue('login_change_password' in response.json['redirect_url']) + + json_data = {'new_password': 'Password12345!!', 'confirm_password': 'Password12345!!'} + response = self.test_client.post(self.test_endpoint, json=json_data) + self.assertEqual(200, response.status_code, msg="Password OK") + + # Reset to original password + user = TeraUser.get_user_by_id(self.user1['id_user']) + user.user_force_password_change = True + user.user_password = 'Password12345!' + user.db().session.commit() \ No newline at end of file diff --git a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py index 4c18b9e1..21fb8873 100644 --- a/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py +++ b/teraserver/python/tests/modules/FlaskModule/API/user/test_UserLoginSetup2FA.py @@ -78,7 +78,7 @@ def test_get_endpoint_with_admin_without_2fa_enabled(self): def test_get_endpoint_login_user1_2fa_already_setup(self): with self._flask_app.app_context(): - # Fisrt login to create session + # First login to create session response = self._login_user('test_user_2fa_1', 'Password12345!') self.assertEqual(200, response.status_code) self.assertTrue('redirect_url' in response.json) From 9dbde4a707d53bcf080145c24ce31a5319bd0402 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 11:45:18 -0400 Subject: [PATCH 58/67] Refs #253, Fix typos. --- teraserver/python/modules/DatabaseModule/DBManager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index c4939347..211592ee 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -97,7 +97,7 @@ def start_cleanup_task(self) -> task: def setup_events_for_2fa_sites(self): """ We need to validate that 2FA is enabled for all users in the site when the flag is set. - This can occur on multiple occasion, when the site is created, updated and also when user + This can occur on multiple occasions : when the site is created, updated and also when user groups are modified. """ @event.listens_for(TeraSite, 'after_update') From a08f3144df03e89b5e41877068a0ff11bd1a0c15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 12:05:07 -0400 Subject: [PATCH 59/67] Refs #253, Add verification for superadmins. --- .../modules/DatabaseModule/DBManager.py | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/teraserver/python/modules/DatabaseModule/DBManager.py b/teraserver/python/modules/DatabaseModule/DBManager.py index 211592ee..517099b0 100755 --- a/teraserver/python/modules/DatabaseModule/DBManager.py +++ b/teraserver/python/modules/DatabaseModule/DBManager.py @@ -113,7 +113,7 @@ def site_updated_or_inserted(mapper, connection, target: TeraSite): .filter(TeraSite.id_site == target.id_site) \ .with_entities(TeraUser).all() # Return the user information only - # Enable 2FA for all users found + # Enable 2FA for all standard users found for user in users: connection.execute( update(TeraUser) @@ -121,6 +121,13 @@ def site_updated_or_inserted(mapper, connection, target: TeraSite): .values(user_2fa_enabled=True) ) + # Enable 2FA for all superadmins + connection.execute( + update(TeraUser) + .where(TeraUser.user_superadmin == bool(True)) + .values(user_2fa_enabled=True) + ) + @event.listens_for(TeraUserGroup, 'after_update') @event.listens_for(TeraUserGroup, 'after_insert') def user_group_updated_or_inserted(mapper, connection, target: TeraUserGroup): @@ -170,11 +177,17 @@ def user_user_group_updated_or_inserted(mapper, connection, target: TeraUserUser def user_updated_or_inserted(mapper, connection, target: TeraUser): # Check if 2FA is enabled for a related site through user groups if target: - sites = TeraServiceAccess.query.join(TeraUserUserGroup, TeraServiceAccess.id_user_group == TeraUserUserGroup.id_user_group) \ - .join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ - .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ - .filter(TeraUserUserGroup.id_user == target.id_user) \ - .with_entities(TeraSite).all() # Return the site information only + sites = [] + if target.user_superadmin: + # Superadmin has access to all sites, so we need to verify if any of them have 2FA enabled + sites = TeraSite.query.filter(TeraSite.site_2fa_required == bool(True)).all() + else: + # Standard user need to verify sites through user groups + sites = TeraServiceAccess.query.join(TeraUserUserGroup, TeraServiceAccess.id_user_group == TeraUserUserGroup.id_user_group) \ + .join(TeraServiceRole, TeraServiceAccess.id_service_role == TeraServiceRole.id_service_role) \ + .join(TeraSite, TeraServiceRole.id_site == TeraSite.id_site) \ + .filter(TeraUserUserGroup.id_user == target.id_user) \ + .with_entities(TeraSite).all() # Return the site information only if not sites: # User is not in any user group related to a 2FA site From 398a7a6c92806e01777ea28bd79ad22e0ba1350b Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 7 Oct 2024 14:35:14 -0400 Subject: [PATCH 60/67] Refs #253. Fixed backwards forms compatibility for OpenTeraPlus <= 1.2.x --- .../modules/FlaskModule/API/user/UserLogin.py | 3 +- .../FlaskModule/API/user/UserQueryForms.py | 24 +++++++++++---- .../python/opentera/forms/TeraSiteForm.py | 8 +++-- .../python/opentera/forms/TeraUserForm.py | 29 ++++++++++--------- 4 files changed, 41 insertions(+), 23 deletions(-) diff --git a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py index d2a4876d..dc4efd24 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserLogin.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserLogin.py @@ -67,6 +67,8 @@ def _common_login_response(self, parser): response['user_uuid'] = current_user.user_uuid response['user_token'] = self._generate_user_token() + self._send_login_success_message() + except OutdatedClientVersionError as e: self._user_logout() @@ -90,7 +92,6 @@ def _common_login_response(self, parser): raise e else: # Everything went well, return response - self._send_login_success_message() return response, 200 diff --git a/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py b/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py index badbf226..0b91c3fd 100644 --- a/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py +++ b/teraserver/python/modules/FlaskModule/API/user/UserQueryForms.py @@ -1,8 +1,6 @@ -from flask_restx import Resource from modules.LoginModule.LoginModule import user_multi_auth, current_user from modules.FlaskModule.FlaskModule import user_api_ns as api from modules.DatabaseModule.DBManager import DBManager -from flask_babel import gettext from opentera.db.models.TeraSessionType import TeraSessionType from opentera.db.models.TeraParticipantGroup import TeraParticipantGroup @@ -26,7 +24,11 @@ from opentera.forms.TeraTestTypeForm import TeraTestTypeForm from opentera.redis.RedisRPCClient import RedisRPCClient + import json +from flask_babel import gettext +from flask_restx import Resource +from flask import request get_parser = api.parser() get_parser.add_argument(name='type', type=str, help='Data type of the required form. Currently, the ' @@ -75,9 +77,19 @@ def get(self): args = get_parser.parse_args() user_access = DBManager.userAccess(current_user) - # if args['type'] == 'user_profile': - # return jsonify(TeraUserForm.get_user_profile_form()) # If we have no arguments, return error + show_2fa_fields = True + if 'X-Client-Name' in request.headers and 'X-Client-Version' in request.headers: + client_name = request.headers['X-Client-Name'] + client_version = request.headers['X-Client-Version'] + client_version_parts = client_version.split('.') + if client_name == 'OpenTeraPlus': + # TODO: Remove when all OpenTeraPlus clients are updated at least to 1.3.0 version + if len(client_version_parts) >= 2: + if int(client_version_parts[1]) < 3 and int(client_version_parts[0]) == 1: + show_2fa_fields = False + else: + show_2fa_fields = False if 'type' not in args: return gettext('Missing type'), 400 @@ -86,10 +98,10 @@ def get(self): return gettext('Missing arguments'), 400 if args['type'] == 'user': - return TeraUserForm.get_user_form() + return TeraUserForm.get_user_form(show_2fa_fields=show_2fa_fields) if args['type'] == 'site': - return TeraSiteForm.get_site_form() + return TeraSiteForm.get_site_form(show_2fa_fields=show_2fa_fields) if args['type'] == 'device': return TeraDeviceForm.get_device_form() diff --git a/teraserver/python/opentera/forms/TeraSiteForm.py b/teraserver/python/opentera/forms/TeraSiteForm.py index 1e06f39d..5074db0e 100644 --- a/teraserver/python/opentera/forms/TeraSiteForm.py +++ b/teraserver/python/opentera/forms/TeraSiteForm.py @@ -5,8 +5,9 @@ class TeraSiteForm: @staticmethod - def get_site_form(): + def get_site_form(**kwargs): form = TeraForm("site") + show_2fa_fields = kwargs.get('show_2fa_fields', True) # Sections section = TeraFormSection("informations", gettext("Information")) @@ -15,8 +16,9 @@ def get_site_form(): # Items section.add_item(TeraFormItem("id_site", gettext("Site ID"), "hidden", True)) section.add_item(TeraFormItem("site_name", gettext("Site Name"), "text", True)) - section.add_item(TeraFormItem("site_2fa_required", gettext("Users Require 2FA"), "boolean", - False, item_default=False)) + if show_2fa_fields: + section.add_item(TeraFormItem("site_2fa_required", gettext("Users Require 2FA"), "boolean", + False, item_default=False)) section.add_item(TeraFormItem("site_role", gettext("Site Role"), "hidden", False)) return form.to_dict() diff --git a/teraserver/python/opentera/forms/TeraUserForm.py b/teraserver/python/opentera/forms/TeraUserForm.py index 075ec2b5..68113a29 100644 --- a/teraserver/python/opentera/forms/TeraUserForm.py +++ b/teraserver/python/opentera/forms/TeraUserForm.py @@ -6,8 +6,9 @@ class TeraUserForm: @staticmethod - def get_user_form(): + def get_user_form(**kwargs): form = TeraForm("user") + show_2fa_fields = kwargs.get('show_2fa_fields', True) # Sections section = TeraFormSection("informations", gettext("Information")) @@ -20,18 +21,20 @@ def get_user_form(): section.add_item(TeraFormItem("user_username", gettext("Username"), "text", True)) section.add_item(TeraFormItem("user_enabled", gettext("User Enabled"), "boolean", True, item_default=True)) - section.add_item(TeraFormItem("user_force_password_change", gettext("Force password change"), - "boolean", False, item_default=False)) - section.add_item(TeraFormItem("user_2fa_enabled", gettext("2FA Enabled"), "boolean", - False, item_default=False)) - - section.add_item(TeraFormItem("user_2fa_otp_enabled", gettext("2FA OTP Enabled"), "boolean", - False, item_default=False, - item_condition=TeraFormItemCondition("user_2fa_enabled", "=", True))) - section.add_item(TeraFormItem("user_2fa_email_enabled", gettext("2FA Email Enabled"), "hidden", - False, item_default=False, - # item_condition = TeraFormItemCondition("user_2fa_enabled", "=", True) - )) + + if show_2fa_fields: + section.add_item(TeraFormItem("user_force_password_change", gettext("Force password change"), + "boolean", False, item_default=False)) + section.add_item(TeraFormItem("user_2fa_enabled", gettext("2FA Enabled"), "boolean", + False, item_default=False)) + + section.add_item(TeraFormItem("user_2fa_otp_enabled", gettext("2FA OTP Enabled"), "boolean", + False, item_default=False, + item_condition=TeraFormItemCondition("user_2fa_enabled", "=", True))) + section.add_item(TeraFormItem("user_2fa_email_enabled", gettext("2FA Email Enabled"), "hidden", + False, item_default=False, + # item_condition = TeraFormItemCondition("user_2fa_enabled", "=", True) + )) # section.add_item(TeraFormItem("user_2fa_otp_secret", gettext("OTP Secret"), "hidden")) section.add_item(TeraFormItem("user_firstname", gettext("First Name"), "text", True)) From a07f2d1d628b5056857f0345868311d822b71f06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 14:50:05 -0400 Subject: [PATCH 61/67] Refs #253, Add tests for 2fa with superadmins. --- .../tests/opentera/db/models/test_TeraSite.py | 10 ++++++ .../tests/opentera/db/models/test_TeraUser.py | 34 +++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraSite.py b/teraserver/python/tests/opentera/db/models/test_TeraSite.py index 185c3d00..7d80da00 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraSite.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraSite.py @@ -388,6 +388,16 @@ def test_disable_2fa_for_user_in_a_2fa_site_should_not_change_2fa_enabled(self): TeraSiteTest.delete_user(user1.id_user) TeraSiteTest.delete_user_group(group.id_user_group) + def test_enable_2fa_for_site_needs_to_enable_2fa_for_all_superadmins(self): + with self._flask_app.app_context(): + site = TeraSiteTest.new_test_site(name='2FA Site', site_2fa_required=True) + self.assertIsNotNone(site) + + # Query all superadmins + superadmins = TeraUser.query.filter(TeraUser.user_superadmin == True).all() + for superadmin in superadmins: + self.assertTrue(superadmin.user_2fa_enabled) + @staticmethod def new_test_site(name: str = 'Test Site', site_2fa_required: bool = False) -> TeraSite: diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUser.py b/teraserver/python/tests/opentera/db/models/test_TeraUser.py index 454e689a..8965012d 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUser.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUser.py @@ -308,6 +308,40 @@ def test_disable_2fa_on_2fa_enabled_user_should_reset_secret_email_and_otp_state TeraUser.delete(user.id_user, hard_delete=True) + def test_change_superadmin_2fa_enabled_while_site_has_2fa_required_does_not_change_2fa_enabled(self): + with self._flask_app.app_context(): + sites = TeraSite.query.all() + for site in sites: + site.site_2fa_required = True + self.db.session.add(site) + self.db.session.commit() + + superadmins = TeraUser.query.filter_by(user_superadmin=True).all() + for superadmin in superadmins: + self.assertTrue(superadmin.user_2fa_enabled) + superadmin.user_2fa_enabled = False + self.db.session.add(superadmin) + self.db.session.commit() + + # Reverify flag + for superadmin in superadmins: + self.assertTrue(superadmin.user_2fa_enabled) + + # Reset site 2fa required + for site in sites: + site.site_2fa_required = False + self.db.session.add(site) + self.db.session.commit() + + #Reset superadmin 2fa enabled + for superadmin in superadmins: + superadmin.user_2fa_enabled = False + self.db.session.add(superadmin) + self.db.session.commit() + + for superadmin in superadmins: + self.assertFalse(superadmin.user_2fa_enabled) + @staticmethod def new_test_user(user_name: str, user_groups: list | None = None) -> TeraUser: user = TeraUser() From 7bde01466eae0d10d84a42d6f2e27b3c39fdfb01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 14:55:43 -0400 Subject: [PATCH 62/67] Refs #253, Code cleanup. --- .../tests/opentera/db/models/test_TeraUser.py | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUser.py b/teraserver/python/tests/opentera/db/models/test_TeraUser.py index 8965012d..6391dbb9 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUser.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUser.py @@ -1,4 +1,9 @@ +import uuid +import jwt + + from modules.DatabaseModule.DBManager import DBManager + from opentera.db.models.TeraUser import TeraUser from opentera.db.models.TeraSite import TeraSite from opentera.db.models.TeraProject import TeraProject @@ -8,9 +13,15 @@ from opentera.db.models.TeraService import TeraService from opentera.db.models.TeraServiceConfig import TeraServiceConfig from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup +from opentera.db.models.TeraServiceRole import TeraServiceRole +from opentera.db.models.TeraUserGroup import TeraUserGroup +from opentera.db.models.TeraServiceAccess import TeraServiceAccess + from tests.opentera.db.models.BaseModelsTest import BaseModelsTest -import uuid -import jwt +from tests.opentera.db.models.test_TeraSession import TeraSessionTest +from tests.opentera.db.models.test_TeraAsset import TeraAssetTest +from tests.opentera.db.models.test_TeraTest import TeraTestTest +from tests.opentera.db.models.test_TeraUserUserGroup import TeraUserUserGroupTest class TeraUserTest(BaseModelsTest): @@ -98,7 +109,6 @@ def test_hard_delete(self): id_user = user.id_user # Assign user to sessions - from tests.opentera.db.models.test_TeraSession import TeraSessionTest user_session = TeraSessionTest.new_test_session(id_creator_user=id_user) id_session = user_session.id_session @@ -106,14 +116,12 @@ def test_hard_delete(self): id_session_invitee = user_session.id_session # Attach asset - from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_user=id_user) id_asset = asset.id_asset # ... and test - from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_user=id_user) id_test = test.id_test @@ -152,12 +160,10 @@ def test_undelete(self): id_user = user.id_user # Assign to user group - from tests.opentera.db.models.test_TeraUserUserGroup import TeraUserUserGroupTest uug = TeraUserUserGroupTest.new_test_user_usergroup(id_user=id_user, id_user_group=1) id_user_user_group = uug.id_user_user_group # Assign user to sessions - from tests.opentera.db.models.test_TeraSession import TeraSessionTest user_session = TeraSessionTest.new_test_session(id_creator_user=id_user) id_session = user_session.id_session @@ -227,12 +233,6 @@ def test_token_for_admin_should_have_empty_service_access(self): self.assertEqual(token_dict['service_access'], {}) # Should be empty def test_token_for_siteadmin_should_have_valid_service_access(self): - from opentera.db.models.TeraService import TeraService - from opentera.db.models.TeraServiceRole import TeraServiceRole - from opentera.db.models.TeraUserGroup import TeraUserGroup - from opentera.db.models.TeraServiceAccess import TeraServiceAccess - from opentera.db.models.TeraUserUserGroup import TeraUserUserGroup - with self._flask_app.app_context(): user = TeraUser.get_user_by_username('siteadmin') self.assertIsNotNone(user) From a42bb28a61c8706a426ec699ff3a5a68400742b0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20Le=CC=81tourneau?= Date: Mon, 7 Oct 2024 14:57:44 -0400 Subject: [PATCH 63/67] Refs #253, Code cleanup. --- teraserver/python/tests/opentera/db/models/test_TeraUser.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/teraserver/python/tests/opentera/db/models/test_TeraUser.py b/teraserver/python/tests/opentera/db/models/test_TeraUser.py index 6391dbb9..865e10a0 100644 --- a/teraserver/python/tests/opentera/db/models/test_TeraUser.py +++ b/teraserver/python/tests/opentera/db/models/test_TeraUser.py @@ -22,7 +22,7 @@ from tests.opentera.db.models.test_TeraAsset import TeraAssetTest from tests.opentera.db.models.test_TeraTest import TeraTestTest from tests.opentera.db.models.test_TeraUserUserGroup import TeraUserUserGroupTest - +from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest class TeraUserTest(BaseModelsTest): @@ -171,19 +171,16 @@ def test_undelete(self): id_session_invitee = user_session.id_session # Attach asset - from tests.opentera.db.models.test_TeraAsset import TeraAssetTest asset = TeraAssetTest.new_test_asset(id_session=id_session, service_uuid=TeraService.get_openteraserver_service().service_uuid, id_user=id_user) id_asset = asset.id_asset # ... and test - from tests.opentera.db.models.test_TeraTest import TeraTestTest test = TeraTestTest.new_test_test(id_session=id_session, id_user=id_user) id_test = test.id_test # ... and service config - from tests.opentera.db.models.test_TeraServiceConfig import TeraServiceConfigTest service_conf = TeraServiceConfigTest.new_test_service_config(id_service=1, id_user=id_user) id_service_conf = service_conf.id_service_config From 58815365ec8d1fb54620678702d10c1601d53be6 Mon Sep 17 00:00:00 2001 From: Simon Briere Date: Mon, 7 Oct 2024 15:11:04 -0400 Subject: [PATCH 64/67] Refs #253. Updated translations --- .../en/LC_MESSAGES/filetransferservice.po | 2 +- .../fr/LC_MESSAGES/filetransferservice.po | 2 +- .../en/LC_MESSAGES/loggingservice.po | 2 +- .../fr/LC_MESSAGES/loggingservice.po | 2 +- .../en/LC_MESSAGES/videorehabservice.po | 2 +- .../fr/LC_MESSAGES/videorehabservice.po | 2 +- .../translations/en/LC_MESSAGES/messages.po | 1716 ++++++++-------- .../translations/fr/LC_MESSAGES/messages.po | 1717 +++++++++-------- 8 files changed, 1810 insertions(+), 1635 deletions(-) diff --git a/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po b/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po index 77e5be28..fab8143f 100644 --- a/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po +++ b/teraserver/python/services/FileTransferService/translations/en/LC_MESSAGES/filetransferservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:06-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2021-01-19 16:16-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po b/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po index 54bfafac..8cd9c6fe 100644 --- a/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po +++ b/teraserver/python/services/FileTransferService/translations/fr/LC_MESSAGES/filetransferservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:06-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2023-02-28 08:22-0500\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po b/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po index 576e9f7f..86c7e823 100644 --- a/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po +++ b/teraserver/python/services/LoggingService/translations/en/LC_MESSAGES/loggingservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:06-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2023-01-26 13:29-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po b/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po index 0f474aa9..38987866 100644 --- a/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po +++ b/teraserver/python/services/LoggingService/translations/fr/LC_MESSAGES/loggingservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:06-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2023-02-28 08:10-0500\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po b/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po index 936597b2..3374100e 100644 --- a/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po +++ b/teraserver/python/services/VideoRehabService/translations/en/LC_MESSAGES/videorehabservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:05-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2021-01-19 16:16-0500\n" "Last-Translator: FULL NAME \n" "Language: en\n" diff --git a/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po b/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po index cc9bb5db..2c75d6a8 100644 --- a/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po +++ b/teraserver/python/services/VideoRehabService/translations/fr/LC_MESSAGES/videorehabservice.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:05-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2023-05-23 14:29-0400\n" "Last-Translator: \n" "Language: fr\n" diff --git a/teraserver/python/translations/en/LC_MESSAGES/messages.po b/teraserver/python/translations/en/LC_MESSAGES/messages.po index 20d3080b..685782d6 100644 --- a/teraserver/python/translations/en/LC_MESSAGES/messages.po +++ b/teraserver/python/translations/en/LC_MESSAGES/messages.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:00-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" "PO-Revision-Date: 2021-01-25 13:01-0500\n" "Last-Translator: \n" "Language: en\n" @@ -18,165 +18,189 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Generated-By: Babel 2.16.0\n" -#: modules/FlaskModule/API/device/DeviceLogin.py:88 +#: modules/FlaskModule/API/user/UserQueryUsers.py:53 +#: modules/FlaskModule/FlaskUtils.py:13 +msgid "Password missing special character" +msgstr "" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:55 +#: modules/FlaskModule/FlaskUtils.py:15 +msgid "Password missing numeric character" +msgstr "" + +#: modules/FlaskModule/FlaskUtils.py:17 +msgid "Password not long enough (10 characters)" +msgstr "" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:59 +#: modules/FlaskModule/FlaskUtils.py:19 +msgid "Password missing lower case letter" +msgstr "" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:61 +#: modules/FlaskModule/FlaskUtils.py:21 +msgid "Password missing upper case letter" +msgstr "" + +#: modules/FlaskModule/API/device/DeviceLogin.py:90 msgid "Unable to get online devices." msgstr "" -#: modules/FlaskModule/API/device/DeviceLogin.py:104 +#: modules/FlaskModule/API/device/DeviceLogin.py:106 msgid "Device already logged in." msgstr "" -#: modules/FlaskModule/API/device/DeviceLogout.py:29 +#: modules/FlaskModule/API/device/DeviceLogout.py:32 msgid "Device logged out." msgstr "" -#: modules/FlaskModule/API/device/DeviceLogout.py:31 +#: modules/FlaskModule/API/device/DeviceLogout.py:34 msgid "Device not logged in" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryAssets.py:39 -#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:46 +#: modules/FlaskModule/API/device/DeviceQueryAssets.py:42 +#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:48 msgid "No access to session" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:69 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:75 msgid "Missing device schema" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:78 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:84 msgid "Missing config" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:82 -#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:42 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:49 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:55 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:62 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:68 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:74 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:80 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:52 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:66 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:121 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:144 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:179 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:72 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:79 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:89 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:52 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:57 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:99 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:106 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:158 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:112 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:100 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:104 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:108 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:116 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:194 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:86 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:88 +#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:44 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:51 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:57 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:64 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:70 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:76 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:82 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:55 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:69 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:127 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:150 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:188 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:75 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:82 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:92 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:55 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:60 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:105 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:112 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:167 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:121 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:106 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:110 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:114 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:122 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:203 #: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:91 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:66 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:72 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:76 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:84 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:90 -#: modules/FlaskModule/API/service/ServiceQuerySites.py:36 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:121 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:159 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:193 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:256 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:96 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:101 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:50 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:54 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:106 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:207 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:117 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:126 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:137 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:301 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:122 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:257 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:112 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:165 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:105 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:191 -#: modules/FlaskModule/API/user/UserQueryDevices.py:293 -#: modules/FlaskModule/API/user/UserQueryDevices.py:297 -#: modules/FlaskModule/API/user/UserQueryDevices.py:417 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:52 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:60 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:66 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:72 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:78 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:84 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:107 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:159 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:362 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:365 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:204 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:274 -#: modules/FlaskModule/API/user/UserQueryProjects.py:151 -#: modules/FlaskModule/API/user/UserQueryProjects.py:156 -#: modules/FlaskModule/API/user/UserQueryProjects.py:264 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:117 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:122 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:96 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:69 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:75 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:79 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:87 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:93 +#: modules/FlaskModule/API/service/ServiceQuerySites.py:39 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:127 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:165 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:199 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:265 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:102 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:107 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:52 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:56 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:111 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:215 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:116 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:125 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:136 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:306 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:125 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:262 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:115 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:170 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:107 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:195 +#: modules/FlaskModule/API/user/UserQueryDevices.py:296 +#: modules/FlaskModule/API/user/UserQueryDevices.py:300 +#: modules/FlaskModule/API/user/UserQueryDevices.py:422 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:53 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:61 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:67 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:73 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:79 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:85 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:110 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:164 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:368 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:371 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:207 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:279 +#: modules/FlaskModule/API/user/UserQueryProjects.py:155 +#: modules/FlaskModule/API/user/UserQueryProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryProjects.py:270 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:121 #: modules/FlaskModule/API/user/UserQueryServiceAccess.py:126 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:134 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:213 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:217 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:221 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:225 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:151 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:159 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:165 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:240 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:130 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:138 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:219 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:223 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:227 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:231 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:154 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:158 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:162 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:168 #: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:245 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:249 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:253 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:79 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:136 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:129 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:259 -#: modules/FlaskModule/API/user/UserQueryServices.py:121 -#: modules/FlaskModule/API/user/UserQueryServices.py:134 -#: modules/FlaskModule/API/user/UserQueryServices.py:231 -#: modules/FlaskModule/API/user/UserQueryServices.py:239 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:91 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:144 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:204 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:203 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:267 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:55 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:107 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:138 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:187 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:275 -#: modules/FlaskModule/API/user/UserQuerySites.py:127 -#: modules/FlaskModule/API/user/UserQuerySites.py:131 -#: modules/FlaskModule/API/user/UserQuerySites.py:180 -#: modules/FlaskModule/API/user/UserQueryStats.py:52 -#: modules/FlaskModule/API/user/UserQueryStats.py:57 -#: modules/FlaskModule/API/user/UserQueryStats.py:62 -#: modules/FlaskModule/API/user/UserQueryStats.py:68 -#: modules/FlaskModule/API/user/UserQueryStats.py:73 -#: modules/FlaskModule/API/user/UserQueryStats.py:81 -#: modules/FlaskModule/API/user/UserQueryStats.py:86 -#: modules/FlaskModule/API/user/UserQueryStats.py:91 -#: modules/FlaskModule/API/user/UserQueryTestType.py:62 -#: modules/FlaskModule/API/user/UserQueryTestType.py:169 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:205 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:193 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:256 -#: modules/FlaskModule/API/user/UserQueryTests.py:140 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:147 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:47 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:96 -#: modules/FlaskModule/API/user/UserQueryUsers.py:214 -#: modules/FlaskModule/API/user/UserQueryUsers.py:219 -#: modules/FlaskModule/API/user/UserQueryUsers.py:348 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:250 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:254 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:258 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:83 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:142 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:133 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:265 +#: modules/FlaskModule/API/user/UserQueryServices.py:124 +#: modules/FlaskModule/API/user/UserQueryServices.py:137 +#: modules/FlaskModule/API/user/UserQueryServices.py:236 +#: modules/FlaskModule/API/user/UserQueryServices.py:244 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:94 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:149 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:208 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:207 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:273 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:57 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:111 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:142 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:190 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:280 +#: modules/FlaskModule/API/user/UserQuerySites.py:128 +#: modules/FlaskModule/API/user/UserQuerySites.py:132 +#: modules/FlaskModule/API/user/UserQuerySites.py:183 +#: modules/FlaskModule/API/user/UserQueryStats.py:54 +#: modules/FlaskModule/API/user/UserQueryStats.py:59 +#: modules/FlaskModule/API/user/UserQueryStats.py:64 +#: modules/FlaskModule/API/user/UserQueryStats.py:70 +#: modules/FlaskModule/API/user/UserQueryStats.py:75 +#: modules/FlaskModule/API/user/UserQueryStats.py:83 +#: modules/FlaskModule/API/user/UserQueryStats.py:88 +#: modules/FlaskModule/API/user/UserQueryStats.py:93 +#: modules/FlaskModule/API/user/UserQueryTestType.py:64 +#: modules/FlaskModule/API/user/UserQueryTestType.py:173 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:209 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:197 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:262 +#: modules/FlaskModule/API/user/UserQueryTests.py:147 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:150 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:48 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:99 +#: modules/FlaskModule/API/user/UserQueryUsers.py:229 +#: modules/FlaskModule/API/user/UserQueryUsers.py:234 +#: modules/FlaskModule/API/user/UserQueryUsers.py:356 #: opentera/services/ServiceAccessManager.py:116 #: opentera/services/ServiceAccessManager.py:166 #: opentera/services/ServiceAccessManager.py:195 @@ -192,766 +216,770 @@ msgstr "" msgid "Forbidden" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:93 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:74 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:89 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:157 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:183 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:233 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:247 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:284 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:136 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:153 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:201 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:116 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:138 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:176 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:73 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:94 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:123 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:138 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:150 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:172 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:205 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:104 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:119 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:185 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:218 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:233 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:274 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:111 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:132 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:166 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:231 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:242 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:277 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:151 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:168 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:225 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:180 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:221 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:276 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:331 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:232 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:268 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:130 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:145 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:187 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:121 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:136 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:189 -#: modules/FlaskModule/API/user/UserQueryDevices.py:328 -#: modules/FlaskModule/API/user/UserQueryDevices.py:343 -#: modules/FlaskModule/API/user/UserQueryDevices.py:460 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:120 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:135 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:178 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:244 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:285 -#: modules/FlaskModule/API/user/UserQueryProjects.py:196 -#: modules/FlaskModule/API/user/UserQueryProjects.py:211 -#: modules/FlaskModule/API/user/UserQueryProjects.py:282 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:156 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:168 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:190 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:236 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:208 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:264 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:287 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:322 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:98 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:119 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:147 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:244 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:286 -#: modules/FlaskModule/API/user/UserQueryServices.py:162 -#: modules/FlaskModule/API/user/UserQueryServices.py:182 -#: modules/FlaskModule/API/user/UserQueryServices.py:259 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:104 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:119 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:155 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:251 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:291 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:244 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:286 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:197 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:212 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:342 -#: modules/FlaskModule/API/user/UserQuerySessions.py:179 -#: modules/FlaskModule/API/user/UserQuerySessions.py:194 -#: modules/FlaskModule/API/user/UserQuerySessions.py:272 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:246 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:286 -#: modules/FlaskModule/API/user/UserQuerySites.py:144 -#: modules/FlaskModule/API/user/UserQuerySites.py:159 -#: modules/FlaskModule/API/user/UserQuerySites.py:198 -#: modules/FlaskModule/API/user/UserQueryTestType.py:222 -#: modules/FlaskModule/API/user/UserQueryTestType.py:237 -#: modules/FlaskModule/API/user/UserQueryTestType.py:345 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:252 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:293 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:233 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:274 -#: modules/FlaskModule/API/user/UserQueryTests.py:151 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:190 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:205 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:233 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:262 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:310 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:111 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:138 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:174 -#: modules/FlaskModule/API/user/UserQueryUsers.py:240 -#: modules/FlaskModule/API/user/UserQueryUsers.py:272 -#: modules/FlaskModule/API/user/UserQueryUsers.py:385 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:99 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:79 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:94 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:162 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:188 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:239 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:253 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:293 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:142 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:159 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:210 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:122 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:144 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:185 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:79 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:100 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:132 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:144 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:156 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:178 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:214 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:109 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:124 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:191 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:224 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:239 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:283 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:117 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:138 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:175 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:237 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:248 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:286 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:156 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:173 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:233 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:183 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:226 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:279 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:336 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:235 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:273 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:133 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:148 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:192 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:123 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:138 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:193 +#: modules/FlaskModule/API/user/UserQueryDevices.py:331 +#: modules/FlaskModule/API/user/UserQueryDevices.py:346 +#: modules/FlaskModule/API/user/UserQueryDevices.py:465 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:123 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:138 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:247 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:290 +#: modules/FlaskModule/API/user/UserQueryProjects.py:200 +#: modules/FlaskModule/API/user/UserQueryProjects.py:215 +#: modules/FlaskModule/API/user/UserQueryProjects.py:288 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:160 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:172 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:194 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:242 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:211 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:269 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:291 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:328 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:102 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:123 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:153 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:248 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:292 +#: modules/FlaskModule/API/user/UserQueryServices.py:165 +#: modules/FlaskModule/API/user/UserQueryServices.py:185 +#: modules/FlaskModule/API/user/UserQueryServices.py:264 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:107 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:122 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:160 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:255 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:297 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:248 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:292 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:201 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:216 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:349 +#: modules/FlaskModule/API/user/UserQuerySessions.py:183 +#: modules/FlaskModule/API/user/UserQuerySessions.py:198 +#: modules/FlaskModule/API/user/UserQuerySessions.py:278 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:249 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:291 +#: modules/FlaskModule/API/user/UserQuerySites.py:145 +#: modules/FlaskModule/API/user/UserQuerySites.py:160 +#: modules/FlaskModule/API/user/UserQuerySites.py:201 +#: modules/FlaskModule/API/user/UserQueryTestType.py:226 +#: modules/FlaskModule/API/user/UserQueryTestType.py:241 +#: modules/FlaskModule/API/user/UserQueryTestType.py:351 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:256 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:299 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:237 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:280 +#: modules/FlaskModule/API/user/UserQueryTests.py:158 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:193 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:208 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:236 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:265 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:315 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:114 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:180 +#: modules/FlaskModule/API/user/UserQueryUsers.py:255 +#: modules/FlaskModule/API/user/UserQueryUsers.py:293 +#: modules/FlaskModule/API/user/UserQueryUsers.py:393 msgid "Database error" msgstr "" #: modules/FlaskModule/API/device/DeviceQueryParticipants.py:41 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:82 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:72 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:67 -#: modules/FlaskModule/API/service/ServiceQueryServices.py:76 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:62 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:122 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:109 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:86 -#: modules/FlaskModule/API/user/UserQueryProjects.py:116 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:84 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:123 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:67 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:112 -#: modules/FlaskModule/API/user/UserQueryServices.py:104 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:62 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:101 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:79 -#: modules/FlaskModule/API/user/UserQuerySessions.py:103 -#: modules/FlaskModule/API/user/UserQuerySites.py:100 -#: modules/FlaskModule/API/user/UserQueryTestType.py:112 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:99 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:67 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:84 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:75 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:70 +#: modules/FlaskModule/API/service/ServiceQueryServices.py:79 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:64 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:125 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:110 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:87 +#: modules/FlaskModule/API/user/UserQueryProjects.py:118 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:86 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:125 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:69 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:114 +#: modules/FlaskModule/API/user/UserQueryServices.py:105 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:63 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:103 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:81 +#: modules/FlaskModule/API/user/UserQuerySessions.py:105 +#: modules/FlaskModule/API/user/UserQuerySites.py:99 +#: modules/FlaskModule/API/user/UserQueryTestType.py:114 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:101 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:68 msgid "Invalid request" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:31 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:97 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:72 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:193 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:33 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:105 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:74 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:201 msgid "Forbidden for security reasons" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:47 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:53 -#: modules/FlaskModule/API/device/DeviceQueryStatus.py:48 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:270 -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:73 -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:87 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:76 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:104 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:119 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:49 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:46 -#: modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py:43 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:55 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:152 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:263 -#: modules/FlaskModule/API/service/ServiceQueryUsers.py:38 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:70 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:65 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:61 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:157 -#: modules/FlaskModule/API/user/UserQueryForms.py:85 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:76 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:81 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:53 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:63 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:60 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:46 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:62 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:59 -#: modules/FlaskModule/API/user/UserQuerySessions.py:59 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:80 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:62 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:60 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:52 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:58 +#: modules/FlaskModule/API/device/DeviceQueryStatus.py:50 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:279 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:71 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:88 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:79 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:107 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:125 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:52 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:48 +#: modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py:46 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:58 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:161 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:272 +#: modules/FlaskModule/API/service/ServiceQueryUsers.py:41 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:71 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:66 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:62 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:161 +#: modules/FlaskModule/API/user/UserQueryForms.py:98 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:78 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:82 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:55 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:65 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:62 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:47 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:64 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:61 +#: modules/FlaskModule/API/user/UserQuerySessions.py:61 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:81 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:64 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:62 msgid "Missing arguments" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:61 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:137 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:164 -#: modules/LoginModule/LoginModule.py:586 -#: modules/LoginModule/LoginModule.py:686 -#: modules/LoginModule/LoginModule.py:752 -#: modules/LoginModule/LoginModule.py:779 -#: modules/LoginModule/LoginModule.py:798 -#: modules/LoginModule/LoginModule.py:817 -#: modules/LoginModule/LoginModule.py:819 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:66 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:142 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:169 +#: modules/LoginModule/LoginModule.py:598 +#: modules/LoginModule/LoginModule.py:698 +#: modules/LoginModule/LoginModule.py:764 +#: modules/LoginModule/LoginModule.py:791 +#: modules/LoginModule/LoginModule.py:810 +#: modules/LoginModule/LoginModule.py:829 +#: modules/LoginModule/LoginModule.py:831 msgid "Unauthorized" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:87 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:115 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:136 -#: modules/FlaskModule/API/user/UserQuerySessions.py:120 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:92 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:120 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:142 +#: modules/FlaskModule/API/user/UserQuerySessions.py:124 msgid "Missing session" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:95 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:123 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:100 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:128 msgid "Missing id_session value" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:99 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:127 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:104 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:132 msgid "Missing id_session_type value" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:104 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:132 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:109 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:137 msgid "Missing session participants and/or users and/or devices" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:114 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:141 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:119 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:146 msgid "No access to session type" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:119 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:146 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:124 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:151 msgid "Missing argument 'session name'" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:121 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:148 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:126 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:153 msgid "Missing argument 'session_start_datetime'" msgstr "" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:170 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:196 -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:130 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:175 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:201 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:132 msgid "Invalid participant uuid" msgstr "" -#: modules/FlaskModule/API/device/DeviceQueryStatus.py:67 +#: modules/FlaskModule/API/device/DeviceQueryStatus.py:69 msgid "Status update forbidden on offline device." msgstr "" -#: modules/FlaskModule/API/device/DeviceRegister.py:73 -#: modules/FlaskModule/API/device/DeviceRegister.py:99 +#: modules/FlaskModule/API/device/DeviceRegister.py:76 +#: modules/FlaskModule/API/device/DeviceRegister.py:105 msgid "Invalid registration key" msgstr "" -#: modules/FlaskModule/API/device/DeviceRegister.py:103 +#: modules/FlaskModule/API/device/DeviceRegister.py:109 msgid "Invalid content type" msgstr "" -#: modules/FlaskModule/API/device/DeviceRegister.py:137 +#: modules/FlaskModule/API/device/DeviceRegister.py:143 msgid "Invalid CSR signature" msgstr "" -#: modules/FlaskModule/API/participant/ParticipantLogin.py:93 +#: modules/FlaskModule/API/participant/ParticipantLogin.py:96 msgid "Participant already logged in." msgstr "" -#: modules/FlaskModule/API/participant/ParticipantLogin.py:135 +#: modules/FlaskModule/API/participant/ParticipantLogin.py:138 msgid "Missing current_participant" msgstr "" -#: modules/FlaskModule/API/participant/ParticipantLogout.py:41 +#: modules/FlaskModule/API/participant/ParticipantLogout.py:44 msgid "Participant logged out." msgstr "" -#: modules/FlaskModule/API/participant/ParticipantLogout.py:43 +#: modules/FlaskModule/API/participant/ParticipantLogout.py:46 msgid "Participant not logged in" msgstr "" -#: modules/FlaskModule/API/participant/ParticipantQueryParticipants.py:55 +#: modules/FlaskModule/API/participant/ParticipantQueryParticipants.py:58 msgid "Not implemented" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:57 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:61 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:57 -#: modules/FlaskModule/API/user/UserQueryAssets.py:60 -#: modules/FlaskModule/API/user/UserQueryTests.py:54 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:59 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:64 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:60 +#: modules/FlaskModule/API/user/UserQueryAssets.py:61 +#: modules/FlaskModule/API/user/UserQueryTests.py:56 msgid "No arguments specified" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:67 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:69 msgid "Missing at least one from argument for uuids" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:77 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:79 msgid "Invalid user uuid" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:126 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:128 msgid "Participant cannot be admin" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:163 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:165 msgid "Device cannot be admin" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:167 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:169 msgid "Invalid device uuid" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:64 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:90 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:60 -#: modules/FlaskModule/API/user/UserQueryAssets.py:63 -#: modules/FlaskModule/API/user/UserQueryAssets.py:91 -#: modules/FlaskModule/API/user/UserQueryTests.py:57 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:67 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:93 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:63 +#: modules/FlaskModule/API/user/UserQueryAssets.py:64 +#: modules/FlaskModule/API/user/UserQueryAssets.py:92 +#: modules/FlaskModule/API/user/UserQueryTests.py:59 msgid "Device access denied" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:68 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:64 -#: modules/FlaskModule/API/user/UserQueryAssets.py:67 -#: modules/FlaskModule/API/user/UserQueryTests.py:61 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:71 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:67 +#: modules/FlaskModule/API/user/UserQueryAssets.py:68 +#: modules/FlaskModule/API/user/UserQueryTests.py:63 msgid "Session access denied" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:72 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:86 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:68 -#: modules/FlaskModule/API/user/UserQueryAssets.py:71 -#: modules/FlaskModule/API/user/UserQueryAssets.py:87 -#: modules/FlaskModule/API/user/UserQueryTests.py:65 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:75 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:89 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:71 +#: modules/FlaskModule/API/user/UserQueryAssets.py:72 +#: modules/FlaskModule/API/user/UserQueryAssets.py:88 +#: modules/FlaskModule/API/user/UserQueryTests.py:67 msgid "Participant access denied" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:76 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:82 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:72 -#: modules/FlaskModule/API/user/UserQueryAssets.py:75 -#: modules/FlaskModule/API/user/UserQueryAssets.py:83 -#: modules/FlaskModule/API/user/UserQueryTests.py:69 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:79 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:85 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:75 +#: modules/FlaskModule/API/user/UserQueryAssets.py:76 +#: modules/FlaskModule/API/user/UserQueryAssets.py:84 +#: modules/FlaskModule/API/user/UserQueryTests.py:71 msgid "User access denied" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:97 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:80 -#: modules/FlaskModule/API/user/UserQueryAssets.py:100 -#: modules/FlaskModule/API/user/UserQueryTests.py:76 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:100 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:83 +#: modules/FlaskModule/API/user/UserQueryAssets.py:101 +#: modules/FlaskModule/API/user/UserQueryTests.py:78 msgid "Missing argument" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:163 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:169 msgid "Missing asset field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:169 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:175 msgid "Missing id_asset field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:173 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:141 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:179 #: modules/FlaskModule/API/service/ServiceQueryTests.py:147 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:153 msgid "Unknown session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:176 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:182 msgid "Invalid asset type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:179 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:185 msgid "Invalid asset name" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:189 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:195 msgid "Service can't create assets for that session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:196 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:196 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:202 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:202 msgid "Invalid participant" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:204 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:204 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:210 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:210 msgid "Invalid user" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:212 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:212 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:218 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:218 msgid "Invalid device" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:273 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:282 msgid "Service can't delete assets for that session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:93 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:94 msgid "Unknown device type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:97 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:98 msgid "Unknown device subtype" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:84 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:88 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:86 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:89 msgid "Success" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:57 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:191 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:252 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:203 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:296 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:209 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:63 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:308 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:266 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:270 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:271 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:60 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:200 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:261 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:208 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:301 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:215 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:64 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:314 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:272 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:276 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:277 msgid "Not found" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:95 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:101 msgid "Missing participant_group" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:102 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:108 msgid "Missing id_participant_group" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:107 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:93 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:180 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:198 -#: modules/FlaskModule/API/user/UserQueryProjects.py:138 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:179 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:113 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:99 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:201 +#: modules/FlaskModule/API/user/UserQueryProjects.py:142 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:183 msgid "Missing id_project" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:114 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:120 msgid "Missing group name" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:184 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:193 msgid "The id_participant_group given was not found" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:190 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:199 msgid "Deletion impossible: Participant group still has participant(s)" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:125 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:131 msgid "Unknown project" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:128 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:134 msgid "Invalid participant name" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:131 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:137 msgid "Invalid participant email" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:155 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:311 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:161 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:315 msgid "Can't insert participant: participant's project is disabled or invalid." msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:165 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:284 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:171 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:288 msgid "Can't update participant: participant's project is disabled." msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:48 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:51 msgid "Missing parameter" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:87 -#: modules/FlaskModule/API/user/UserQueryProjects.py:132 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:93 +#: modules/FlaskModule/API/user/UserQueryProjects.py:136 msgid "Missing project" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:96 -#: modules/FlaskModule/API/user/UserQueryProjects.py:140 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:102 +#: modules/FlaskModule/API/user/UserQueryProjects.py:144 msgid "Missing id_site arguments" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:169 -#: modules/FlaskModule/API/user/UserQueryProjects.py:275 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:178 +#: modules/FlaskModule/API/user/UserQueryProjects.py:281 msgid "" "Can't delete project: please delete all participants with sessions before" " deleting." msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:52 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:58 msgid "Missing service_role field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:58 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:154 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:172 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:85 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:64 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:160 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:176 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:89 msgid "Missing id_service_role" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:81 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:119 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:106 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:87 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:125 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:110 msgid "Missing fields" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:87 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:104 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:93 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:108 msgid "Missing service_access" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:91 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:108 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:97 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:112 msgid "Missing id_service_access" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:95 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:112 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:101 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:116 msgid "Can't combine id_user_group, id_participant_group and id_device in request" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:114 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:132 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:120 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:136 msgid "Bad id_service_role" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:158 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:176 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:192 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:164 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:180 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:195 msgid "Missing at least one id field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryServices.py:38 +#: modules/FlaskModule/API/service/ServiceQueryServices.py:41 msgid "Missing service key, id or uuid" msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:76 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:77 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:81 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:80 msgid "Missing session_event field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:82 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:83 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:87 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:86 msgid "Missing id_session or id_session_event fields" msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:95 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:98 msgid "Missing arguments: at least one id is required" msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:142 -#: modules/FlaskModule/API/user/UserQuerySessions.py:126 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:148 +#: modules/FlaskModule/API/user/UserQuerySessions.py:130 msgid "Missing id_session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:157 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:163 msgid "Service doesn't have access to at least one participant of that session." msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:164 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:170 msgid "Service doesn't have access to at least one user of that session." msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:171 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:177 msgid "Service doesn't have access to at least one device of that session." msgstr "" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:189 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:129 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:121 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:101 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:195 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:133 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:125 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:105 msgid "Missing id_session_type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:115 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:93 -#: modules/FlaskModule/API/user/UserQueryTestType.py:134 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:127 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:119 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:121 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:99 +#: modules/FlaskModule/API/user/UserQueryTestType.py:138 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:131 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:123 msgid "Missing id_test_type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:117 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:143 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:144 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:131 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:129 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:123 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:148 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:135 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:133 msgid "Missing projects" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:129 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:141 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:135 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:145 msgid "Access denied to at least one project" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:145 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:176 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:266 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:157 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:285 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:151 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:182 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:275 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:161 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:192 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:291 msgid "" "Can't delete test type from project: please delete all tests of that type" " in the project before deleting." msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:152 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:161 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:164 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:158 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:165 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:168 msgid "Missing project ID" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:154 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:166 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:152 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:170 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:156 msgid "Missing test types" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:185 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:196 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:197 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:191 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:200 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:201 msgid "Unknown format" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:190 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:195 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:207 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:201 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:200 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:202 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:190 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:196 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:198 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:211 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:205 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:204 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:206 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:194 msgid "Badly formatted request" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:87 -#: modules/FlaskModule/API/user/UserQueryTestType.py:128 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:93 +#: modules/FlaskModule/API/user/UserQueryTestType.py:132 msgid "Missing test_type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:155 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:164 msgid "Test type not related to this service. Can't delete." msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:131 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:137 msgid "Missing test field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:137 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:143 msgid "Missing id_test field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:154 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:160 msgid "Missing id_test_type field" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:159 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:165 msgid "Invalid test type" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:189 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:195 msgid "Service can't create tests for that session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:266 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:275 msgid "Service can't delete tests for that session" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:81 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:132 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:86 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:135 msgid "Missing user_group" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:88 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:196 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:179 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:139 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:102 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:93 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:199 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:182 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:106 msgid "Missing id_user_group" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:100 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:105 msgid "Missing service role name or id_service_role" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:113 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:118 msgid "Can't set access to service other than self" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:119 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:124 msgid "No access for at a least one project in the list" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:125 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:130 msgid "No access for at a least one site in the list" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:137 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:142 msgid "Bad role name for service" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:155 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:160 msgid "A new usergroup must have at least one service access" msgstr "" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:217 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:302 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:225 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:307 msgid "" "Can't delete user group: please delete all users part of that user group " "before deleting." msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:116 -#: modules/FlaskModule/API/user/UserSessionManager.py:107 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:118 +#: modules/FlaskModule/API/user/UserSessionManager.py:108 msgid "Missing action" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:131 -#: modules/FlaskModule/API/user/UserSessionManager.py:121 -#: modules/FlaskModule/API/user/UserSessionManager.py:134 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:133 +#: modules/FlaskModule/API/user/UserSessionManager.py:122 +#: modules/FlaskModule/API/user/UserSessionManager.py:135 msgid "Invalid session" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:139 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:141 msgid "Service doesn't have access to that session" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:160 -#: modules/FlaskModule/API/user/UserSessionManager.py:155 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:162 +#: modules/FlaskModule/API/user/UserSessionManager.py:156 msgid "Missing required id_session_type for new sessions" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:166 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:168 msgid "Invalid session type" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:201 -#: modules/FlaskModule/API/user/UserSessionManager.py:192 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:203 +#: modules/FlaskModule/API/user/UserSessionManager.py:193 msgid "Service not found" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:209 -#: modules/FlaskModule/API/user/UserSessionManager.py:197 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:211 +#: modules/FlaskModule/API/user/UserSessionManager.py:198 msgid "Not implemented yet" msgstr "" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:224 -#: modules/FlaskModule/API/user/UserSessionManager.py:213 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:226 +#: modules/FlaskModule/API/user/UserSessionManager.py:214 msgid "No answer from service." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:46 +#: modules/FlaskModule/API/user/UserLogin.py:41 +msgid "Password change required for this user." +msgstr "" + +#: modules/FlaskModule/API/user/UserLogin.py:52 msgid "2FA required for this user." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:50 +#: modules/FlaskModule/API/user/UserLogin.py:56 msgid "2FA enabled but OTP not set for this user.Please setup 2FA." msgstr "" -#: modules/FlaskModule/API/user/UserLogin.py:71 +#: modules/FlaskModule/API/user/UserLogin.py:79 #: modules/FlaskModule/API/user/UserLogin2FA.py:101 #: modules/FlaskModule/API/user/UserLoginBase.py:154 #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:85 @@ -998,6 +1026,20 @@ msgstr "" msgid "Unknown client name :" msgstr "" +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:34 +#: modules/FlaskModule/Views/LoginChangePasswordView.py:48 +msgid "New password and confirm password do not match" +msgstr "" + +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:37 +msgid "User not required to change password" +msgstr "" + +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:47 +#: modules/FlaskModule/API/user/UserQueryUsers.py:260 +msgid "New password same as old password" +msgstr "" + #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:53 #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:123 msgid "User already has 2FA OTP secret set" @@ -1007,176 +1049,176 @@ msgstr "" msgid "2FA enabled for this user." msgstr "" -#: modules/FlaskModule/API/user/UserLogout.py:33 +#: modules/FlaskModule/API/user/UserLogout.py:36 msgid "User logged out." msgstr "" -#: modules/FlaskModule/API/user/UserLogout.py:35 +#: modules/FlaskModule/API/user/UserLogout.py:38 msgid "User not logged in" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssets.py:79 +#: modules/FlaskModule/API/user/UserQueryAssets.py:80 msgid "Service access denied" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssets.py:174 +#: modules/FlaskModule/API/user/UserQueryAssets.py:177 msgid "" "Asset information update and creation must be done directly into a " "service (such as Filetransfer service)" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssets.py:182 +#: modules/FlaskModule/API/user/UserQueryAssets.py:187 msgid "" "Asset information deletion must be done directly into a service (such as " "Filetransfer service)" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:89 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:88 msgid "Only one of the ID parameter is supported at once" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:152 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:151 msgid "Missing required parameter" msgstr "" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:189 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:188 msgid "Unable to create archive information from FileTransferService" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:128 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:207 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:131 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:212 msgid "User is not admin of the participant's project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:131 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:210 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:134 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:215 msgid "Access denied to device" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:149 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:152 msgid "Device not assigned to project or participant" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:141 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:127 -#: modules/FlaskModule/API/user/UserQueryDevices.py:259 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:144 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:130 +#: modules/FlaskModule/API/user/UserQueryDevices.py:262 msgid "Missing id_device" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:157 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:165 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:232 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:243 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:135 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:168 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:273 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:127 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:165 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:133 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:171 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:275 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:125 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:157 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:168 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:191 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:235 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:192 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:247 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:139 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:172 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:279 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:131 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:169 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:137 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:175 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:281 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:129 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:161 msgid "Access denied" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:172 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:208 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:321 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:175 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:211 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:326 msgid "" "Can't delete device from project. Please remove all participants " "associated with the device or all sessions in the project referring to " "the device before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:182 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:157 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:185 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:160 msgid "Missing devices" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:238 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:241 msgid "" "At least one device is not part of the allowed device for that project " "site" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:314 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:319 msgid "" "Can't delete device from project: please remove all participants with " "device before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:317 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:322 msgid "" "Can't delete device from project: please remove all sessions in this " "project referring to that device before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:129 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:136 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:123 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:121 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:132 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:140 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:127 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:125 msgid "Missing sites" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:148 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:175 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:151 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:178 msgid "" "Can't delete device from site. Please remove all participants associated " "with the device or all sessions in the site referring to the device " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:164 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:158 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:150 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:158 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:168 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:162 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:154 msgid "Missing site ID" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:252 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:263 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:252 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:257 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:269 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:258 msgid "Bad parameter" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:50 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:51 msgid "Too Many IDs" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:58 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:59 msgid "No access to device subtype" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:64 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:65 msgid "No access to device type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:102 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:105 msgid "Missing device_subtype" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:109 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:112 msgid "Missing id_device_subtype" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:121 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:124 msgid "Invalid device subtype" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:169 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:174 msgid "Device subtype not found" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:179 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:184 msgid "" "Can't delete device subtype: please delete all devices of that subtype " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:189 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:194 msgid "Device subtype successfully deleted" msgstr "" @@ -1186,667 +1228,680 @@ msgid "Unexisting ID/Forbidden access" msgstr "" #: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:79 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:399 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:182 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:405 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:185 msgid "Database Error" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:95 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:97 msgid "Missing device type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:102 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:104 msgid "Missing id_device_type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:113 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:115 msgid "Invalid device type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:160 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:164 msgid "Tried to delete with 2 parameters" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:168 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:171 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:172 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:175 msgid "Device type not found" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:181 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:185 msgid "" "Can't delete device type: please delete all associated devices before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:193 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:197 msgid "Device type successfully deleted" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:130 +#: modules/FlaskModule/API/user/UserQueryDevices.py:131 msgid "Too many IDs" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:253 +#: modules/FlaskModule/API/user/UserQueryDevices.py:256 msgid "Missing device" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:275 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:177 +#: modules/FlaskModule/API/user/UserQueryDevices.py:278 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:180 msgid "No site admin access for at a least one project in the list" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:287 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:158 +#: modules/FlaskModule/API/user/UserQueryDevices.py:290 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:161 msgid "No site admin access for at a least one site in the list" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:412 -#: modules/FlaskModule/API/user/UserQueryUsers.py:343 +#: modules/FlaskModule/API/user/UserQueryDevices.py:417 +#: modules/FlaskModule/API/user/UserQueryUsers.py:351 msgid "Invalid id" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:436 +#: modules/FlaskModule/API/user/UserQueryDevices.py:441 msgid "" "Can't delete device: please delete all participants association before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:439 +#: modules/FlaskModule/API/user/UserQueryDevices.py:444 msgid "" "Can't delete device: please remove all sessions referring to that device " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:442 +#: modules/FlaskModule/API/user/UserQueryDevices.py:447 msgid "" "Can't delete device: please remove all sessions created by that device " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:445 +#: modules/FlaskModule/API/user/UserQueryDevices.py:450 msgid "" "Can't delete device: please delete all assets created by that device " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:448 +#: modules/FlaskModule/API/user/UserQueryDevices.py:453 msgid "" "Can't delete device: please delete all tests created by that device " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:451 +#: modules/FlaskModule/API/user/UserQueryDevices.py:456 msgid "" "Can't delete device: please remove all related sessions, assets and tests" " before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryDevices.py:469 +#: modules/FlaskModule/API/user/UserQueryDevices.py:474 msgid "Device successfully deleted" msgstr "" -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:49 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:57 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:50 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:58 msgid "Use Logout instead to disconnect current user" msgstr "" -#: modules/FlaskModule/API/user/UserQueryForms.py:82 +#: modules/FlaskModule/API/user/UserQueryForms.py:95 msgid "Missing type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryForms.py:137 +#: modules/FlaskModule/API/user/UserQueryForms.py:150 msgid "Missing session type id" msgstr "" -#: modules/FlaskModule/API/user/UserQueryForms.py:148 +#: modules/FlaskModule/API/user/UserQueryForms.py:161 msgid "No reply from service while querying session type config" msgstr "" -#: modules/FlaskModule/API/user/UserQueryForms.py:182 +#: modules/FlaskModule/API/user/UserQueryForms.py:195 msgid "Invalid service specified" msgstr "" -#: modules/FlaskModule/API/user/UserQueryForms.py:195 +#: modules/FlaskModule/API/user/UserQueryForms.py:208 msgid "Unknown form type: " msgstr "" #: modules/FlaskModule/API/user/UserQueryOnlineDevices.py:60 -#: modules/FlaskModule/API/user/UserQueryOnlineParticipants.py:69 -#: modules/FlaskModule/API/user/UserQueryOnlineUsers.py:57 +#: modules/FlaskModule/API/user/UserQueryOnlineParticipants.py:70 +#: modules/FlaskModule/API/user/UserQueryOnlineUsers.py:59 msgid "Internal server error when making RPC call." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:93 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:96 msgid "Missing group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:102 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:105 msgid "Missing id_participant_group or id_project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:170 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:175 msgid "" "Can't delete participant group: please delete all sessions from all " "participants before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:219 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:223 msgid "Missing participant" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:225 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:229 msgid "Missing id_participant" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:229 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:233 msgid "Missing id_project or id_participant_group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:244 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:248 msgid "No admin access to project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:252 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:256 msgid "No admin access to group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:261 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:265 msgid "Participant group not found." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:265 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:269 msgid "Mismatch between id_project and group's project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:382 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:388 msgid "Can't delete participant: please remove all related sessions beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:384 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:390 msgid "" "Can't delete participant: please remove all sessions created by this " "participant beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:387 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:393 msgid "Can't delete participant: please remove all related assets beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:389 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:395 msgid "Can't delete participant: please remove all related tests beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:391 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:397 msgid "" "Can't delete participant: please remove all related sessions, assets and " "tests before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:200 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:203 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:186 msgid "Missing role name or id" msgstr "" -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:231 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:234 msgid "Invalid role name or id for that project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:270 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:275 msgid "No project access to delete." msgstr "" -#: modules/FlaskModule/API/user/UserQueryProjects.py:169 +#: modules/FlaskModule/API/user/UserQueryProjects.py:173 msgid "No access to a session type for at least one of it" msgstr "" -#: modules/FlaskModule/API/user/UserQueryProjects.py:181 +#: modules/FlaskModule/API/user/UserQueryProjects.py:185 msgid "At least one session type is not associated to the project site" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:35 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:189 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:142 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:134 -#: modules/FlaskModule/API/user/UserQueryServices.py:128 +#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:37 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:192 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:138 +#: modules/FlaskModule/API/user/UserQueryServices.py:131 msgid "Missing id_service" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:38 +#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:40 msgid "No access to specified service" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:72 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:73 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:149 msgid "Can't combine id_user, id_participant and id_device in request" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:121 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:124 msgid "Missing service_config" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:175 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:198 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:178 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:201 msgid "Invalid config format provided" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:170 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:211 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:339 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:174 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:215 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:345 msgid "" "Can't delete service-project: please remove all related sessions, assets " "and tests before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:181 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:166 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:185 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:170 msgid "Missing services" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:249 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:253 msgid "" "At least one service is not part of the allowed service for that project " "site" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:311 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:317 msgid "Operation not completed" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:332 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:338 msgid "" "Can't delete service-project: please remove all sessions involving a " "session type using this project beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:335 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:341 msgid "Can't delete service-project: please remove all related assets beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:337 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:343 msgid "Can't delete service-project: please remove all related tests beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:185 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:278 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:159 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:189 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:284 msgid "" "Can't delete service from site: please delete all sessions, assets and " "tests related to that service beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQueryServices.py:150 +#: modules/FlaskModule/API/user/UserQueryServices.py:153 msgid "OpenTera service can't be updated using this API" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServices.py:164 -#: modules/FlaskModule/API/user/UserQueryServices.py:184 +#: modules/FlaskModule/API/user/UserQueryServices.py:167 +#: modules/FlaskModule/API/user/UserQueryServices.py:187 msgid "Invalid config json schema" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServices.py:168 +#: modules/FlaskModule/API/user/UserQueryServices.py:171 msgid "Missing service_key" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServices.py:236 +#: modules/FlaskModule/API/user/UserQueryServices.py:241 msgid "Invalid service" msgstr "" -#: modules/FlaskModule/API/user/UserQueryServices.py:251 +#: modules/FlaskModule/API/user/UserQueryServices.py:256 msgid "" "Can't delete service: please delete all sessions, assets and tests " "related to that service beforehand." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:154 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:186 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:158 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:190 msgid "" "Can't delete session type from project: please delete all sessions using " "that type in that project before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:163 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:160 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:167 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:164 msgid "Missing session types" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:212 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:216 msgid "At least one session type is not associated to the site of its project" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:283 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:289 msgid "" "Can't delete session type from project: please delete all sessions of " "that type in the project before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:150 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:185 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:278 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:154 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:189 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:284 msgid "" "Can't delete session type from site: please delete all sessions of that " "type in the site before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:95 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:99 msgid "Missing session_type" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:111 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:115 msgid "Missing site(s) to associate that session type to" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:136 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:140 msgid "Missing id_service for session type of type service" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:151 -#: modules/FlaskModule/API/user/UserQueryTestType.py:183 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:155 +#: modules/FlaskModule/API/user/UserQueryTestType.py:187 msgid "No site admin access for at least one site in the list" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:161 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:165 msgid "At least one site isn't associated with the service of that session type" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:178 -#: modules/FlaskModule/API/user/UserQueryTestType.py:206 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:182 +#: modules/FlaskModule/API/user/UserQueryTestType.py:210 msgid "No project admin access for at a least one project in the list" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:264 -#: modules/FlaskModule/API/user/UserQueryTestType.py:286 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:268 +#: modules/FlaskModule/API/user/UserQueryTestType.py:290 msgid "Session type not associated to project site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:292 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:296 msgid "Session type has a a service not associated to its site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:316 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:323 msgid "Cannot delete because you are not admin in all projects." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:321 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:328 msgid "Unable to delete - not admin in at least one project" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:334 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:341 msgid "" "Can't delete session type: please delete all sessions with that type " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:135 +#: modules/FlaskModule/API/user/UserQuerySessions.py:139 msgid "Missing session participants and users" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:141 +#: modules/FlaskModule/API/user/UserQuerySessions.py:145 msgid "No access to session." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:156 -#: modules/FlaskModule/API/user/UserQuerySessions.py:247 +#: modules/FlaskModule/API/user/UserQuerySessions.py:160 +#: modules/FlaskModule/API/user/UserQuerySessions.py:253 msgid "User doesn't have access to at least one participant of that session." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:161 -#: modules/FlaskModule/API/user/UserQuerySessions.py:252 +#: modules/FlaskModule/API/user/UserQuerySessions.py:165 +#: modules/FlaskModule/API/user/UserQuerySessions.py:258 msgid "User doesn't have access to at least one user of that session." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:166 -#: modules/FlaskModule/API/user/UserQuerySessions.py:257 +#: modules/FlaskModule/API/user/UserQuerySessions.py:170 +#: modules/FlaskModule/API/user/UserQuerySessions.py:263 msgid "User doesn't have access to at least one device of that session." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySessions.py:261 +#: modules/FlaskModule/API/user/UserQuerySessions.py:267 msgid "Session is in progress: can't delete that session." msgstr "" -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:181 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:184 msgid "Missing id_site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:230 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:233 msgid "Invalid role name or id for that site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:271 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:276 msgid "No site access to delete" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:115 +#: modules/FlaskModule/API/user/UserQuerySites.py:116 msgid "Missing site" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:122 +#: modules/FlaskModule/API/user/UserQuerySites.py:123 msgid "Missing id_site field" msgstr "" -#: modules/FlaskModule/API/user/UserQuerySites.py:191 +#: modules/FlaskModule/API/user/UserQuerySites.py:194 msgid "" "Can't delete site: please delete all participants with sessions before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryStats.py:94 +#: modules/FlaskModule/API/user/UserQueryStats.py:96 msgid "Missing id argument" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestType.py:151 +#: modules/FlaskModule/API/user/UserQueryTestType.py:155 msgid "Missing project(s) to associate that test type to" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestType.py:192 +#: modules/FlaskModule/API/user/UserQueryTestType.py:196 msgid "At least one site isn't associated with the service of that test type" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestType.py:306 +#: modules/FlaskModule/API/user/UserQueryTestType.py:310 msgid "Test type has a a service not associated to its site" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestType.py:328 +#: modules/FlaskModule/API/user/UserQueryTestType.py:334 msgid "Unable to delete - not admin in the related test type service" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestType.py:338 +#: modules/FlaskModule/API/user/UserQueryTestType.py:344 msgid "" "Can't delete test type: please delete all tests of that type before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:213 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:217 msgid "At least one test type is not associated to the site of its project" msgstr "" -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:143 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:176 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:266 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:147 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:180 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:272 msgid "" "Can't delete test type from site: please delete all tests of that type in" " the site before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryTests.py:123 +#: modules/FlaskModule/API/user/UserQueryTests.py:127 msgid "" "Test information update and creation must be done directly into a service" " (such as Test service)" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:30 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:32 msgid "No access to this API" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:46 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:48 msgid "Item to undelete not found" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:49 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:51 msgid "Item can't be undeleted" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:52 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:54 msgid "Item isn't deleted" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:145 msgid "Missing user group name" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:89 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:92 msgid "Missing app tag" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:57 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:59 msgid "At least one id must be specified" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:89 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:93 msgid "Missing user user group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:100 -#: modules/FlaskModule/API/user/UserQueryUsers.py:183 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:104 +#: modules/FlaskModule/API/user/UserQueryUsers.py:198 msgid "Missing id_user" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:106 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:110 msgid "No access to specified user" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:108 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:112 msgid "No access to specified user group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:113 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:117 msgid "Super admins can't be associated to an user group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:158 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:164 msgid "Can't delete specified relationship" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:161 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:167 msgid "No access to relationship's user" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:163 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:169 msgid "No access to relationship's user group" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:176 +#: modules/FlaskModule/API/user/UserQueryUsers.py:57 +msgid "Password not long enough" +msgstr "" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:191 msgid "Missing user" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:198 +#: modules/FlaskModule/API/user/UserQueryUsers.py:213 msgid "No access for at a least one user group in the list" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:229 +#: modules/FlaskModule/API/user/UserQueryUsers.py:244 msgid "Username can't be modified" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:250 +#: modules/FlaskModule/API/user/UserQueryUsers.py:257 +#: modules/FlaskModule/API/user/UserQueryUsers.py:295 +msgid "Password not strong enough" +msgstr "" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:271 msgid "Missing required fields: " msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:253 +#: modules/FlaskModule/API/user/UserQueryUsers.py:274 msgid "Invalid password" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:257 +#: modules/FlaskModule/API/user/UserQueryUsers.py:278 msgid "Username unavailable." msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:336 +#: modules/FlaskModule/API/user/UserQueryUsers.py:344 msgid "Sorry, you can't delete yourself!" msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:366 +#: modules/FlaskModule/API/user/UserQueryUsers.py:374 msgid "" "Can't delete user: please remove all sessions that this user is part of " "before deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:369 +#: modules/FlaskModule/API/user/UserQueryUsers.py:377 msgid "" "Can't delete user: please remove all sessions created by this user before" " deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:372 +#: modules/FlaskModule/API/user/UserQueryUsers.py:380 msgid "" "Can't delete user: please remove all tests created by this user before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:375 +#: modules/FlaskModule/API/user/UserQueryUsers.py:383 msgid "" "Can't delete user: please remove all assets created by this user before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryUsers.py:378 +#: modules/FlaskModule/API/user/UserQueryUsers.py:386 msgid "" "Can't delete user: please delete all assets created by this user before " "deleting." msgstr "" -#: modules/FlaskModule/API/user/UserQueryVersions.py:40 +#: modules/FlaskModule/API/user/UserQueryVersions.py:42 msgid "No version information found" msgstr "" -#: modules/FlaskModule/API/user/UserQueryVersions.py:71 +#: modules/FlaskModule/API/user/UserQueryVersions.py:75 msgid "Wrong ClientVersions" msgstr "" -#: modules/FlaskModule/API/user/UserQueryVersions.py:77 +#: modules/FlaskModule/API/user/UserQueryVersions.py:81 msgid "Not authorized" msgstr "" -#: modules/FlaskModule/API/user/UserSessionManager.py:129 +#: modules/FlaskModule/API/user/UserSessionManager.py:130 msgid "User doesn't have access to that session" msgstr "" -#: modules/FlaskModule/API/user/UserSessionManager.py:160 +#: modules/FlaskModule/API/user/UserSessionManager.py:161 msgid "User doesn't have access to that service." msgstr "" -#: modules/FlaskModule/API/user/UserSessionManager.py:165 +#: modules/FlaskModule/API/user/UserSessionManager.py:166 msgid "Missing parameters" msgstr "" -#: modules/FlaskModule/API/user/UserSessionManager.py:168 +#: modules/FlaskModule/API/user/UserSessionManager.py:169 msgid "Missing reply code in parameters" msgstr "" -#: modules/FlaskModule/API/user/UserSessionManager.py:181 -#: modules/FlaskModule/API/user/UserSessionManager.py:184 +#: modules/FlaskModule/API/user/UserSessionManager.py:182 +#: modules/FlaskModule/API/user/UserSessionManager.py:185 msgid "Invalid reply code" msgstr "" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:39 +#: modules/FlaskModule/Views/LoginChangePasswordView.py:38 msgid "Missing information" msgstr "" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:49 -msgid "New password and confirm password do not match" +#: modules/FlaskModule/Views/LoginChangePasswordView.py:53 +msgid "New password must be different from current" msgstr "" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:54 -msgid "Invalid old password" +#: modules/LoginModule/LoginModule.py:219 +msgid "Unauthorized - User must login first to change password" msgstr "" -#: modules/LoginModule/LoginModule.py:621 -#: modules/LoginModule/LoginModule.py:654 +#: modules/LoginModule/LoginModule.py:222 +msgid "Unauthorized - 2FA is enabled, must login first and use token" +msgstr "" + +#: modules/LoginModule/LoginModule.py:633 +#: modules/LoginModule/LoginModule.py:666 msgid "Disabled device" msgstr "" -#: modules/LoginModule/LoginModule.py:631 +#: modules/LoginModule/LoginModule.py:643 msgid "Invalid token" msgstr "" -#: modules/LoginModule/LoginModule.py:731 +#: modules/LoginModule/LoginModule.py:743 msgid "Invalid Token" msgstr "" @@ -1917,7 +1972,7 @@ msgid "Device Onlineable?" msgstr "" #: opentera/forms/TeraDeviceForm.py:44 opentera/forms/TeraParticipantForm.py:50 -#: opentera/forms/TeraUserForm.py:42 +#: opentera/forms/TeraUserForm.py:46 msgid "Last Connection" msgstr "" @@ -1929,8 +1984,8 @@ msgstr "" #: opentera/forms/TeraProjectForm.py:18 #: opentera/forms/TeraServiceConfigForm.py:18 #: opentera/forms/TeraServiceForm.py:18 opentera/forms/TeraSessionForm.py:120 -#: opentera/forms/TeraSessionTypeForm.py:25 opentera/forms/TeraSiteForm.py:12 -#: opentera/forms/TeraUserForm.py:13 opentera/forms/TeraUserGroupForm.py:18 +#: opentera/forms/TeraSessionTypeForm.py:25 opentera/forms/TeraSiteForm.py:13 +#: opentera/forms/TeraUserForm.py:14 opentera/forms/TeraUserGroupForm.py:18 #: opentera/forms/TeraVersionsForm.py:18 msgid "Information" msgstr "" @@ -1948,7 +2003,7 @@ msgid "Device Configuration" msgstr "" #: opentera/forms/TeraDeviceForm.py:55 opentera/forms/TeraDeviceForm.py:57 -#: opentera/forms/TeraUserForm.py:40 +#: opentera/forms/TeraUserForm.py:48 msgid "Notes" msgstr "" @@ -2054,7 +2109,7 @@ msgstr "" msgid "Role" msgstr "" -#: opentera/forms/TeraProjectForm.py:28 opentera/forms/TeraSiteForm.py:17 +#: opentera/forms/TeraProjectForm.py:28 opentera/forms/TeraSiteForm.py:18 msgid "Site Name" msgstr "" @@ -2072,7 +2127,7 @@ msgstr "" msgid "Service Config ID" msgstr "" -#: opentera/forms/TeraServiceConfigForm.py:27 opentera/forms/TeraUserForm.py:17 +#: opentera/forms/TeraServiceConfigForm.py:27 opentera/forms/TeraUserForm.py:18 msgid "User ID" msgstr "" @@ -2116,13 +2171,13 @@ msgstr "" msgid "Port" msgstr "" -#: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:20 -#: templates/login.html:122 +#: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:21 +#: templates/login.html:125 msgid "Username" msgstr "" -#: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:38 -#: templates/login.html:128 +#: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:44 +#: templates/login.html:131 msgid "Password" msgstr "" @@ -2294,15 +2349,15 @@ msgstr "" msgid "Session Type Configuration" msgstr "" -#: opentera/forms/TeraSiteForm.py:16 +#: opentera/forms/TeraSiteForm.py:17 msgid "Site ID" msgstr "" -#: opentera/forms/TeraSiteForm.py:18 +#: opentera/forms/TeraSiteForm.py:20 msgid "Users Require 2FA" msgstr "" -#: opentera/forms/TeraSiteForm.py:20 +#: opentera/forms/TeraSiteForm.py:22 msgid "Site Role" msgstr "" @@ -2334,51 +2389,51 @@ msgstr "" msgid "Expose Web editor" msgstr "" -#: opentera/forms/TeraUserForm.py:18 +#: opentera/forms/TeraUserForm.py:19 msgid "User UUID" msgstr "" -#: opentera/forms/TeraUserForm.py:19 +#: opentera/forms/TeraUserForm.py:20 msgid "User Full Name" msgstr "" -#: opentera/forms/TeraUserForm.py:21 +#: opentera/forms/TeraUserForm.py:22 msgid "User Enabled" msgstr "" -#: opentera/forms/TeraUserForm.py:23 +#: opentera/forms/TeraUserForm.py:26 msgid "Force password change" msgstr "" -#: opentera/forms/TeraUserForm.py:25 +#: opentera/forms/TeraUserForm.py:28 msgid "2FA Enabled" msgstr "" -#: opentera/forms/TeraUserForm.py:28 +#: opentera/forms/TeraUserForm.py:31 msgid "2FA OTP Enabled" msgstr "" -#: opentera/forms/TeraUserForm.py:30 +#: opentera/forms/TeraUserForm.py:34 msgid "2FA Email Enabled" msgstr "" -#: opentera/forms/TeraUserForm.py:34 +#: opentera/forms/TeraUserForm.py:40 msgid "First Name" msgstr "" -#: opentera/forms/TeraUserForm.py:35 +#: opentera/forms/TeraUserForm.py:41 msgid "Last Name" msgstr "" -#: opentera/forms/TeraUserForm.py:36 +#: opentera/forms/TeraUserForm.py:42 msgid "Email" msgstr "" -#: opentera/forms/TeraUserForm.py:39 +#: opentera/forms/TeraUserForm.py:45 msgid "User Is Super Administrator" msgstr "" -#: opentera/forms/TeraUserForm.py:41 +#: opentera/forms/TeraUserForm.py:49 msgid "Profile" msgstr "" @@ -2546,14 +2601,42 @@ msgstr "" msgid "OpenTera Login Page" msgstr "" -#: templates/login.html:86 +#: templates/login.html:89 msgid "Invalid username or password" msgstr "" -#: templates/login.html:136 +#: templates/login.html:139 msgid "Login" msgstr "" +#: templates/login_change_password.html:5 +msgid "OpenTera - Change Password" +msgstr "" + +#: templates/login_change_password.html:96 +msgid "Password successfully changed!" +msgstr "" + +#: templates/login_change_password.html:97 +msgid "Redirecting to login screen..." +msgstr "" + +#: templates/login_change_password.html:104 +msgid "Password change required" +msgstr "" + +#: templates/login_change_password.html:110 +msgid "New Password" +msgstr "" + +#: templates/login_change_password.html:115 +msgid "Confirm Password" +msgstr "" + +#: templates/login_change_password.html:122 +msgid "Change Password" +msgstr "" + #: templates/login_setup_2fa.html:107 msgid "You need to setup multi-factor authentication before continuing." msgstr "" @@ -2644,3 +2727,6 @@ msgstr "" #~ msgid "2FA enabled but OTP not set for this user. Please setup 2FA." #~ msgstr "" +#~ msgid "Invalid old password" +#~ msgstr "" + diff --git a/teraserver/python/translations/fr/LC_MESSAGES/messages.po b/teraserver/python/translations/fr/LC_MESSAGES/messages.po index 25fab0b1..399ede57 100644 --- a/teraserver/python/translations/fr/LC_MESSAGES/messages.po +++ b/teraserver/python/translations/fr/LC_MESSAGES/messages.po @@ -7,8 +7,8 @@ msgid "" msgstr "" "Project-Id-Version: PROJECT VERSION\n" "Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" -"POT-Creation-Date: 2024-10-01 15:00-0400\n" -"PO-Revision-Date: 2024-10-02 08:14-0400\n" +"POT-Creation-Date: 2024-10-07 15:05-0400\n" +"PO-Revision-Date: 2024-10-07 15:10-0400\n" "Last-Translator: \n" "Language-Team: fr \n" "Language: fr\n" @@ -19,165 +19,189 @@ msgstr "" "Generated-By: Babel 2.16.0\n" "X-Generator: Poedit 3.5\n" -#: modules/FlaskModule/API/device/DeviceLogin.py:88 +#: modules/FlaskModule/API/user/UserQueryUsers.py:53 +#: modules/FlaskModule/FlaskUtils.py:13 +msgid "Password missing special character" +msgstr "Le mot de passe requiert un caractère spécial" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:55 +#: modules/FlaskModule/FlaskUtils.py:15 +msgid "Password missing numeric character" +msgstr "Le mot de passe requiert au moins un chiffre" + +#: modules/FlaskModule/FlaskUtils.py:17 +msgid "Password not long enough (10 characters)" +msgstr "Le mot de passe n'est pas assez long (10 caractères min.)" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:59 +#: modules/FlaskModule/FlaskUtils.py:19 +msgid "Password missing lower case letter" +msgstr "Le mot de passe requiert au moins une minuscule" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:61 +#: modules/FlaskModule/FlaskUtils.py:21 +msgid "Password missing upper case letter" +msgstr "Le mot de passe requiert au moins une majuscule" + +#: modules/FlaskModule/API/device/DeviceLogin.py:90 msgid "Unable to get online devices." msgstr "Impossible d'obtenir les appareils connectés." -#: modules/FlaskModule/API/device/DeviceLogin.py:104 +#: modules/FlaskModule/API/device/DeviceLogin.py:106 msgid "Device already logged in." msgstr "L'appareil est déjà connecté." -#: modules/FlaskModule/API/device/DeviceLogout.py:29 +#: modules/FlaskModule/API/device/DeviceLogout.py:32 msgid "Device logged out." msgstr "Appareil déconnecté." -#: modules/FlaskModule/API/device/DeviceLogout.py:31 +#: modules/FlaskModule/API/device/DeviceLogout.py:34 msgid "Device not logged in" msgstr "L'appareil n'est pas connecté" -#: modules/FlaskModule/API/device/DeviceQueryAssets.py:39 -#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:46 +#: modules/FlaskModule/API/device/DeviceQueryAssets.py:42 +#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:48 msgid "No access to session" msgstr "Aucun accès à la séance" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:69 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:75 msgid "Missing device schema" msgstr "Schéma de l'appareil manquant" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:78 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:84 msgid "Missing config" msgstr "Configuration manquante" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:82 -#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:42 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:49 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:55 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:62 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:68 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:74 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:80 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:52 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:66 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:121 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:144 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:179 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:72 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:79 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:89 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:52 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:57 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:99 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:106 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:158 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:112 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:100 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:104 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:108 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:116 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:194 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:86 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:88 +#: modules/FlaskModule/API/participant/ParticipantQueryAssets.py:44 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:51 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:57 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:64 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:70 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:76 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:82 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:55 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:69 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:127 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:150 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:188 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:75 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:82 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:92 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:55 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:60 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:105 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:112 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:167 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:121 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:106 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:110 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:114 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:122 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:203 #: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:91 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:66 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:72 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:76 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:84 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:90 -#: modules/FlaskModule/API/service/ServiceQuerySites.py:36 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:121 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:159 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:193 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:256 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:96 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:101 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:50 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:54 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:106 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:207 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:117 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:126 -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:137 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:301 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:122 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:257 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:112 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:165 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:105 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:191 -#: modules/FlaskModule/API/user/UserQueryDevices.py:293 -#: modules/FlaskModule/API/user/UserQueryDevices.py:297 -#: modules/FlaskModule/API/user/UserQueryDevices.py:417 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:52 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:60 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:66 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:72 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:78 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:84 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:107 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:159 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:362 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:365 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:204 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:274 -#: modules/FlaskModule/API/user/UserQueryProjects.py:151 -#: modules/FlaskModule/API/user/UserQueryProjects.py:156 -#: modules/FlaskModule/API/user/UserQueryProjects.py:264 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:117 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:122 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:96 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:69 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:75 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:79 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:87 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:93 +#: modules/FlaskModule/API/service/ServiceQuerySites.py:39 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:127 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:165 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:199 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:265 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:102 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:107 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:52 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:56 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:111 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:215 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:116 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:125 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:136 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:306 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:125 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:262 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:115 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:170 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:107 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:195 +#: modules/FlaskModule/API/user/UserQueryDevices.py:296 +#: modules/FlaskModule/API/user/UserQueryDevices.py:300 +#: modules/FlaskModule/API/user/UserQueryDevices.py:422 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:53 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:61 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:67 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:73 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:79 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:85 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:110 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:164 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:368 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:371 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:207 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:279 +#: modules/FlaskModule/API/user/UserQueryProjects.py:155 +#: modules/FlaskModule/API/user/UserQueryProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryProjects.py:270 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:121 #: modules/FlaskModule/API/user/UserQueryServiceAccess.py:126 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:134 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:213 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:217 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:221 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:225 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:151 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:159 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:165 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:240 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:130 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:138 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:219 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:223 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:227 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:231 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:154 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:158 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:162 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:168 #: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:245 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:249 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:253 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:79 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:136 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:129 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:259 -#: modules/FlaskModule/API/user/UserQueryServices.py:121 -#: modules/FlaskModule/API/user/UserQueryServices.py:134 -#: modules/FlaskModule/API/user/UserQueryServices.py:231 -#: modules/FlaskModule/API/user/UserQueryServices.py:239 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:91 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:144 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:204 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:203 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:267 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:55 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:107 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:138 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:187 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:275 -#: modules/FlaskModule/API/user/UserQuerySites.py:127 -#: modules/FlaskModule/API/user/UserQuerySites.py:131 -#: modules/FlaskModule/API/user/UserQuerySites.py:180 -#: modules/FlaskModule/API/user/UserQueryStats.py:52 -#: modules/FlaskModule/API/user/UserQueryStats.py:57 -#: modules/FlaskModule/API/user/UserQueryStats.py:62 -#: modules/FlaskModule/API/user/UserQueryStats.py:68 -#: modules/FlaskModule/API/user/UserQueryStats.py:73 -#: modules/FlaskModule/API/user/UserQueryStats.py:81 -#: modules/FlaskModule/API/user/UserQueryStats.py:86 -#: modules/FlaskModule/API/user/UserQueryStats.py:91 -#: modules/FlaskModule/API/user/UserQueryTestType.py:62 -#: modules/FlaskModule/API/user/UserQueryTestType.py:169 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:205 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:193 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:256 -#: modules/FlaskModule/API/user/UserQueryTests.py:140 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:147 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:47 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:96 -#: modules/FlaskModule/API/user/UserQueryUsers.py:214 -#: modules/FlaskModule/API/user/UserQueryUsers.py:219 -#: modules/FlaskModule/API/user/UserQueryUsers.py:348 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:250 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:254 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:258 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:83 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:142 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:133 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:265 +#: modules/FlaskModule/API/user/UserQueryServices.py:124 +#: modules/FlaskModule/API/user/UserQueryServices.py:137 +#: modules/FlaskModule/API/user/UserQueryServices.py:236 +#: modules/FlaskModule/API/user/UserQueryServices.py:244 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:94 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:149 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:208 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:207 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:273 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:57 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:111 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:142 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:190 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:280 +#: modules/FlaskModule/API/user/UserQuerySites.py:128 +#: modules/FlaskModule/API/user/UserQuerySites.py:132 +#: modules/FlaskModule/API/user/UserQuerySites.py:183 +#: modules/FlaskModule/API/user/UserQueryStats.py:54 +#: modules/FlaskModule/API/user/UserQueryStats.py:59 +#: modules/FlaskModule/API/user/UserQueryStats.py:64 +#: modules/FlaskModule/API/user/UserQueryStats.py:70 +#: modules/FlaskModule/API/user/UserQueryStats.py:75 +#: modules/FlaskModule/API/user/UserQueryStats.py:83 +#: modules/FlaskModule/API/user/UserQueryStats.py:88 +#: modules/FlaskModule/API/user/UserQueryStats.py:93 +#: modules/FlaskModule/API/user/UserQueryTestType.py:64 +#: modules/FlaskModule/API/user/UserQueryTestType.py:173 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:209 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:197 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:262 +#: modules/FlaskModule/API/user/UserQueryTests.py:147 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:150 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:48 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:99 +#: modules/FlaskModule/API/user/UserQueryUsers.py:229 +#: modules/FlaskModule/API/user/UserQueryUsers.py:234 +#: modules/FlaskModule/API/user/UserQueryUsers.py:356 #: opentera/services/ServiceAccessManager.py:116 #: opentera/services/ServiceAccessManager.py:166 #: opentera/services/ServiceAccessManager.py:195 @@ -193,492 +217,492 @@ msgstr "Configuration manquante" msgid "Forbidden" msgstr "Accès refusé" -#: modules/FlaskModule/API/device/DeviceQueryDevices.py:93 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:74 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:89 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:157 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:183 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:233 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:247 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:284 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:136 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:153 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:201 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:116 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:138 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:176 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:73 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:94 -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:123 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:138 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:150 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:172 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:205 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:104 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:119 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:185 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:218 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:233 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:274 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:111 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:132 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:166 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:231 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:242 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:277 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:151 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:168 -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:225 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:180 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:221 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:276 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:331 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:232 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:268 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:130 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:145 -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:187 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:121 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:136 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:189 -#: modules/FlaskModule/API/user/UserQueryDevices.py:328 -#: modules/FlaskModule/API/user/UserQueryDevices.py:343 -#: modules/FlaskModule/API/user/UserQueryDevices.py:460 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:120 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:135 -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:178 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:244 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:285 -#: modules/FlaskModule/API/user/UserQueryProjects.py:196 -#: modules/FlaskModule/API/user/UserQueryProjects.py:211 -#: modules/FlaskModule/API/user/UserQueryProjects.py:282 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:156 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:168 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:190 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:236 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:208 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:264 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:287 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:322 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:98 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:119 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:147 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:244 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:286 -#: modules/FlaskModule/API/user/UserQueryServices.py:162 -#: modules/FlaskModule/API/user/UserQueryServices.py:182 -#: modules/FlaskModule/API/user/UserQueryServices.py:259 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:104 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:119 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:155 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:251 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:291 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:244 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:286 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:197 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:212 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:342 -#: modules/FlaskModule/API/user/UserQuerySessions.py:179 -#: modules/FlaskModule/API/user/UserQuerySessions.py:194 -#: modules/FlaskModule/API/user/UserQuerySessions.py:272 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:246 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:286 -#: modules/FlaskModule/API/user/UserQuerySites.py:144 -#: modules/FlaskModule/API/user/UserQuerySites.py:159 -#: modules/FlaskModule/API/user/UserQuerySites.py:198 -#: modules/FlaskModule/API/user/UserQueryTestType.py:222 -#: modules/FlaskModule/API/user/UserQueryTestType.py:237 -#: modules/FlaskModule/API/user/UserQueryTestType.py:345 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:252 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:293 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:233 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:274 -#: modules/FlaskModule/API/user/UserQueryTests.py:151 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:190 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:205 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:233 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:262 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:310 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:111 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:138 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:174 -#: modules/FlaskModule/API/user/UserQueryUsers.py:240 -#: modules/FlaskModule/API/user/UserQueryUsers.py:272 -#: modules/FlaskModule/API/user/UserQueryUsers.py:385 +#: modules/FlaskModule/API/device/DeviceQueryDevices.py:99 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:79 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:94 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:162 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:188 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:239 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:253 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:293 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:142 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:159 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:210 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:122 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:144 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:185 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:79 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:100 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:132 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:144 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:156 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:178 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:214 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:109 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:124 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:191 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:224 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:239 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:283 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:117 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:138 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:175 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:237 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:248 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:286 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:156 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:173 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:233 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:183 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:226 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:279 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:336 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:235 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:273 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:133 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:148 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:192 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:123 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:138 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:193 +#: modules/FlaskModule/API/user/UserQueryDevices.py:331 +#: modules/FlaskModule/API/user/UserQueryDevices.py:346 +#: modules/FlaskModule/API/user/UserQueryDevices.py:465 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:123 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:138 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:247 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:290 +#: modules/FlaskModule/API/user/UserQueryProjects.py:200 +#: modules/FlaskModule/API/user/UserQueryProjects.py:215 +#: modules/FlaskModule/API/user/UserQueryProjects.py:288 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:160 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:172 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:194 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:242 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:211 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:269 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:291 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:328 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:102 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:123 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:153 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:248 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:292 +#: modules/FlaskModule/API/user/UserQueryServices.py:165 +#: modules/FlaskModule/API/user/UserQueryServices.py:185 +#: modules/FlaskModule/API/user/UserQueryServices.py:264 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:107 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:122 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:160 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:255 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:297 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:248 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:292 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:201 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:216 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:349 +#: modules/FlaskModule/API/user/UserQuerySessions.py:183 +#: modules/FlaskModule/API/user/UserQuerySessions.py:198 +#: modules/FlaskModule/API/user/UserQuerySessions.py:278 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:249 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:291 +#: modules/FlaskModule/API/user/UserQuerySites.py:145 +#: modules/FlaskModule/API/user/UserQuerySites.py:160 +#: modules/FlaskModule/API/user/UserQuerySites.py:201 +#: modules/FlaskModule/API/user/UserQueryTestType.py:226 +#: modules/FlaskModule/API/user/UserQueryTestType.py:241 +#: modules/FlaskModule/API/user/UserQueryTestType.py:351 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:256 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:299 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:237 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:280 +#: modules/FlaskModule/API/user/UserQueryTests.py:158 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:193 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:208 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:236 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:265 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:315 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:114 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:180 +#: modules/FlaskModule/API/user/UserQueryUsers.py:255 +#: modules/FlaskModule/API/user/UserQueryUsers.py:293 +#: modules/FlaskModule/API/user/UserQueryUsers.py:393 msgid "Database error" msgstr "Erreur de base de données" #: modules/FlaskModule/API/device/DeviceQueryParticipants.py:41 -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:82 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:72 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:67 -#: modules/FlaskModule/API/service/ServiceQueryServices.py:76 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:62 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:122 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:109 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:86 -#: modules/FlaskModule/API/user/UserQueryProjects.py:116 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:84 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:123 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:67 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:112 -#: modules/FlaskModule/API/user/UserQueryServices.py:104 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:62 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:101 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:79 -#: modules/FlaskModule/API/user/UserQuerySessions.py:103 -#: modules/FlaskModule/API/user/UserQuerySites.py:100 -#: modules/FlaskModule/API/user/UserQueryTestType.py:112 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:99 -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:67 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:84 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:75 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:70 +#: modules/FlaskModule/API/service/ServiceQueryServices.py:79 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:64 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:125 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:110 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:87 +#: modules/FlaskModule/API/user/UserQueryProjects.py:118 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:86 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:125 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:69 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:114 +#: modules/FlaskModule/API/user/UserQueryServices.py:105 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:63 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:103 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:81 +#: modules/FlaskModule/API/user/UserQuerySessions.py:105 +#: modules/FlaskModule/API/user/UserQuerySites.py:99 +#: modules/FlaskModule/API/user/UserQueryTestType.py:114 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:101 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:68 msgid "Invalid request" msgstr "Requête invalide" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:31 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:97 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:72 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:193 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:33 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:105 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:74 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:201 msgid "Forbidden for security reasons" msgstr "Accès interdit pour raison de sécurité" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:47 -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:53 -#: modules/FlaskModule/API/device/DeviceQueryStatus.py:48 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:270 -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:73 -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:87 -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:76 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:104 -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:119 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:49 -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:46 -#: modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py:43 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:55 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:152 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:263 -#: modules/FlaskModule/API/service/ServiceQueryUsers.py:38 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:70 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:65 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:61 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:157 -#: modules/FlaskModule/API/user/UserQueryForms.py:85 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:76 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:81 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:53 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:63 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:60 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:46 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:62 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:59 -#: modules/FlaskModule/API/user/UserQuerySessions.py:59 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:80 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:62 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:60 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:52 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:58 +#: modules/FlaskModule/API/device/DeviceQueryStatus.py:50 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:279 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:71 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:88 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:79 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:107 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:125 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:52 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:48 +#: modules/FlaskModule/API/service/ServiceQuerySiteProjectAccessRoles.py:46 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:58 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:161 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:272 +#: modules/FlaskModule/API/service/ServiceQueryUsers.py:41 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:71 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:66 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:62 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:161 +#: modules/FlaskModule/API/user/UserQueryForms.py:98 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:78 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:82 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:55 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:65 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:62 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:47 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:64 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:61 +#: modules/FlaskModule/API/user/UserQuerySessions.py:61 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:81 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:64 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:62 msgid "Missing arguments" msgstr "Arguments manquants" -#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:61 -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:137 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:164 -#: modules/LoginModule/LoginModule.py:586 modules/LoginModule/LoginModule.py:686 -#: modules/LoginModule/LoginModule.py:752 modules/LoginModule/LoginModule.py:779 -#: modules/LoginModule/LoginModule.py:798 modules/LoginModule/LoginModule.py:817 -#: modules/LoginModule/LoginModule.py:819 +#: modules/FlaskModule/API/device/DeviceQuerySessionEvents.py:66 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:142 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:169 +#: modules/LoginModule/LoginModule.py:598 modules/LoginModule/LoginModule.py:698 +#: modules/LoginModule/LoginModule.py:764 modules/LoginModule/LoginModule.py:791 +#: modules/LoginModule/LoginModule.py:810 modules/LoginModule/LoginModule.py:829 +#: modules/LoginModule/LoginModule.py:831 msgid "Unauthorized" msgstr "Non autorisé" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:87 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:115 -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:136 -#: modules/FlaskModule/API/user/UserQuerySessions.py:120 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:92 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:120 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:142 +#: modules/FlaskModule/API/user/UserQuerySessions.py:124 msgid "Missing session" msgstr "Champ session manquant" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:95 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:123 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:100 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:128 msgid "Missing id_session value" msgstr "Champ id_session manquant" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:99 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:127 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:104 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:132 msgid "Missing id_session_type value" msgstr "Champ id_session_type manquant" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:104 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:132 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:109 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:137 msgid "Missing session participants and/or users and/or devices" msgstr "Utilisateurs et/ou participants et/ou appareils manquants pour la séance" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:114 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:141 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:119 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:146 msgid "No access to session type" msgstr "Aucun accès au type de séance" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:119 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:146 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:124 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:151 msgid "Missing argument 'session name'" msgstr "Paramètre 'session name' manquant" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:121 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:148 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:126 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:153 msgid "Missing argument 'session_start_datetime'" msgstr "Paramètre 'session_start_datetime' manquant" -#: modules/FlaskModule/API/device/DeviceQuerySessions.py:170 -#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:196 -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:130 +#: modules/FlaskModule/API/device/DeviceQuerySessions.py:175 +#: modules/FlaskModule/API/participant/ParticipantQuerySessions.py:201 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:132 msgid "Invalid participant uuid" msgstr "UUID participant invalide" -#: modules/FlaskModule/API/device/DeviceQueryStatus.py:67 +#: modules/FlaskModule/API/device/DeviceQueryStatus.py:69 msgid "Status update forbidden on offline device." msgstr "Mise à jour de l'état interdite sur un appareil hors-ligne." -#: modules/FlaskModule/API/device/DeviceRegister.py:73 -#: modules/FlaskModule/API/device/DeviceRegister.py:99 +#: modules/FlaskModule/API/device/DeviceRegister.py:76 +#: modules/FlaskModule/API/device/DeviceRegister.py:105 msgid "Invalid registration key" msgstr "Clé d'enregistrement invalide" -#: modules/FlaskModule/API/device/DeviceRegister.py:103 +#: modules/FlaskModule/API/device/DeviceRegister.py:109 msgid "Invalid content type" msgstr "Content-Type invalide" -#: modules/FlaskModule/API/device/DeviceRegister.py:137 +#: modules/FlaskModule/API/device/DeviceRegister.py:143 msgid "Invalid CSR signature" msgstr "La signature du certificat CSR est invalides" -#: modules/FlaskModule/API/participant/ParticipantLogin.py:93 +#: modules/FlaskModule/API/participant/ParticipantLogin.py:96 msgid "Participant already logged in." msgstr "Le participant est déjà connecté." -#: modules/FlaskModule/API/participant/ParticipantLogin.py:135 +#: modules/FlaskModule/API/participant/ParticipantLogin.py:138 msgid "Missing current_participant" msgstr "Champ current_participant manquant" -#: modules/FlaskModule/API/participant/ParticipantLogout.py:41 +#: modules/FlaskModule/API/participant/ParticipantLogout.py:44 msgid "Participant logged out." msgstr "Participant hors ligne." -#: modules/FlaskModule/API/participant/ParticipantLogout.py:43 +#: modules/FlaskModule/API/participant/ParticipantLogout.py:46 msgid "Participant not logged in" msgstr "Le participant n'est pas connecté" -#: modules/FlaskModule/API/participant/ParticipantQueryParticipants.py:55 +#: modules/FlaskModule/API/participant/ParticipantQueryParticipants.py:58 msgid "Not implemented" msgstr "Non implémenté" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:57 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:61 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:57 -#: modules/FlaskModule/API/user/UserQueryAssets.py:60 -#: modules/FlaskModule/API/user/UserQueryTests.py:54 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:59 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:64 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:60 +#: modules/FlaskModule/API/user/UserQueryAssets.py:61 +#: modules/FlaskModule/API/user/UserQueryTests.py:56 msgid "No arguments specified" msgstr "Aucun argument spécifié" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:67 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:69 msgid "Missing at least one from argument for uuids" msgstr "Au moins un champ \"UUID\" manquant" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:77 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:79 msgid "Invalid user uuid" msgstr "UUID d'utilisateur invalide" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:126 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:128 msgid "Participant cannot be admin" msgstr "Un participant ne peut être administrateur" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:163 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:165 msgid "Device cannot be admin" msgstr "Un appareil ne peut être administrateur" -#: modules/FlaskModule/API/service/ServiceQueryAccess.py:167 +#: modules/FlaskModule/API/service/ServiceQueryAccess.py:169 msgid "Invalid device uuid" msgstr "UUID de l'appareil invalide" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:64 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:90 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:60 -#: modules/FlaskModule/API/user/UserQueryAssets.py:63 -#: modules/FlaskModule/API/user/UserQueryAssets.py:91 -#: modules/FlaskModule/API/user/UserQueryTests.py:57 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:67 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:93 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:63 +#: modules/FlaskModule/API/user/UserQueryAssets.py:64 +#: modules/FlaskModule/API/user/UserQueryAssets.py:92 +#: modules/FlaskModule/API/user/UserQueryTests.py:59 msgid "Device access denied" msgstr "Accès à l'appareil interdit" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:68 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:64 -#: modules/FlaskModule/API/user/UserQueryAssets.py:67 -#: modules/FlaskModule/API/user/UserQueryTests.py:61 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:71 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:67 +#: modules/FlaskModule/API/user/UserQueryAssets.py:68 +#: modules/FlaskModule/API/user/UserQueryTests.py:63 msgid "Session access denied" msgstr "Accès à la session refusé" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:72 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:86 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:68 -#: modules/FlaskModule/API/user/UserQueryAssets.py:71 -#: modules/FlaskModule/API/user/UserQueryAssets.py:87 -#: modules/FlaskModule/API/user/UserQueryTests.py:65 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:75 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:89 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:71 +#: modules/FlaskModule/API/user/UserQueryAssets.py:72 +#: modules/FlaskModule/API/user/UserQueryAssets.py:88 +#: modules/FlaskModule/API/user/UserQueryTests.py:67 msgid "Participant access denied" msgstr "Accès au participant refusé" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:76 -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:82 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:72 -#: modules/FlaskModule/API/user/UserQueryAssets.py:75 -#: modules/FlaskModule/API/user/UserQueryAssets.py:83 -#: modules/FlaskModule/API/user/UserQueryTests.py:69 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:79 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:85 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:75 +#: modules/FlaskModule/API/user/UserQueryAssets.py:76 +#: modules/FlaskModule/API/user/UserQueryAssets.py:84 +#: modules/FlaskModule/API/user/UserQueryTests.py:71 msgid "User access denied" msgstr "Accès à l'utilisateur refusé" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:97 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:80 -#: modules/FlaskModule/API/user/UserQueryAssets.py:100 -#: modules/FlaskModule/API/user/UserQueryTests.py:76 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:100 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:83 +#: modules/FlaskModule/API/user/UserQueryAssets.py:101 +#: modules/FlaskModule/API/user/UserQueryTests.py:78 msgid "Missing argument" msgstr "Paramètre manquant" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:163 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:169 msgid "Missing asset field" msgstr "Champ asset manquant" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:169 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:175 msgid "Missing id_asset field" msgstr "Champ id_asset manquant" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:173 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:141 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:179 #: modules/FlaskModule/API/service/ServiceQueryTests.py:147 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:153 msgid "Unknown session" msgstr "Séance inconnue" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:176 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:182 msgid "Invalid asset type" msgstr "Mauvais type de données (asset)" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:179 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:185 msgid "Invalid asset name" msgstr "Nom de donnée (asset) invalide" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:189 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:195 msgid "Service can't create assets for that session" msgstr "Le service ne peut pas créer de données pour cette séance" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:196 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:196 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:202 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:202 msgid "Invalid participant" msgstr "Nom de participant incorrect" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:204 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:204 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:210 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:210 msgid "Invalid user" msgstr "Utilisateur invalide" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:212 -#: modules/FlaskModule/API/service/ServiceQueryTests.py:212 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:218 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:218 msgid "Invalid device" msgstr "Appareil invalide" -#: modules/FlaskModule/API/service/ServiceQueryAssets.py:273 +#: modules/FlaskModule/API/service/ServiceQueryAssets.py:282 msgid "Service can't delete assets for that session" msgstr "Le service ne peut pas effacer les données de cette séance" -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:93 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:94 msgid "Unknown device type" msgstr "Type d’appareil inconnu" -#: modules/FlaskModule/API/service/ServiceQueryDevices.py:97 +#: modules/FlaskModule/API/service/ServiceQueryDevices.py:98 msgid "Unknown device subtype" msgstr "Sous-type d'appareil inconnu" -#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:84 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:88 +#: modules/FlaskModule/API/service/ServiceQueryDisconnect.py:86 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:89 msgid "Success" msgstr "Succès" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:57 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:191 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:252 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:203 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:296 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:209 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:63 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:308 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:266 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:270 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:271 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:60 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:200 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:261 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:208 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:301 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:215 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:64 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:314 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:272 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:276 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:277 msgid "Not found" msgstr "Non trouvé" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:95 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:101 msgid "Missing participant_group" msgstr "Groupe participant manquant" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:102 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:108 msgid "Missing id_participant_group" msgstr "ID groupe participant manquant" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:107 -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:93 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:180 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:198 -#: modules/FlaskModule/API/user/UserQueryProjects.py:138 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:179 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:113 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:99 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:201 +#: modules/FlaskModule/API/user/UserQueryProjects.py:142 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:183 msgid "Missing id_project" msgstr "Champ manquant : id_project" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:114 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:120 msgid "Missing group name" msgstr "Nom du groupe manquant" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:184 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:193 msgid "The id_participant_group given was not found" msgstr "L'ID du groupe participant n'a pu être trouvé" -#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:190 +#: modules/FlaskModule/API/service/ServiceQueryParticipantGroups.py:199 msgid "Deletion impossible: Participant group still has participant(s)" msgstr "Suppression impossible: le groupe participant a encore des participants" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:125 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:131 msgid "Unknown project" msgstr "Projet inconnu" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:128 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:134 msgid "Invalid participant name" msgstr "Nom de participant incorrect" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:131 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:137 msgid "Invalid participant email" msgstr "Courriel de participant incorrect" -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:155 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:311 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:161 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:315 msgid "Can't insert participant: participant's project is disabled or invalid." msgstr "" "Impossible d'ajouter le participant: le projet du participant est désactivé ou " "invalide." -#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:165 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:284 +#: modules/FlaskModule/API/service/ServiceQueryParticipants.py:171 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:288 msgid "Can't update participant: participant's project is disabled." msgstr "" "Impossible de mettre à jour le participant: le projet du participant est " "désactivé." -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:48 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:51 msgid "Missing parameter" msgstr "Paramètre manquant" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:87 -#: modules/FlaskModule/API/user/UserQueryProjects.py:132 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:93 +#: modules/FlaskModule/API/user/UserQueryProjects.py:136 msgid "Missing project" msgstr "Projet manquant" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:96 -#: modules/FlaskModule/API/user/UserQueryProjects.py:140 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:102 +#: modules/FlaskModule/API/user/UserQueryProjects.py:144 msgid "Missing id_site arguments" msgstr "Champ id_site manquant" -#: modules/FlaskModule/API/service/ServiceQueryProjects.py:169 -#: modules/FlaskModule/API/user/UserQueryProjects.py:275 +#: modules/FlaskModule/API/service/ServiceQueryProjects.py:178 +#: modules/FlaskModule/API/user/UserQueryProjects.py:281 msgid "" "Can't delete project: please delete all participants with sessions before " "deleting." @@ -686,119 +710,119 @@ msgstr "" "Impossible de supprimer le projet: veuillez supprimer tous les participants " "ayant des séances au préalable." -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:52 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:58 msgid "Missing service_role field" msgstr "Champ iservice_role manquant" -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:58 -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:154 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:172 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:85 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:64 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:160 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:176 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:89 msgid "Missing id_service_role" msgstr "Champ id_service_role manquant" -#: modules/FlaskModule/API/service/ServiceQueryRoles.py:81 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:119 -#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:106 +#: modules/FlaskModule/API/service/ServiceQueryRoles.py:87 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:125 +#: modules/FlaskModule/API/user/UserQueryServiceRoles.py:110 msgid "Missing fields" msgstr "Champs manquants" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:87 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:104 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:93 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:108 msgid "Missing service_access" msgstr "Accès Service manquant (service_access)" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:91 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:108 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:97 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:112 msgid "Missing id_service_access" msgstr "Champ manquant: id_service_access" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:95 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:112 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:101 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:116 msgid "Can't combine id_user_group, id_participant_group and id_device in request" msgstr "" "Ne peut pas combiner id_user_group, id_participant et id_device dans la requête" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:114 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:132 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:120 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:136 msgid "Bad id_service_role" msgstr "Mauvais id_service_role" -#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:158 -#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:176 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:192 +#: modules/FlaskModule/API/service/ServiceQueryServiceAccess.py:164 +#: modules/FlaskModule/API/user/UserQueryServiceAccess.py:180 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:195 msgid "Missing at least one id field" msgstr "Au moins un champ id manquant" -#: modules/FlaskModule/API/service/ServiceQueryServices.py:38 +#: modules/FlaskModule/API/service/ServiceQueryServices.py:41 msgid "Missing service key, id or uuid" msgstr "Clé, ID ou UUID de service manquant" -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:76 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:77 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:81 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:80 msgid "Missing session_event field" msgstr "Champ session_event manquant" -#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:82 -#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:83 +#: modules/FlaskModule/API/service/ServiceQuerySessionEvents.py:87 +#: modules/FlaskModule/API/user/UserQuerySessionEvents.py:86 msgid "Missing id_session or id_session_event fields" msgstr "Champs manquants: id_session ou id_session_event" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:95 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:98 msgid "Missing arguments: at least one id is required" msgstr "Au moins un champ id manquant" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:142 -#: modules/FlaskModule/API/user/UserQuerySessions.py:126 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:148 +#: modules/FlaskModule/API/user/UserQuerySessions.py:130 msgid "Missing id_session" msgstr "Champ id_session manquant" -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:157 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:163 msgid "Service doesn't have access to at least one participant of that session." msgstr "Le service n'a pas accès à au moins un participant de la séance." -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:164 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:170 msgid "Service doesn't have access to at least one user of that session." msgstr "Le service n'a pas accès à au moins un utilisateur de la séance." -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:171 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:177 msgid "Service doesn't have access to at least one device of that session." msgstr "Le service n'a pas accès à au moins un appareil de la séance." -#: modules/FlaskModule/API/service/ServiceQuerySessions.py:189 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:129 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:121 -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:101 +#: modules/FlaskModule/API/service/ServiceQuerySessions.py:195 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:133 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:125 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:105 msgid "Missing id_session_type" msgstr "Champ id_session_type manquant" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:115 -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:93 -#: modules/FlaskModule/API/user/UserQueryTestType.py:134 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:127 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:119 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:121 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:99 +#: modules/FlaskModule/API/user/UserQueryTestType.py:138 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:131 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:123 msgid "Missing id_test_type" msgstr "Champ id_test_type manquant" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:117 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:143 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:144 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:131 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:129 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:123 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:148 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:135 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:133 msgid "Missing projects" msgstr "Projets manquants" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:129 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:141 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:135 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:145 msgid "Access denied to at least one project" msgstr "Accès refusé pour au moins un projet" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:145 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:176 -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:266 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:157 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:285 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:151 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:182 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:275 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:161 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:192 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:291 msgid "" "Can't delete test type from project: please delete all tests of that type in the " "project before deleting." @@ -806,106 +830,106 @@ msgstr "" "Impossible de supprimer le type de test: veuillez supprimer tous les tests de ce " "type au préalable." -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:152 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:161 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:164 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:158 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:165 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:168 msgid "Missing project ID" msgstr "ID de projet manquant" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:154 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:166 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:152 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:170 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:156 msgid "Missing test types" msgstr "Types de test manquants" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:185 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:196 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:197 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:191 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:200 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:201 msgid "Unknown format" msgstr "Format inconnu" -#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:190 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:195 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:207 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:201 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:200 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:202 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:190 +#: modules/FlaskModule/API/service/ServiceQueryTestTypeProjects.py:196 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:198 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:211 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:205 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:204 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:206 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:194 msgid "Badly formatted request" msgstr "Requête mal formée" -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:87 -#: modules/FlaskModule/API/user/UserQueryTestType.py:128 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:93 +#: modules/FlaskModule/API/user/UserQueryTestType.py:132 msgid "Missing test_type" msgstr "Champ test_type manquant" -#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:155 +#: modules/FlaskModule/API/service/ServiceQueryTestTypes.py:164 msgid "Test type not related to this service. Can't delete." msgstr "Le type de test n’est pas associé au service. Impossible de supprimer." -#: modules/FlaskModule/API/service/ServiceQueryTests.py:131 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:137 msgid "Missing test field" msgstr "Champ \"test\" manquant" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:137 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:143 msgid "Missing id_test field" msgstr "Champ id_test manquant" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:154 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:160 msgid "Missing id_test_type field" msgstr "Champ id_test_type manquant" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:159 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:165 msgid "Invalid test type" msgstr "Type de test invalide" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:189 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:195 msgid "Service can't create tests for that session" msgstr "Le service ne peut pas créer de tests pour cette séance" -#: modules/FlaskModule/API/service/ServiceQueryTests.py:266 +#: modules/FlaskModule/API/service/ServiceQueryTests.py:275 msgid "Service can't delete tests for that session" msgstr "Le service ne peut pas effacer les tests de cette séance" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:81 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:132 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:86 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:135 msgid "Missing user_group" msgstr "Champ user_group manquant" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:88 -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:196 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:179 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:139 -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:102 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:93 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:199 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:182 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:106 msgid "Missing id_user_group" msgstr "Champ id_user_group manquant" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:100 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:105 msgid "Missing service role name or id_service_role" msgstr "Nom du rôle ou id_service_role manquant" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:113 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:118 msgid "Can't set access to service other than self" msgstr "Impossible d'ajuster les accès à un service autre que soi-même" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:119 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:124 msgid "No access for at a least one project in the list" msgstr "Aucun accès pour au moins un projet dans la liste" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:125 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:130 msgid "No access for at a least one site in the list" msgstr "Aucun accès pour au moins un site dans la liste" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:137 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:142 msgid "Bad role name for service" msgstr "Nom de rôle invalide pour le service" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:155 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:160 msgid "A new usergroup must have at least one service access" msgstr "Un nouveau groupe utilisateur doit avoir au moins un accès à un service" -#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:217 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:302 +#: modules/FlaskModule/API/service/ServiceQueryUserGroups.py:225 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:307 msgid "" "Can't delete user group: please delete all users part of that user group before " "deleting." @@ -913,56 +937,60 @@ msgstr "" "Impossible de supprimer le groupe d'utilisateurs: veuillez retirer tous les " "utilisateurs de ce groupe au préalable." -#: modules/FlaskModule/API/service/ServiceSessionManager.py:116 -#: modules/FlaskModule/API/user/UserSessionManager.py:107 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:118 +#: modules/FlaskModule/API/user/UserSessionManager.py:108 msgid "Missing action" msgstr "Action manquante" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:131 -#: modules/FlaskModule/API/user/UserSessionManager.py:121 -#: modules/FlaskModule/API/user/UserSessionManager.py:134 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:133 +#: modules/FlaskModule/API/user/UserSessionManager.py:122 +#: modules/FlaskModule/API/user/UserSessionManager.py:135 msgid "Invalid session" msgstr "Séance invalide" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:139 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:141 msgid "Service doesn't have access to that session" msgstr "Le service n'a pas accès à cette séance" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:160 -#: modules/FlaskModule/API/user/UserSessionManager.py:155 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:162 +#: modules/FlaskModule/API/user/UserSessionManager.py:156 msgid "Missing required id_session_type for new sessions" msgstr "Champ id_session_type manquant pour les nouvelles séances" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:166 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:168 msgid "Invalid session type" msgstr "Type de séance invalide" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:201 -#: modules/FlaskModule/API/user/UserSessionManager.py:192 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:203 +#: modules/FlaskModule/API/user/UserSessionManager.py:193 msgid "Service not found" msgstr "Service non trouvé" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:209 -#: modules/FlaskModule/API/user/UserSessionManager.py:197 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:211 +#: modules/FlaskModule/API/user/UserSessionManager.py:198 msgid "Not implemented yet" msgstr "Non encore implémenté" -#: modules/FlaskModule/API/service/ServiceSessionManager.py:224 -#: modules/FlaskModule/API/user/UserSessionManager.py:213 +#: modules/FlaskModule/API/service/ServiceSessionManager.py:226 +#: modules/FlaskModule/API/user/UserSessionManager.py:214 msgid "No answer from service." msgstr "Aucune réponse du service." -#: modules/FlaskModule/API/user/UserLogin.py:46 +#: modules/FlaskModule/API/user/UserLogin.py:41 +msgid "Password change required for this user." +msgstr "Changement de mot de passe requis pour cet utilisateur." + +#: modules/FlaskModule/API/user/UserLogin.py:52 msgid "2FA required for this user." msgstr "2FA requise pour cet utilisateur." -#: modules/FlaskModule/API/user/UserLogin.py:50 +#: modules/FlaskModule/API/user/UserLogin.py:56 msgid "2FA enabled but OTP not set for this user.Please setup 2FA." msgstr "" "Authentification multi-facteurs requise, mais non configurée pour cet " "utilisateur. Veuillez configurer celle-ci." -#: modules/FlaskModule/API/user/UserLogin.py:71 +#: modules/FlaskModule/API/user/UserLogin.py:79 #: modules/FlaskModule/API/user/UserLogin2FA.py:101 #: modules/FlaskModule/API/user/UserLoginBase.py:154 #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:85 @@ -1011,6 +1039,20 @@ msgstr "La version du client ne correspond pas" msgid "Unknown client name :" msgstr "Nom du client inconnu :" +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:34 +#: modules/FlaskModule/Views/LoginChangePasswordView.py:48 +msgid "New password and confirm password do not match" +msgstr "Le nouveau et l'ancien mot de passe ne correspondent pas" + +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:37 +msgid "User not required to change password" +msgstr "L'utilisateur n'a pas à changer son mot de passe" + +#: modules/FlaskModule/API/user/UserLoginChangePassword.py:47 +#: modules/FlaskModule/API/user/UserQueryUsers.py:260 +msgid "New password same as old password" +msgstr "Le nouveau mot de passe est identique à l'ancien" + #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:53 #: modules/FlaskModule/API/user/UserLoginSetup2FA.py:123 msgid "User already has 2FA OTP secret set" @@ -1020,19 +1062,19 @@ msgstr "L'utilisateur a déjà configuré la double authentification par OTP" msgid "2FA enabled for this user." msgstr "Double authentification activée pour cet utilisateur." -#: modules/FlaskModule/API/user/UserLogout.py:33 +#: modules/FlaskModule/API/user/UserLogout.py:36 msgid "User logged out." msgstr "Utilisateur déconnecté." -#: modules/FlaskModule/API/user/UserLogout.py:35 +#: modules/FlaskModule/API/user/UserLogout.py:38 msgid "User not logged in" msgstr "L'utilisateur n'est pas connecté" -#: modules/FlaskModule/API/user/UserQueryAssets.py:79 +#: modules/FlaskModule/API/user/UserQueryAssets.py:80 msgid "Service access denied" msgstr "Accès au service refusé" -#: modules/FlaskModule/API/user/UserQueryAssets.py:174 +#: modules/FlaskModule/API/user/UserQueryAssets.py:177 msgid "" "Asset information update and creation must be done directly into a service (such " "as Filetransfer service)" @@ -1041,7 +1083,7 @@ msgstr "" "directement dans un service (comme le service de transfert de fichiers - " "FileTransfer)" -#: modules/FlaskModule/API/user/UserQueryAssets.py:182 +#: modules/FlaskModule/API/user/UserQueryAssets.py:187 msgid "" "Asset information deletion must be done directly into a service (such as " "Filetransfer service)" @@ -1049,62 +1091,62 @@ msgstr "" "La suppression d'information sur les ressources doivent être fait directement " "dans un service (comme le service de transfert de fichiers - FileTransfer)" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:89 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:88 msgid "Only one of the ID parameter is supported at once" msgstr "Un seul des paramètres d'ID est supporté à la fois" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:152 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:151 msgid "Missing required parameter" msgstr "Paramètre requis manquant" -#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:189 +#: modules/FlaskModule/API/user/UserQueryAssetsArchive.py:188 msgid "Unable to create archive information from FileTransferService" msgstr "" "Impossible de créer les informations de l'archive dans le service de transfert " "de fichiers" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:128 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:207 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:131 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:212 msgid "User is not admin of the participant's project" msgstr "L’utilisateur n’est pas administrateur du projet du participant" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:131 -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:210 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:134 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:215 msgid "Access denied to device" msgstr "Aucun accès à l’appareil" -#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:149 +#: modules/FlaskModule/API/user/UserQueryDeviceParticipants.py:152 msgid "Device not assigned to project or participant" msgstr "Appareil non assigné à un projet ou un participant" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:141 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:127 -#: modules/FlaskModule/API/user/UserQueryDevices.py:259 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:144 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:130 +#: modules/FlaskModule/API/user/UserQueryDevices.py:262 msgid "Missing id_device" msgstr "Champ manquant : id_device" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:157 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:165 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:232 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:188 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:243 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:135 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:168 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:273 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:127 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:165 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:133 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:171 -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:275 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:125 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:157 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:160 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:168 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:191 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:235 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:192 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:247 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:139 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:172 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:279 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:131 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:169 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:137 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:175 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:281 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:129 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:161 msgid "Access denied" msgstr "Accès refusé" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:172 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:208 -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:321 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:175 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:211 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:326 msgid "" "Can't delete device from project. Please remove all participants associated with " "the device or all sessions in the project referring to the device before " @@ -1114,16 +1156,16 @@ msgstr "" "participants associés à cet appareil et/ou toutes les séances de ce projet " "impliquant cet appareil." -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:182 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:157 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:185 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:160 msgid "Missing devices" msgstr "Appareils manquants" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:238 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:241 msgid "At least one device is not part of the allowed device for that project site" msgstr "Au moins un appareil n'est pas admissible pour ce projet pour ce site" -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:314 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:319 msgid "" "Can't delete device from project: please remove all participants with device " "before deleting." @@ -1131,7 +1173,7 @@ msgstr "" "Impossible de retirer l'appareil du projet: veuillez désassocier tous les " "participants liés à cet appareil dans ce projet au préalable." -#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:317 +#: modules/FlaskModule/API/user/UserQueryDeviceProjects.py:322 msgid "" "Can't delete device from project: please remove all sessions in this project " "referring to that device before deleting." @@ -1139,15 +1181,15 @@ msgstr "" "Impossible de retirer l'appareil du projet: veuillez retirer toutes les séances " "impliquant cet appareil dans ce projet au préalable." -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:129 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:136 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:123 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:121 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:132 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:140 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:127 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:125 msgid "Missing sites" msgstr "Sites manquants" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:148 -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:175 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:151 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:178 msgid "" "Can't delete device from site. Please remove all participants associated with " "the device or all sessions in the site referring to the device before deleting." @@ -1156,48 +1198,48 @@ msgstr "" "associés à cet appareil dans ce site et/ou toutes les séances de ce site " "impliquant cet appareil." -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:164 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:158 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:150 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:158 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:168 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:162 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:154 msgid "Missing site ID" msgstr "Champ id_site manquant" -#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:252 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:263 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:252 +#: modules/FlaskModule/API/user/UserQueryDeviceSites.py:257 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:269 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:258 msgid "Bad parameter" msgstr "Mauvais Paramètre(s)" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:50 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:51 msgid "Too Many IDs" msgstr "Trop d'IDs" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:58 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:59 msgid "No access to device subtype" msgstr "Aucun accès au sous-type d'appareil" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:64 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:65 msgid "No access to device type" msgstr "Aucun accès au type d'appareil" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:102 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:105 msgid "Missing device_subtype" msgstr "Champ device_subtype manquant" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:109 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:112 msgid "Missing id_device_subtype" msgstr "Champ id_device_subtype manquant" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:121 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:124 msgid "Invalid device subtype" msgstr "Sous-type d’appareil invalide" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:169 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:174 msgid "Device subtype not found" msgstr "Sous-type d'appareil non trouvé" -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:179 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:184 msgid "" "Can't delete device subtype: please delete all devices of that subtype before " "deleting." @@ -1205,7 +1247,7 @@ msgstr "" "Impossible de supprimer le sous-type d'appareil: veuillez supprimer ou modifier " "tous les appareils utilisant ce sous-type au préalable." -#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:189 +#: modules/FlaskModule/API/user/UserQueryDeviceSubTypes.py:194 msgid "Device subtype successfully deleted" msgstr "Sous-type d'appareil supprimé avec succès" @@ -1215,74 +1257,74 @@ msgid "Unexisting ID/Forbidden access" msgstr "ID inexistant/Accès interdit" #: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:79 -#: modules/FlaskModule/API/user/UserQueryParticipants.py:399 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:182 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:405 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:185 msgid "Database Error" msgstr "Erreur de bases de données" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:95 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:97 msgid "Missing device type" msgstr "Champ device_type manquant" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:102 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:104 msgid "Missing id_device_type" msgstr "Champ id_device_type manquant" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:113 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:115 msgid "Invalid device type" msgstr "Type d’appareil invalide" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:160 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:164 msgid "Tried to delete with 2 parameters" msgstr "Tentative de suppression avec 2 paramètres conflictuels" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:168 -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:171 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:172 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:175 msgid "Device type not found" msgstr "Type d'appareil non trouvé" -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:181 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:185 msgid "" "Can't delete device type: please delete all associated devices before deleting." msgstr "" "Impossible de supprimer le type d'appareil: veuillez supprimer tous les " "appareils associés au préalable." -#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:193 +#: modules/FlaskModule/API/user/UserQueryDeviceTypes.py:197 msgid "Device type successfully deleted" msgstr "Type d'appareil supprimé avec succès" -#: modules/FlaskModule/API/user/UserQueryDevices.py:130 +#: modules/FlaskModule/API/user/UserQueryDevices.py:131 msgid "Too many IDs" msgstr "Trop de paramètres ID" -#: modules/FlaskModule/API/user/UserQueryDevices.py:253 +#: modules/FlaskModule/API/user/UserQueryDevices.py:256 msgid "Missing device" msgstr "Appareil manquant" -#: modules/FlaskModule/API/user/UserQueryDevices.py:275 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:177 +#: modules/FlaskModule/API/user/UserQueryDevices.py:278 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:180 msgid "No site admin access for at a least one project in the list" msgstr "Aucun accès administrateur pour au moins un projet dans la liste" -#: modules/FlaskModule/API/user/UserQueryDevices.py:287 -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:158 +#: modules/FlaskModule/API/user/UserQueryDevices.py:290 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:161 msgid "No site admin access for at a least one site in the list" msgstr "Aucun administrateur de site pour au moins un site dans la liste" -#: modules/FlaskModule/API/user/UserQueryDevices.py:412 -#: modules/FlaskModule/API/user/UserQueryUsers.py:343 +#: modules/FlaskModule/API/user/UserQueryDevices.py:417 +#: modules/FlaskModule/API/user/UserQueryUsers.py:351 msgid "Invalid id" msgstr "ID invalide" -#: modules/FlaskModule/API/user/UserQueryDevices.py:436 +#: modules/FlaskModule/API/user/UserQueryDevices.py:441 msgid "" "Can't delete device: please delete all participants association before deleting." msgstr "" "Impossible de supprimer l'appareil: veuillez supprimer ou retirer tous les " "participants associés au préalable." -#: modules/FlaskModule/API/user/UserQueryDevices.py:439 +#: modules/FlaskModule/API/user/UserQueryDevices.py:444 msgid "" "Can't delete device: please remove all sessions referring to that device before " "deleting." @@ -1290,7 +1332,7 @@ msgstr "" "Impossible de supprimer l'appareil: veuillez retirer toutes les séances " "impliquant cet appareil." -#: modules/FlaskModule/API/user/UserQueryDevices.py:442 +#: modules/FlaskModule/API/user/UserQueryDevices.py:447 msgid "" "Can't delete device: please remove all sessions created by that device before " "deleting." @@ -1298,7 +1340,7 @@ msgstr "" "Impossible de supprimer l'appareil: veuillez retirer toutes les séances créées " "par cet appareil au préalable." -#: modules/FlaskModule/API/user/UserQueryDevices.py:445 +#: modules/FlaskModule/API/user/UserQueryDevices.py:450 msgid "" "Can't delete device: please delete all assets created by that device before " "deleting." @@ -1306,7 +1348,7 @@ msgstr "" "Impossible de supprimer l'appareil: veuillez supprimer toutes les ressources " "crées par cet appareil au préalable." -#: modules/FlaskModule/API/user/UserQueryDevices.py:448 +#: modules/FlaskModule/API/user/UserQueryDevices.py:453 msgid "" "Can't delete device: please delete all tests created by that device before " "deleting." @@ -1314,7 +1356,7 @@ msgstr "" "Impossible de supprimer l'appareil: veuillez supprimer tous les tests créés par " "cet appareil au préalable." -#: modules/FlaskModule/API/user/UserQueryDevices.py:451 +#: modules/FlaskModule/API/user/UserQueryDevices.py:456 msgid "" "Can't delete device: please remove all related sessions, assets and tests before " "deleting." @@ -1322,52 +1364,52 @@ msgstr "" "Impossible de supprimer l'appareil: veuillez retirer toutes les séances, " "ressources et tests associés au préalable." -#: modules/FlaskModule/API/user/UserQueryDevices.py:469 +#: modules/FlaskModule/API/user/UserQueryDevices.py:474 msgid "Device successfully deleted" msgstr "Appareil supprimé avec succès" -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:49 -#: modules/FlaskModule/API/user/UserQueryDisconnect.py:57 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:50 +#: modules/FlaskModule/API/user/UserQueryDisconnect.py:58 msgid "Use Logout instead to disconnect current user" msgstr "Utilisez l'API de Logout pour déconnecter l'utilisateur actuel" -#: modules/FlaskModule/API/user/UserQueryForms.py:82 +#: modules/FlaskModule/API/user/UserQueryForms.py:95 msgid "Missing type" msgstr "Champ type manquant" -#: modules/FlaskModule/API/user/UserQueryForms.py:137 +#: modules/FlaskModule/API/user/UserQueryForms.py:150 msgid "Missing session type id" msgstr "Champ id_session_type manquant" -#: modules/FlaskModule/API/user/UserQueryForms.py:148 +#: modules/FlaskModule/API/user/UserQueryForms.py:161 msgid "No reply from service while querying session type config" msgstr "" "Aucune réponse du service lors de la requête de la configuration du type de " "séance" -#: modules/FlaskModule/API/user/UserQueryForms.py:182 +#: modules/FlaskModule/API/user/UserQueryForms.py:195 msgid "Invalid service specified" msgstr "Service non valide" -#: modules/FlaskModule/API/user/UserQueryForms.py:195 +#: modules/FlaskModule/API/user/UserQueryForms.py:208 msgid "Unknown form type: " msgstr "Formulaire inconnu: " #: modules/FlaskModule/API/user/UserQueryOnlineDevices.py:60 -#: modules/FlaskModule/API/user/UserQueryOnlineParticipants.py:69 -#: modules/FlaskModule/API/user/UserQueryOnlineUsers.py:57 +#: modules/FlaskModule/API/user/UserQueryOnlineParticipants.py:70 +#: modules/FlaskModule/API/user/UserQueryOnlineUsers.py:59 msgid "Internal server error when making RPC call." msgstr "Erreur interne du serveur pour RPC." -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:93 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:96 msgid "Missing group" msgstr "Groupe manquant" -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:102 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:105 msgid "Missing id_participant_group or id_project" msgstr "Champs id_participant_group ou id_project manquants" -#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:170 +#: modules/FlaskModule/API/user/UserQueryParticipantGroup.py:175 msgid "" "Can't delete participant group: please delete all sessions from all participants " "before deleting." @@ -1375,41 +1417,41 @@ msgstr "" "Impossible de supprimer le groupe: veuillez supprimer toutes les séances de tous " "les participants du groupe au préalable." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:219 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:223 msgid "Missing participant" msgstr "Participant manquant" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:225 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:229 msgid "Missing id_participant" msgstr "ID Participant manquant" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:229 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:233 msgid "Missing id_project or id_participant_group" msgstr "Champ manquants: id_participant, id_project ou id_participant_group" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:244 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:248 msgid "No admin access to project" msgstr "Aucun accès administrateur à ce projet" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:252 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:256 msgid "No admin access to group" msgstr "Aucun accès administrateur à ce groupe" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:261 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:265 msgid "Participant group not found." msgstr "Le groupe participant n'existe pas." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:265 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:269 msgid "Mismatch between id_project and group's project" msgstr "Aucune correspondance entre id_project et le projet" -#: modules/FlaskModule/API/user/UserQueryParticipants.py:382 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:388 msgid "Can't delete participant: please remove all related sessions beforehand." msgstr "" "Impossible de supprimer le participant: veuillez supprimer toutes les séances de " "ce participant au préalable." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:384 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:390 msgid "" "Can't delete participant: please remove all sessions created by this participant " "beforehand." @@ -1417,19 +1459,19 @@ msgstr "" "Impossible de supprimer le participant: veuillez supprimer toutes les séances " "créées par ce participant au préalable." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:387 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:393 msgid "Can't delete participant: please remove all related assets beforehand." msgstr "" "Impossible de supprimer le participant: veuillez supprimer toutes les ressources " "de ce participant au préalable." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:389 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:395 msgid "Can't delete participant: please remove all related tests beforehand." msgstr "" "Impossible de supprimer le participant: veuillez supprimer tous les tests " "associés à ce participant au préalable." -#: modules/FlaskModule/API/user/UserQueryParticipants.py:391 +#: modules/FlaskModule/API/user/UserQueryParticipants.py:397 msgid "" "Can't delete participant: please remove all related sessions, assets and tests " "before deleting." @@ -1437,56 +1479,56 @@ msgstr "" "Impossible de supprimer le participant: veuillez retirer toutes les séances, " "ressources et tests associés à ce participant au préalable." -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:200 -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:183 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:203 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:186 msgid "Missing role name or id" msgstr "Nom du rôle ou ID manquant" -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:231 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:234 msgid "Invalid role name or id for that project" msgstr "Nom du rôle ou ID invalide pour ce projet" -#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:270 +#: modules/FlaskModule/API/user/UserQueryProjectAccess.py:275 msgid "No project access to delete." msgstr "Aucun accès au projet pour supprimer." -#: modules/FlaskModule/API/user/UserQueryProjects.py:169 +#: modules/FlaskModule/API/user/UserQueryProjects.py:173 msgid "No access to a session type for at least one of it" msgstr "Pas d'accès à ce type de session pour au moins un projet" -#: modules/FlaskModule/API/user/UserQueryProjects.py:181 +#: modules/FlaskModule/API/user/UserQueryProjects.py:185 msgid "At least one session type is not associated to the project site" msgstr "Au moins un type de séance n’est pas associé au site du projet" -#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:35 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:189 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:142 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:134 -#: modules/FlaskModule/API/user/UserQueryServices.py:128 +#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:37 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:192 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:138 +#: modules/FlaskModule/API/user/UserQueryServices.py:131 msgid "Missing id_service" msgstr "Champ id_service manquant" -#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:38 +#: modules/FlaskModule/API/user/UserQueryServiceAccessToken.py:40 msgid "No access to specified service" msgstr "Aucun accès au service spécifié" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:72 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:146 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:73 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:149 msgid "Can't combine id_user, id_participant and id_device in request" msgstr "Ne peut pas combiner id_user, id_participant et id_device dans la requête" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:121 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:124 msgid "Missing service_config" msgstr "Champ service_config manquant" -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:175 -#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:198 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:178 +#: modules/FlaskModule/API/user/UserQueryServiceConfigs.py:201 msgid "Invalid config format provided" msgstr "Le format de la configuration est invalide" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:170 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:211 -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:339 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:174 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:215 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:345 msgid "" "Can't delete service-project: please remove all related sessions, assets and " "tests before deleting." @@ -1494,21 +1536,21 @@ msgstr "" "Impossible de retirer ce service de ce projet: veuillez supprimer toutes les " "séances, ressources et tests en lien avec ce service au préalable." -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:181 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:166 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:185 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:170 msgid "Missing services" msgstr "Services manquants" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:249 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:253 msgid "" "At least one service is not part of the allowed service for that project site" msgstr "Au moins un service n'est pas permis pour ce projet pour ce site" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:311 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:317 msgid "Operation not completed" msgstr "Opération non complétée" -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:332 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:338 msgid "" "Can't delete service-project: please remove all sessions involving a session " "type using this project beforehand." @@ -1516,21 +1558,21 @@ msgstr "" "Impossible de retirer ce service de ce projet: veuillez supprimer toutes les " "séances impliquant un type de séance en lien avec ce service au préalable." -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:335 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:341 msgid "Can't delete service-project: please remove all related assets beforehand." msgstr "" "Impossible de retirer ce service de ce projet: veuillez supprimer toutes les " "ressources associées au préalable." -#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:337 +#: modules/FlaskModule/API/user/UserQueryServiceProjects.py:343 msgid "Can't delete service-project: please remove all related tests beforehand." msgstr "" "Impossible de retirer ce service de ce projet: veuillez supprimer tous les tests " "associés à ce service au préalable." -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:155 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:185 -#: modules/FlaskModule/API/user/UserQueryServiceSites.py:278 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:159 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:189 +#: modules/FlaskModule/API/user/UserQueryServiceSites.py:284 msgid "" "Can't delete service from site: please delete all sessions, assets and tests " "related to that service beforehand." @@ -1538,24 +1580,24 @@ msgstr "" "Impossible de retirer le service de ce site: veuillez supprimer toutes les " "séances, ressources et tests reliés à ce service dans ce site au préalable." -#: modules/FlaskModule/API/user/UserQueryServices.py:150 +#: modules/FlaskModule/API/user/UserQueryServices.py:153 msgid "OpenTera service can't be updated using this API" msgstr "Les services ne peuvent pas être mis-à-jour par cet API" -#: modules/FlaskModule/API/user/UserQueryServices.py:164 -#: modules/FlaskModule/API/user/UserQueryServices.py:184 +#: modules/FlaskModule/API/user/UserQueryServices.py:167 +#: modules/FlaskModule/API/user/UserQueryServices.py:187 msgid "Invalid config json schema" msgstr "Schéma JSON invalide pour la configuration" -#: modules/FlaskModule/API/user/UserQueryServices.py:168 +#: modules/FlaskModule/API/user/UserQueryServices.py:171 msgid "Missing service_key" msgstr "Clé du service manquante" -#: modules/FlaskModule/API/user/UserQueryServices.py:236 +#: modules/FlaskModule/API/user/UserQueryServices.py:241 msgid "Invalid service" msgstr "Service invalide" -#: modules/FlaskModule/API/user/UserQueryServices.py:251 +#: modules/FlaskModule/API/user/UserQueryServices.py:256 msgid "" "Can't delete service: please delete all sessions, assets and tests related to " "that service beforehand." @@ -1563,8 +1605,8 @@ msgstr "" "Impossible de supprimer le service: veuillez supprimer toutes les séances, " "ressources et tests reliés à ce service au préalable." -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:154 -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:186 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:158 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:190 msgid "" "Can't delete session type from project: please delete all sessions using that " "type in that project before deleting." @@ -1572,16 +1614,16 @@ msgstr "" "Impossible de retirer le type de séance du projet: veuillez supprimer toutes les " "séances de ce type dans ce projet avant de supprimer." -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:163 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:160 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:167 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:164 msgid "Missing session types" msgstr "Champ session_type manquant" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:212 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:216 msgid "At least one session type is not associated to the site of its project" msgstr "Au moins un type de session n'est pas associé au site de ce projet" -#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:283 +#: modules/FlaskModule/API/user/UserQuerySessionTypeProjects.py:289 msgid "" "Can't delete session type from project: please delete all sessions of that type " "in the project before deleting." @@ -1589,9 +1631,9 @@ msgstr "" "Impossible de retirer le type de séance du projet: veuillez supprimer toutes les " "séances de ce type dans ce projet avant de supprimer." -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:150 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:185 -#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:278 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:154 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:189 +#: modules/FlaskModule/API/user/UserQuerySessionTypeSites.py:284 msgid "" "Can't delete session type from site: please delete all sessions of that type in " "the site before deleting." @@ -1599,53 +1641,53 @@ msgstr "" "Impossible de retirer le type de séance du site: veuillez supprimer toutes les " "séances de ce type dans ce site au préalable." -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:95 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:99 msgid "Missing session_type" msgstr "Champ session_type manquant" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:111 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:115 msgid "Missing site(s) to associate that session type to" msgstr "Site(s) manquant(s) pour l'association avec un type de session" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:136 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:140 msgid "Missing id_service for session type of type service" msgstr "Champ id_service pour la session manquant" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:151 -#: modules/FlaskModule/API/user/UserQueryTestType.py:183 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:155 +#: modules/FlaskModule/API/user/UserQueryTestType.py:187 msgid "No site admin access for at least one site in the list" msgstr "Aucun accès administrateur de site pour au moins un site dans la liste" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:161 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:165 msgid "At least one site isn't associated with the service of that session type" msgstr "Au moins un site n'est pas associé avec ce type de session pour ce service" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:178 -#: modules/FlaskModule/API/user/UserQueryTestType.py:206 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:182 +#: modules/FlaskModule/API/user/UserQueryTestType.py:210 msgid "No project admin access for at a least one project in the list" msgstr "Pas administrateur pour au moins un projet da la liste" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:264 -#: modules/FlaskModule/API/user/UserQueryTestType.py:286 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:268 +#: modules/FlaskModule/API/user/UserQueryTestType.py:290 msgid "Session type not associated to project site" msgstr "Type de séance non associé au site du projet" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:292 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:296 msgid "Session type has a a service not associated to its site" msgstr "" "Tentative d'association avec un type de séance qui a un service non associé à " "son site" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:316 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:323 msgid "Cannot delete because you are not admin in all projects." msgstr "" "Impossible de supprimer: vous n'êtes pas administrateur dans tous les projets." -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:321 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:328 msgid "Unable to delete - not admin in at least one project" msgstr "Impossible de supprimer - pas administrateur dans au moins un projet" -#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:334 +#: modules/FlaskModule/API/user/UserQuerySessionTypes.py:341 msgid "" "Can't delete session type: please delete all sessions with that type before " "deleting." @@ -1653,97 +1695,97 @@ msgstr "" "Impossible de supprimer le type de séance: veuillez supprimer toutes les séances " "de ce type au préalable." -#: modules/FlaskModule/API/user/UserQuerySessions.py:135 +#: modules/FlaskModule/API/user/UserQuerySessions.py:139 msgid "Missing session participants and users" msgstr "Usagers ou participants manquants pour la session" -#: modules/FlaskModule/API/user/UserQuerySessions.py:141 +#: modules/FlaskModule/API/user/UserQuerySessions.py:145 msgid "No access to session." msgstr "Aucun accès à la séance." -#: modules/FlaskModule/API/user/UserQuerySessions.py:156 -#: modules/FlaskModule/API/user/UserQuerySessions.py:247 +#: modules/FlaskModule/API/user/UserQuerySessions.py:160 +#: modules/FlaskModule/API/user/UserQuerySessions.py:253 msgid "User doesn't have access to at least one participant of that session." msgstr "L'utilisateur n'a pas accès à au moins un participant de la séance." -#: modules/FlaskModule/API/user/UserQuerySessions.py:161 -#: modules/FlaskModule/API/user/UserQuerySessions.py:252 +#: modules/FlaskModule/API/user/UserQuerySessions.py:165 +#: modules/FlaskModule/API/user/UserQuerySessions.py:258 msgid "User doesn't have access to at least one user of that session." msgstr "L'utilisateur n'a pas accès à au moins un utilisateur de la séance." -#: modules/FlaskModule/API/user/UserQuerySessions.py:166 -#: modules/FlaskModule/API/user/UserQuerySessions.py:257 +#: modules/FlaskModule/API/user/UserQuerySessions.py:170 +#: modules/FlaskModule/API/user/UserQuerySessions.py:263 msgid "User doesn't have access to at least one device of that session." msgstr "L'utilisateur n'a pas accès à au moins un appareil de la séance." -#: modules/FlaskModule/API/user/UserQuerySessions.py:261 +#: modules/FlaskModule/API/user/UserQuerySessions.py:267 msgid "Session is in progress: can't delete that session." msgstr "La séance est en cours: impossible de supprimer." -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:181 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:184 msgid "Missing id_site" msgstr "Champ id_site manquant" -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:230 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:233 msgid "Invalid role name or id for that site" msgstr "Nom de rôle ou id invalide(s) pour ce site" -#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:271 +#: modules/FlaskModule/API/user/UserQuerySiteAccess.py:276 msgid "No site access to delete" msgstr "Pas d'accès à effacer" -#: modules/FlaskModule/API/user/UserQuerySites.py:115 +#: modules/FlaskModule/API/user/UserQuerySites.py:116 msgid "Missing site" msgstr "Site manquant" -#: modules/FlaskModule/API/user/UserQuerySites.py:122 +#: modules/FlaskModule/API/user/UserQuerySites.py:123 msgid "Missing id_site field" msgstr "Champ id_site manquant" -#: modules/FlaskModule/API/user/UserQuerySites.py:191 +#: modules/FlaskModule/API/user/UserQuerySites.py:194 msgid "" "Can't delete site: please delete all participants with sessions before deleting." msgstr "" "Impossible de supprimer le site: veuillez supprimer tous les participants au " "préalable." -#: modules/FlaskModule/API/user/UserQueryStats.py:94 +#: modules/FlaskModule/API/user/UserQueryStats.py:96 msgid "Missing id argument" msgstr "Champ id manquant" -#: modules/FlaskModule/API/user/UserQueryTestType.py:151 +#: modules/FlaskModule/API/user/UserQueryTestType.py:155 msgid "Missing project(s) to associate that test type to" msgstr "Projet(s) manquant(s) pour l'association avec ce type de test" -#: modules/FlaskModule/API/user/UserQueryTestType.py:192 +#: modules/FlaskModule/API/user/UserQueryTestType.py:196 msgid "At least one site isn't associated with the service of that test type" msgstr "Au moins un site n'est pas associé avec le service de ce type de test" -#: modules/FlaskModule/API/user/UserQueryTestType.py:306 +#: modules/FlaskModule/API/user/UserQueryTestType.py:310 msgid "Test type has a a service not associated to its site" msgstr "" "Tentative d'association avec un type de test qui a un service non associé à son " "site" -#: modules/FlaskModule/API/user/UserQueryTestType.py:328 +#: modules/FlaskModule/API/user/UserQueryTestType.py:334 msgid "Unable to delete - not admin in the related test type service" msgstr "" "Impossible de supprimer - pas administrateur pour le type de test du service" -#: modules/FlaskModule/API/user/UserQueryTestType.py:338 +#: modules/FlaskModule/API/user/UserQueryTestType.py:344 msgid "" "Can't delete test type: please delete all tests of that type before deleting." msgstr "" "Impossible de supprimer le type de test: veuillez supprimer tous les tests de ce " "type au préalable." -#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:213 +#: modules/FlaskModule/API/user/UserQueryTestTypeProjects.py:217 msgid "At least one test type is not associated to the site of its project" msgstr "Au moins un type de test n'est pas associé au site de ce projet" -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:143 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:176 -#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:266 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:147 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:180 +#: modules/FlaskModule/API/user/UserQueryTestTypeSites.py:272 msgid "" "Can't delete test type from site: please delete all tests of that type in the " "site before deleting." @@ -1751,7 +1793,7 @@ msgstr "" "Impossible de retirer ce type de test du site: veuillez supprimer tous les tests " "de ce type dans ce site au préalable." -#: modules/FlaskModule/API/user/UserQueryTests.py:123 +#: modules/FlaskModule/API/user/UserQueryTests.py:127 msgid "" "Test information update and creation must be done directly into a service (such " "as Test service)" @@ -1759,97 +1801,106 @@ msgstr "" "La création et la mise à jour d'information sur les tests doivent être fait " "directement dans un service" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:30 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:32 msgid "No access to this API" msgstr "Aucun accès à cet API" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:46 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:48 msgid "Item to undelete not found" msgstr "L'élément à restaurer est introuvable" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:49 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:51 msgid "Item can't be undeleted" msgstr "L'élément ne peut pas être restauré" -#: modules/FlaskModule/API/user/UserQueryUndelete.py:52 +#: modules/FlaskModule/API/user/UserQueryUndelete.py:54 msgid "Item isn't deleted" msgstr "L'élément n'est pas supprimé" -#: modules/FlaskModule/API/user/UserQueryUserGroups.py:142 +#: modules/FlaskModule/API/user/UserQueryUserGroups.py:145 msgid "Missing user group name" msgstr "Nom du groupe utilisateur manquant" -#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:89 +#: modules/FlaskModule/API/user/UserQueryUserPreferences.py:92 msgid "Missing app tag" msgstr "Champ App Tag manquant" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:57 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:59 msgid "At least one id must be specified" msgstr "Au moins un ID doit être spécifié" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:89 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:93 msgid "Missing user user group" msgstr "Champ user_user_group manquant" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:100 -#: modules/FlaskModule/API/user/UserQueryUsers.py:183 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:104 +#: modules/FlaskModule/API/user/UserQueryUsers.py:198 msgid "Missing id_user" msgstr "Champ id_user manquant" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:106 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:110 msgid "No access to specified user" msgstr "Aucun accès à l'utilisateur" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:108 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:112 msgid "No access to specified user group" msgstr "Aucun accès au groupe utilisateurs" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:113 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:117 msgid "Super admins can't be associated to an user group" msgstr "" "Les super administrateurs ne peuvent pas être associés à un groupe d'utilisateurs" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:158 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:164 msgid "Can't delete specified relationship" msgstr "Impossible de supprimer cette relation" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:161 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:167 msgid "No access to relationship's user" msgstr "Aucun accès à l'utilisateur" -#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:163 +#: modules/FlaskModule/API/user/UserQueryUserUserGroups.py:169 msgid "No access to relationship's user group" msgstr "Aucun accès au groupe" -#: modules/FlaskModule/API/user/UserQueryUsers.py:176 +#: modules/FlaskModule/API/user/UserQueryUsers.py:57 +msgid "Password not long enough" +msgstr "Mot de passe pas assez long" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:191 msgid "Missing user" msgstr "Champ user manquant" -#: modules/FlaskModule/API/user/UserQueryUsers.py:198 +#: modules/FlaskModule/API/user/UserQueryUsers.py:213 msgid "No access for at a least one user group in the list" msgstr "Aucun accès pour au moins un groupe d'utilisateurs dans la liste" -#: modules/FlaskModule/API/user/UserQueryUsers.py:229 +#: modules/FlaskModule/API/user/UserQueryUsers.py:244 msgid "Username can't be modified" msgstr "Le code utilisateur ne peut pas être modifié" -#: modules/FlaskModule/API/user/UserQueryUsers.py:250 +#: modules/FlaskModule/API/user/UserQueryUsers.py:257 +#: modules/FlaskModule/API/user/UserQueryUsers.py:295 +msgid "Password not strong enough" +msgstr "Mot de passe insécure" + +#: modules/FlaskModule/API/user/UserQueryUsers.py:271 msgid "Missing required fields: " msgstr "Champs manquants: " -#: modules/FlaskModule/API/user/UserQueryUsers.py:253 +#: modules/FlaskModule/API/user/UserQueryUsers.py:274 msgid "Invalid password" msgstr "Mot de passe incorrect" -#: modules/FlaskModule/API/user/UserQueryUsers.py:257 +#: modules/FlaskModule/API/user/UserQueryUsers.py:278 msgid "Username unavailable." msgstr "Nom d'utilisateur non disponible." -#: modules/FlaskModule/API/user/UserQueryUsers.py:336 +#: modules/FlaskModule/API/user/UserQueryUsers.py:344 msgid "Sorry, you can't delete yourself!" msgstr "Désolé, vous ne pouvez pas vous supprimer!" -#: modules/FlaskModule/API/user/UserQueryUsers.py:366 +#: modules/FlaskModule/API/user/UserQueryUsers.py:374 msgid "" "Can't delete user: please remove all sessions that this user is part of before " "deleting." @@ -1857,7 +1908,7 @@ msgstr "" "Impossible de supprimer l'utilisateur: veuillez supprimer toutes les séances " "dont cet utilisateur fait partie au préalable." -#: modules/FlaskModule/API/user/UserQueryUsers.py:369 +#: modules/FlaskModule/API/user/UserQueryUsers.py:377 msgid "" "Can't delete user: please remove all sessions created by this user before " "deleting." @@ -1865,81 +1916,88 @@ msgstr "" "Impossible de supprimer l'utilisateur: veuillez supprimer toutes les séances " "créées par cet utilisateur au préalable." -#: modules/FlaskModule/API/user/UserQueryUsers.py:372 +#: modules/FlaskModule/API/user/UserQueryUsers.py:380 msgid "" "Can't delete user: please remove all tests created by this user before deleting." msgstr "" "Impossible de supprimer l'utilisateur: veuillez supprimer tous les tests créés " "par cet utilisateur au préalable." -#: modules/FlaskModule/API/user/UserQueryUsers.py:375 +#: modules/FlaskModule/API/user/UserQueryUsers.py:383 msgid "" "Can't delete user: please remove all assets created by this user before deleting." msgstr "" "Impossible de supprimer l'utilisateur: veuillez supprimer toutes les ressources " "créées par cet utilisateur au préalable." -#: modules/FlaskModule/API/user/UserQueryUsers.py:378 +#: modules/FlaskModule/API/user/UserQueryUsers.py:386 msgid "" "Can't delete user: please delete all assets created by this user before deleting." msgstr "" "Impossible de supprimer l'utilisateur: veuillez supprimer toutes les ressources " "créées par cet utilisateur au préalable." -#: modules/FlaskModule/API/user/UserQueryVersions.py:40 +#: modules/FlaskModule/API/user/UserQueryVersions.py:42 msgid "No version information found" msgstr "Aucune information de version disponible" -#: modules/FlaskModule/API/user/UserQueryVersions.py:71 +#: modules/FlaskModule/API/user/UserQueryVersions.py:75 msgid "Wrong ClientVersions" msgstr "Mauvais version du client (ClientVersions)" -#: modules/FlaskModule/API/user/UserQueryVersions.py:77 +#: modules/FlaskModule/API/user/UserQueryVersions.py:81 msgid "Not authorized" msgstr "Non autorisé" -#: modules/FlaskModule/API/user/UserSessionManager.py:129 +#: modules/FlaskModule/API/user/UserSessionManager.py:130 msgid "User doesn't have access to that session" msgstr "L'utilisateur n'a pas accès à cette session" -#: modules/FlaskModule/API/user/UserSessionManager.py:160 +#: modules/FlaskModule/API/user/UserSessionManager.py:161 msgid "User doesn't have access to that service." msgstr "L'utilisateur n'a pas accès à ce service." -#: modules/FlaskModule/API/user/UserSessionManager.py:165 +#: modules/FlaskModule/API/user/UserSessionManager.py:166 msgid "Missing parameters" msgstr "Paramètres manquants" -#: modules/FlaskModule/API/user/UserSessionManager.py:168 +#: modules/FlaskModule/API/user/UserSessionManager.py:169 msgid "Missing reply code in parameters" msgstr "Manque le reply code dans les paramètres" -#: modules/FlaskModule/API/user/UserSessionManager.py:181 -#: modules/FlaskModule/API/user/UserSessionManager.py:184 +#: modules/FlaskModule/API/user/UserSessionManager.py:182 +#: modules/FlaskModule/API/user/UserSessionManager.py:185 msgid "Invalid reply code" msgstr "Le champ reply code est invalide" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:39 +#: modules/FlaskModule/Views/LoginChangePasswordView.py:38 msgid "Missing information" msgstr "Information manquante" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:49 -msgid "New password and confirm password do not match" -msgstr "Le nouveau et l'ancien mot de passe ne correspondent pas" +#: modules/FlaskModule/Views/LoginChangePasswordView.py:53 +msgid "New password must be different from current" +msgstr "Le nouveau mot de passe doit être différent de l'actuel" -#: modules/FlaskModule/Views/LoginChangePasswordView.py:54 -msgid "Invalid old password" -msgstr "Ancien mot de passe incorrect" +#: modules/LoginModule/LoginModule.py:219 +msgid "Unauthorized - User must login first to change password" +msgstr "" +"Non-autorisé - L'utilisateur doit se connecter pour changer son mot de passe" -#: modules/LoginModule/LoginModule.py:621 modules/LoginModule/LoginModule.py:654 +#: modules/LoginModule/LoginModule.py:222 +msgid "Unauthorized - 2FA is enabled, must login first and use token" +msgstr "" +"Non-autorisé - Authentification multi-facteurs activée, doit se connecter et " +"utiliser un jeton" + +#: modules/LoginModule/LoginModule.py:633 modules/LoginModule/LoginModule.py:666 msgid "Disabled device" msgstr "Appareil désactivé" -#: modules/LoginModule/LoginModule.py:631 +#: modules/LoginModule/LoginModule.py:643 msgid "Invalid token" msgstr "Jeton invalide" -#: modules/LoginModule/LoginModule.py:731 +#: modules/LoginModule/LoginModule.py:743 msgid "Invalid Token" msgstr "Jeton invalide" @@ -2006,7 +2064,7 @@ msgid "Device Onlineable?" msgstr "Se met en ligne?" #: opentera/forms/TeraDeviceForm.py:44 opentera/forms/TeraParticipantForm.py:50 -#: opentera/forms/TeraUserForm.py:42 +#: opentera/forms/TeraUserForm.py:46 msgid "Last Connection" msgstr "Dernière connexion" @@ -2015,8 +2073,8 @@ msgstr "Dernière connexion" #: opentera/forms/TeraParticipantGroupForm.py:18 #: opentera/forms/TeraProjectForm.py:18 opentera/forms/TeraServiceConfigForm.py:18 #: opentera/forms/TeraServiceForm.py:18 opentera/forms/TeraSessionForm.py:120 -#: opentera/forms/TeraSessionTypeForm.py:25 opentera/forms/TeraSiteForm.py:12 -#: opentera/forms/TeraUserForm.py:13 opentera/forms/TeraUserGroupForm.py:18 +#: opentera/forms/TeraSessionTypeForm.py:25 opentera/forms/TeraSiteForm.py:13 +#: opentera/forms/TeraUserForm.py:14 opentera/forms/TeraUserGroupForm.py:18 #: opentera/forms/TeraVersionsForm.py:18 msgid "Information" msgstr "Information" @@ -2034,7 +2092,7 @@ msgid "Device Configuration" msgstr "Configuration de l'appareil" #: opentera/forms/TeraDeviceForm.py:55 opentera/forms/TeraDeviceForm.py:57 -#: opentera/forms/TeraUserForm.py:40 +#: opentera/forms/TeraUserForm.py:48 msgid "Notes" msgstr "Notes" @@ -2140,7 +2198,7 @@ msgstr "Site" msgid "Role" msgstr "Rôle" -#: opentera/forms/TeraProjectForm.py:28 opentera/forms/TeraSiteForm.py:17 +#: opentera/forms/TeraProjectForm.py:28 opentera/forms/TeraSiteForm.py:18 msgid "Site Name" msgstr "Nom Site" @@ -2157,7 +2215,7 @@ msgstr "ID Service" msgid "Service Config ID" msgstr "ID Configuration Service" -#: opentera/forms/TeraServiceConfigForm.py:27 opentera/forms/TeraUserForm.py:17 +#: opentera/forms/TeraServiceConfigForm.py:27 opentera/forms/TeraUserForm.py:18 msgid "User ID" msgstr "ID Usager" @@ -2201,13 +2259,13 @@ msgstr "Adresse Réseau" msgid "Port" msgstr "Port" -#: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:20 -#: templates/login.html:122 +#: opentera/forms/TeraServiceConfigForm.py:71 opentera/forms/TeraUserForm.py:21 +#: templates/login.html:125 msgid "Username" msgstr "Code utilisateur" -#: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:38 -#: templates/login.html:128 +#: opentera/forms/TeraServiceConfigForm.py:74 opentera/forms/TeraUserForm.py:44 +#: templates/login.html:131 msgid "Password" msgstr "Mot de passe" @@ -2379,15 +2437,15 @@ msgstr "Couleur du Type de Séance" msgid "Session Type Configuration" msgstr "Configuration du Type de Séance" -#: opentera/forms/TeraSiteForm.py:16 +#: opentera/forms/TeraSiteForm.py:17 msgid "Site ID" msgstr "ID Site" -#: opentera/forms/TeraSiteForm.py:18 +#: opentera/forms/TeraSiteForm.py:20 msgid "Users Require 2FA" msgstr "Authentification Multi-Facteurs (MFA) requise" -#: opentera/forms/TeraSiteForm.py:20 +#: opentera/forms/TeraSiteForm.py:22 msgid "Site Role" msgstr "Rôle du Site" @@ -2419,51 +2477,51 @@ msgstr "A une interface web" msgid "Expose Web editor" msgstr "A un éditeur web" -#: opentera/forms/TeraUserForm.py:18 +#: opentera/forms/TeraUserForm.py:19 msgid "User UUID" msgstr "UUID Utilisateur" -#: opentera/forms/TeraUserForm.py:19 +#: opentera/forms/TeraUserForm.py:20 msgid "User Full Name" msgstr "Nom complet Utilisateur" -#: opentera/forms/TeraUserForm.py:21 +#: opentera/forms/TeraUserForm.py:22 msgid "User Enabled" msgstr "Utilisateur activé" -#: opentera/forms/TeraUserForm.py:23 +#: opentera/forms/TeraUserForm.py:26 msgid "Force password change" msgstr "Imposer changement de mot de passe" -#: opentera/forms/TeraUserForm.py:25 +#: opentera/forms/TeraUserForm.py:28 msgid "2FA Enabled" msgstr "Double authentification (2FA) activée" -#: opentera/forms/TeraUserForm.py:28 +#: opentera/forms/TeraUserForm.py:31 msgid "2FA OTP Enabled" msgstr "2FA OTP activé" -#: opentera/forms/TeraUserForm.py:30 +#: opentera/forms/TeraUserForm.py:34 msgid "2FA Email Enabled" msgstr "2FA par courriel activée" -#: opentera/forms/TeraUserForm.py:34 +#: opentera/forms/TeraUserForm.py:40 msgid "First Name" msgstr "Prénom" -#: opentera/forms/TeraUserForm.py:35 +#: opentera/forms/TeraUserForm.py:41 msgid "Last Name" msgstr "Nom" -#: opentera/forms/TeraUserForm.py:36 +#: opentera/forms/TeraUserForm.py:42 msgid "Email" msgstr "Courriel" -#: opentera/forms/TeraUserForm.py:39 +#: opentera/forms/TeraUserForm.py:45 msgid "User Is Super Administrator" msgstr "Utilisateur est Super Administrateur" -#: opentera/forms/TeraUserForm.py:41 +#: opentera/forms/TeraUserForm.py:49 msgid "Profile" msgstr "Profil" @@ -2638,14 +2696,42 @@ msgstr "La documentation d’API est désactivée!" msgid "OpenTera Login Page" msgstr "OpenTera - Page de connexion" -#: templates/login.html:86 +#: templates/login.html:89 msgid "Invalid username or password" msgstr "Code utilisateur ou mot de passe incorrect" -#: templates/login.html:136 +#: templates/login.html:139 msgid "Login" msgstr "Connecter" +#: templates/login_change_password.html:5 +msgid "OpenTera - Change Password" +msgstr "OpenTera - Changement de mot de passe" + +#: templates/login_change_password.html:96 +msgid "Password successfully changed!" +msgstr "Mot de passe changé avec succès!" + +#: templates/login_change_password.html:97 +msgid "Redirecting to login screen..." +msgstr "Redirection vers la page de connexion..." + +#: templates/login_change_password.html:104 +msgid "Password change required" +msgstr "Changement de mot de passe requis" + +#: templates/login_change_password.html:110 +msgid "New Password" +msgstr "Nouveau" + +#: templates/login_change_password.html:115 +msgid "Confirm Password" +msgstr "Confirmation" + +#: templates/login_change_password.html:122 +msgid "Change Password" +msgstr "Changer mot de passe" + #: templates/login_setup_2fa.html:107 msgid "You need to setup multi-factor authentication before continuing." msgstr "" @@ -2725,3 +2811,6 @@ msgstr "Authentification complétée" #~ msgid "Invalid client version handler" #~ msgstr "Mauvaise version du client" + +#~ msgid "Invalid old password" +#~ msgstr "Ancien mot de passe incorrect" From b64580feb04c27ac6a40daea92114a1a5ad3c882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20L=C3=A9tourneau?= Date: Thu, 10 Oct 2024 14:25:19 -0400 Subject: [PATCH 65/67] Update FlaskUtils.py Added EOL. --- teraserver/python/modules/FlaskModule/FlaskUtils.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teraserver/python/modules/FlaskModule/FlaskUtils.py b/teraserver/python/modules/FlaskModule/FlaskUtils.py index 42e0be40..3736d994 100644 --- a/teraserver/python/modules/FlaskModule/FlaskUtils.py +++ b/teraserver/python/modules/FlaskModule/FlaskUtils.py @@ -20,4 +20,4 @@ def get_password_weaknesses_text(weaknesses: list, separator=',') -> str: if weakness == UserPasswordInsecure.PasswordWeaknesses.NO_UPPER_CASE: text_list.append(gettext('Password missing upper case letter')) - return separator.join(text for text in text_list) \ No newline at end of file + return separator.join(text for text in text_list) From add82fbc07c8b8437660f0a7a65d4290fdbc2117 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20L=C3=A9tourneau?= Date: Thu, 10 Oct 2024 14:32:29 -0400 Subject: [PATCH 66/67] Update LoginModule.py Fix typo. --- teraserver/python/modules/LoginModule/LoginModule.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teraserver/python/modules/LoginModule/LoginModule.py b/teraserver/python/modules/LoginModule/LoginModule.py index ee655d50..4f0f741c 100755 --- a/teraserver/python/modules/LoginModule/LoginModule.py +++ b/teraserver/python/modules/LoginModule/LoginModule.py @@ -93,7 +93,7 @@ def cleanup_disabled_tokens(self): LoginModule.__user_disabled_token_storage.remove_all_expired_tokens( self.redisGet(RedisVars.RedisVar_UserTokenAPIKey) ) - # Remove expired tokens from participant tokens disabled storagef + # Remove expired tokens from participant tokens disabled storage LoginModule.__participant_disabled_token_storage.remove_all_expired_tokens( self.redisGet(RedisVars.RedisVar_ParticipantTokenAPIKey) ) From d641348cd36b2f414bc735754512d412bebe3eee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dominic=20L=C3=A9tourneau?= Date: Thu, 10 Oct 2024 14:41:54 -0400 Subject: [PATCH 67/67] Update login_change_password.html Fix EOL. --- teraserver/python/templates/login_change_password.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/teraserver/python/templates/login_change_password.html b/teraserver/python/templates/login_change_password.html index 787db778..26b4f8d8 100644 --- a/teraserver/python/templates/login_change_password.html +++ b/teraserver/python/templates/login_change_password.html @@ -132,4 +132,4 @@
- \ No newline at end of file +