diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..4e2d2ce7 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*.{html,py,js}] +charset = utf-8 +indent_style = space +indent_size = 4 +max_line_length = 100 + +[*.html] +indent_size = 4 diff --git a/.flake8 b/.flake8 new file mode 100644 index 00000000..17eee72d --- /dev/null +++ b/.flake8 @@ -0,0 +1,25 @@ +[flake8] +ignore = B009,C101,D403,E302,F403,F405,I100,I101,I201,W503,W504,Q003,C901,Q000 +exclude = + .git, + __pycache__, + venv, + lib/*, + BaCa2/packages_source/*, + BaCa2/submits/*, + packages_source/*, + */migrations/*, + BaCa2/core/db/setup.py, + BaCa2/core/db/ext_databases.py, + BaCa2/scratch*, +filename = *.py +accept-encodings = utf-8 +count = True +inline-quotes = single +max-complexity = 10 +max-line-length = 100 +multiline-quotes = double +per-file-ignores = + BaCa2/core/settings/templates/settings.dev.py:F821 + *__init__.py:F401 +statistics = True diff --git a/.github/workflows/documentation.yaml b/.github/workflows/documentation.yaml index 9adf6c6d..81bc79f9 100644 --- a/.github/workflows/documentation.yaml +++ b/.github/workflows/documentation.yaml @@ -49,4 +49,4 @@ jobs: publish_branch: gh-pages github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: build/ - force_orphan: true \ No newline at end of file + force_orphan: true diff --git a/.gitignore b/.gitignore index 214fbbb4..5d9beddc 100644 --- a/.gitignore +++ b/.gitignore @@ -522,8 +522,6 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ parts/ sdist/ var/ @@ -644,8 +642,28 @@ dmypy.json # Cython debug symbols cython_debug/ -BaCa2/BaCa2/settings_local.py +# Poetry +poetry.lock +/poetry.lock + +# BaCa2 specyfic ------------------------------------------------------------------------------- + +BaCa2/core/settings_local.py Baca2/submits/ -BaCa2/BaCa2/db/ext_databases.py +secrets.yaml + +**/migrations/* +migrations +!migrations/__init__.py + +db.cache +uploads/ + +packages_source/ +!packages_source/dosko/ +!packages_source/kolejka/ +/BaCa2/task_descriptions/ + +/local/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 00000000..1ebbe408 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,57 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + exclude: ^.*admin\.json$|^.*\.map$ + - id: double-quote-string-fixer + - id: check-added-large-files + - id: detect-private-key + - id: check-toml + - id: check-merge-conflict + - id: no-commit-to-branch + args: [ -b, master ] + - id: check-ast + + - repo: https://github.com/pre-commit/pygrep-hooks + rev: v1.10.0 + hooks: + - id: python-check-blanket-noqa + - id: python-check-blanket-type-ignore + - id: python-use-type-annotations + - id: rst-backticks + - id: rst-directive-colons + - id: rst-inline-touching-normal + + # - repo: https://github.com/Lucas-C/pre-commit-hooks-safety + # rev: v1.3.3 + # hooks: + # - id: python-safety-dependencies-check + # files: pyproject.toml + # args: [ "--ignore=65213" ] + - repo: https://github.com/pycqa/isort + rev: 5.13.2 + hooks: + - id: isort + name: isort (python) + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.5.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + exclude: ^.*admin.json$ + - repo: https://github.com/pycqa/flake8 + rev: 7.0.0 + hooks: + - id: flake8 + additional_dependencies: + - flake8-bugbear + - flake8-builtins + - flake8-coding + - flake8-import-order + - flake8-polyfill + - flake8-quotes diff --git a/BaCa2/BaCa2/apps_configurations/dbbackup.py b/BaCa2/BaCa2/apps_configurations/dbbackup.py deleted file mode 100644 index 4aec8fde..00000000 --- a/BaCa2/BaCa2/apps_configurations/dbbackup.py +++ /dev/null @@ -1,7 +0,0 @@ -from pathlib import Path - -BACKUP_DIR = Path(__file__).resolve().parent.parent.parent -DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage' -DBBACKUP_STORAGE_OPTIONS = { - 'location': BACKUP_DIR / 'backup' -} \ No newline at end of file diff --git a/BaCa2/BaCa2/choices.py b/BaCa2/BaCa2/choices.py deleted file mode 100644 index 52cc7449..00000000 --- a/BaCa2/BaCa2/choices.py +++ /dev/null @@ -1,32 +0,0 @@ -from django.db import models -from django.utils.translation import gettext_lazy as _ - - -class TaskJudgingMode(models.TextChoices): - LIN = 'LIN', _('Linear') - UNA = 'UNA', _('Unanimous') - - -class ResultStatus(models.TextChoices): - PND = 'PND', _('Pending') - OK = 'OK', _('Test accepted') - ANS = 'ANS', _('Wrong answer') - RTE = 'RTE', _('Runtime error') - MEM = 'MEM', _('Memory exceeded') - TLE = 'TLE', _('Time limit exceeded') - CME = 'CME', _('Compilation error') - EXT = 'EXT', _('Unknown extension') - INT = 'INT', _('Internal error') - - -class PermissionTypes(models.TextChoices): - ADD = 'ADD', 'add' - DEL = 'DEL', 'delete' - EDIT = 'EDIT', 'change' - VIEW = 'VIEW', 'view' - - -class DefaultCourseGroups(models.TextChoices): - ADMIN = 'ADMIN', 'admin' - MOD = 'MOD', 'moderator' - VIEWER = 'VIEWER', 'viewer' diff --git a/BaCa2/BaCa2/db/creator.py b/BaCa2/BaCa2/db/creator.py deleted file mode 100644 index 0901d5f2..00000000 --- a/BaCa2/BaCa2/db/creator.py +++ /dev/null @@ -1,188 +0,0 @@ -import logging - -import psycopg2 -from threading import Lock - -from BaCa2.db.setup import DEFAULT_DB_SETTINGS -from BaCa2.exceptions import NewDBError - -log = logging.getLogger(__name__) - -#: A lock that prevents multiple threads from accessing the database as root at the same time. -_db_root_access = Lock() - - -def _raw_root_connection(): - """ - It creates a raw connection to the database server as the root user - :return: A connection to the postgres database. - """ - from BaCa2.db.setup import ADMIN_DB_USER, DEFAULT_DB_HOST - conn = psycopg2.connect( - database='postgres', - user=ADMIN_DB_USER['user'], - password=ADMIN_DB_USER['password'], - host=DEFAULT_DB_HOST - ) - conn.autocommit = True - return conn - - -def createDB(db_name: str, verbose: bool=False, **db_kwargs): - """ - It creates a new database, adds it to the settings file and to the runtime database connections. - - While runtime :py:data:`_db_root_access` is acquired. - - :param db_name: The name of the database to create - :type db_name: str - :param verbose: If True, prints out the progress of the function, defaults to False - :type verbose: bool (optional) - """ - - db_key = db_name - db_name += '_db' - from BaCa2.settings import DATABASES, SETTINGS_DIR - - if db_key in DATABASES.keys(): - log.error(f"DB {db_name} already exists.") - raise NewDBError(f"DB {db_name} already exists.") - - _db_root_access.acquire() - if verbose: - print("Creating connection...") - conn = _raw_root_connection() - cursor = conn.cursor() - - drop_if_exist = f'''DROP DATABASE IF EXISTS {db_name};''' - sql = f''' CREATE DATABASE {db_name}; ''' - - cursor.execute(drop_if_exist) - cursor.execute(sql) - if verbose: - print(f"DB {db_name} created.") - - conn.close() - log.info(f"DB {db_name} created successfully.") - - new_db = DEFAULT_DB_SETTINGS | db_kwargs | {'NAME': db_name} - DATABASES[db_key] = new_db - # from django.db import connections - # connections.configure_settings(None) - if verbose: - print("Connection created.") - - new_db_save = f''' -DATABASES['{db_key}'] = {'{'} - 'ENGINE': '{new_db['ENGINE']}', - 'NAME': '{new_db['NAME']}', - 'USER': '{new_db['USER']}', - 'PASSWORD': '{new_db['PASSWORD']}', - 'HOST': '{new_db['HOST']}', - 'PORT': '{new_db['PORT']}' -{'}'} - -''' - with open(SETTINGS_DIR / 'db/ext_databases.py', 'a') as f: - f.write(new_db_save) - if verbose: - print("Settings saved to file.") - - # migrateDB(db_name) - - _db_root_access.release() - - log.info(f"DB {db_name} settings saved to ext_databases.") - - -def migrateDB(db_name: str): - """ - It runs the Django command `migrate` on the database specified by `db_name` - - :param db_name: The name of the database to migrate - :type db_name: str - """ - from django.core.management import call_command - # call_command('makemigrations') - - call_command('migrate', database=db_name, interactive=False, skip_checks=True) - log.info(f"Migration for DB {db_name} applied.") - - -def migrateAll(): - """ - It loops through all the databases in the settings file and runs the migrateDB function on each one. - """ - from BaCa2.settings import DATABASES - log.info(f"Migrating all databases.") - for db in DATABASES.keys(): - if db != 'default': - migrateDB(db) - log.info(f"All databases migrated.") - - -CLOSE_ALL_DB_CONNECTIONS = '''SELECT pg_terminate_backend(pg_stat_activity.pid) -FROM pg_stat_activity -WHERE pg_stat_activity.datname = '%s' - AND pid <> pg_backend_pid(); -''' - - -def deleteDB(db_name: str, verbose: bool=False): - """ - It deletes a database from the settings.DATABASES dictionary, deletes the database settings from the ext_databases.py - file, closes all connections to the database, and then drops the database. - - While runtime :py:data:`_db_root_access` is acquired. - - :param db_name: str - :type db_name: str - :param verbose: if True, prints out what's happening, defaults to False - :type verbose: bool (optional) - """ - from BaCa2.settings import DATABASES, BASE_DIR - - log.info(f'Attempting to delete DB {db_name}') - - _db_root_access.acquire() - - log.info(f"Deleting DB {db_name} from settings.DATABASES.") - try: - DATABASES.pop(db_name) - except KeyError: - log.info(f"Database {db_name} not found in settings.DATABASES.") - db_alias = f"{db_name}_db" - - if verbose: - print(f"Deleted {db_name} from settings.DATABASES.") - - with open(BASE_DIR / "BaCa2/db/ext_databases.py", 'r') as f: - db_setts = f.read().split('\n\n') - - for i, sett in enumerate(db_setts): - if sett.find(f"DATABASES['{db_name}']") != -1: - log.info(f"Deleting DB {db_name} from ext_databases.py") - db_setts.pop(i) - break - - with open(BASE_DIR / "BaCa2/db/ext_databases.py", 'w') as f: - f.write('\n\n'.join(db_setts)) - - if verbose: - print(f"Deleted {db_name} settings from ext_databases.py") - - conn = _raw_root_connection() - cursor = conn.cursor() - - cursor.execute(CLOSE_ALL_DB_CONNECTIONS % db_alias) - if verbose: - print("All DB connections closed") - - sql = f''' DROP DATABASE IF EXISTS {db_alias}; ''' - if verbose: - print(f"DB {db_alias} dropped.") - cursor.execute(sql) - conn.close() - log.info(f"DB {db_name} successfully deleted.") - - _db_root_access.release() diff --git a/BaCa2/BaCa2/db/setup.py b/BaCa2/BaCa2/db/setup.py deleted file mode 100644 index 93e70055..00000000 --- a/BaCa2/BaCa2/db/setup.py +++ /dev/null @@ -1,34 +0,0 @@ -ADMIN_DB_USER = { - 'user': 'root', - 'password': 'BaCa2root' -} - -''' creating root db user -CREATE ROLE root WITH - LOGIN - SUPERUSER - CREATEDB - CREATEROLE - INHERIT - REPLICATION - CONNECTION LIMIT -1 - PASSWORD 'BaCa2root'; -GRANT postgres TO root WITH ADMIN OPTION; -COMMENT ON ROLE root IS 'root db user for db managment purposes'; -''' - -DEFAULT_DB_HOST = 'localhost' - -DEFAULT_DB_SETTINGS = { - 'ENGINE': 'django.db.backends.postgresql_psycopg2', - 'USER': 'baca2', - 'PASSWORD': 'zaqwsxcde', - 'HOST': 'localhost', - 'PORT': '', - 'TIME_ZONE': None, - 'CONN_HEALTH_CHECKS': False, - 'CONN_MAX_AGE': 0, - 'AUTOCOMMIT': True, - 'OPTIONS': {}, - 'ATOMIC_REQUESTS': False -} diff --git a/BaCa2/BaCa2/settings.py b/BaCa2/BaCa2/settings.py deleted file mode 100644 index 19b7790f..00000000 --- a/BaCa2/BaCa2/settings.py +++ /dev/null @@ -1,166 +0,0 @@ -""" -Django settings for BaCa2 project. - -Generated by 'django-admin startproject' using Django 4.1. - -For more information on this file, see -https://docs.djangoproject.com/en/4.1/topics/settings/ - -For the full list of settings and their values, see -https://docs.djangoproject.com/en/4.1/ref/settings/ -""" -from contextvars import ContextVar -from pathlib import Path -from BaCa2.db.setup import DEFAULT_DB_SETTINGS - -# Build paths inside the project like this: BASE_DIR / 'subdir'. -BASE_DIR = Path(__file__).resolve().parent.parent -SETTINGS_DIR = Path(__file__).resolve().parent - -# Quick-start development settings - unsuitable for production -# See https://docs.djangoproject.com/en/4.1/howto/deployment/checklist/ - -# SECURITY WARNING: keep the secret key used in production secret! -SECRET_KEY = 'django-insecure-h)q%%z-63-!_w*7qsme!7j#1n6_9_v6r+4e%k1u+va@dz4p%x#' - -# SECURITY WARNING: don't run with debug turned on in production! -DEBUG = True - -ALLOWED_HOSTS = [] - -# Application definition - -INSTALLED_APPS = [ - 'django.contrib.admin', - 'django.contrib.auth', - 'django.contrib.contenttypes', - 'django.contrib.sessions', - 'django.contrib.messages', - 'django.contrib.staticfiles', - 'django.contrib.sites', - - 'allauth', # https://github.com/pennersr/django-allauth - 'allauth.account', - 'allauth.socialaccount', - - 'django_extensions', # https://github.com/django-extensions/django-extensions - 'dbbackup', # https://github.com/jazzband/django-dbbackup - - 'main', # local app - - 'course', # local app - - 'package', # local app -] - -MIDDLEWARE = [ - 'django.middleware.security.SecurityMiddleware', - 'django.contrib.sessions.middleware.SessionMiddleware', - 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', - 'django.contrib.auth.middleware.AuthenticationMiddleware', - 'django.contrib.messages.middleware.MessageMiddleware', - 'django.middleware.clickjacking.XFrameOptionsMiddleware', -] - -ROOT_URLCONF = 'BaCa2.urls' - -TEMPLATES = [ - { - 'BACKEND': 'django.template.backends.django.DjangoTemplates', - 'DIRS': [], - 'APP_DIRS': True, - 'OPTIONS': { - 'context_processors': [ - 'django.template.context_processors.debug', - 'django.template.context_processors.request', # required by allauth - 'django.contrib.auth.context_processors.auth', - 'django.contrib.messages.context_processors.messages', - ], - }, - }, -] - -WSGI_APPLICATION = 'BaCa2.wsgi.application' - -# Internationalization -# https://docs.djangoproject.com/en/4.1/topics/i18n/ - -LANGUAGE_CODE = 'pl' - -TIME_ZONE = 'Europe/Warsaw' - -USE_I18N = True - -USE_TZ = True - -# Database -# https://docs.djangoproject.com/en/4.1/ref/settings/#databases - - -DATABASES = { - 'default': DEFAULT_DB_SETTINGS | {'NAME': 'baca2db'} -} -if (SETTINGS_DIR / 'db/ext_databases.py').exists(): - exec(open((SETTINGS_DIR / 'db/ext_databases.py'), "rb").read()) - -# DB routing -# https://docs.djangoproject.com/en/4.1/topics/db/multi-db/ -DATABASE_ROUTERS = ['course.routing.ContextCourseRouter'] - -currentDB = ContextVar('currentDB') - -# Password validation -# https://docs.djangoproject.com/en/4.1/ref/settings/#auth-password-validators - -AUTH_PASSWORD_VALIDATORS = [ - { - 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', - }, - { - 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', - }, -] - -AUTHENTICATION_BACKENDS = [ - # Needed to login by username in Django admin, regardless of `allauth` - 'django.contrib.auth.backends.ModelBackend', - - # `allauth` specific authentication methods, such as login by e-mail - 'allauth.account.auth_backends.AuthenticationBackend', -] - -AUTH_USER_MODEL = 'main.User' - -SITE_ID = 1 # allauth required - -# Static files (CSS, JavaScript, Images) -# https://docs.djangoproject.com/en/4.1/howto/static-files/ - -STATIC_URL = 'static/' - -# Default primary key field type -# https://docs.djangoproject.com/en/4.1/ref/settings/#default-auto-field - -DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' - -################## -# APPS SETTINGS # -################## - -# import applications configuration -for f in (SETTINGS_DIR / 'app_configurations').glob('[!_]*.py'): - exec(open(f, "rb").read()) - -if (SETTINGS_DIR / "settings_local.py").exists(): - exec(open(SETTINGS_DIR / "settings_local.py", "rb").read()) - -SUPPORTED_EXTENSIONS = ['cpp'] - -PACKAGES = {} diff --git a/BaCa2/BaCa2/tools.py b/BaCa2/BaCa2/tools.py deleted file mode 100644 index 3c5a2505..00000000 --- a/BaCa2/BaCa2/tools.py +++ /dev/null @@ -1,5 +0,0 @@ -from random import choice - - -def random_string(length: int, array): - return ''.join(choice(array) for _ in range(length)) diff --git a/BaCa2/BaCa2/urls.py b/BaCa2/BaCa2/urls.py deleted file mode 100644 index c45f79b3..00000000 --- a/BaCa2/BaCa2/urls.py +++ /dev/null @@ -1,22 +0,0 @@ -"""BaCa2 URL Configuration - -The `urlpatterns` list routes URLs to views. For more information please see: - https://docs.djangoproject.com/en/4.1/topics/http/urls/ -Examples: -Function views - 1. Add an import: from my_app import views - 2. Add a URL to urlpatterns: path('', views.home, name='home') -Class-based views - 1. Add an import: from other_app.views import Home - 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home') -Including another URLconf - 1. Import the include() function: from django.urls import include, path - 2. Add a URL to urlpatterns: path('blog/', include('blog.urls')) -""" -from django.contrib import admin -from django.urls import path, include - -urlpatterns = [ - # path('polls/', include('polls.urls')), - path('baca/', admin.site.urls), -] diff --git a/BaCa2/assets/css/baca2_theme.css b/BaCa2/assets/css/baca2_theme.css new file mode 100644 index 00000000..2d438f4d --- /dev/null +++ b/BaCa2/assets/css/baca2_theme.css @@ -0,0 +1,12516 @@ +@charset "UTF-8"; +:root { + --bs-form-valid-border-color: $red; +} + +/*! + * Bootstrap v5.3.1 (https://getbootstrap.com/) + * Copyright 2011-2023 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) + */ +: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-baca2_blue: #08D9D6; + --bs-baca2_beige: #D3CABD; + --bs-baca2_pink: #FE2E63; + --bs-dark_muted: #3e4042; + --bs-light_muted: #a0a0a0; + --bs-pale_muted: #d0d0d0; + --bs-darker: #171a1d; + --bs-primary: #FE2E63; + --bs-secondary: #D3CABD; + --bs-success: #08D9D6; + --bs-info: #0dcaf0; + --bs-warning: #ffc107; + --bs-danger: #FE2E63; + --bs-light: #f8f9fa; + --bs-dark: #212529; + --bs-baca2_blue-rgb: 8, 217, 214; + --bs-baca2_beige-rgb: 211, 202, 189; + --bs-baca2_pink-rgb: 254, 46, 99; + --bs-dark_muted-rgb: 62, 64, 66; + --bs-light_muted-rgb: 160, 160, 160; + --bs-pale_muted-rgb: 208, 208, 208; + --bs-darker-rgb: 23, 26, 29; + --bs-primary-rgb: 254, 46, 99; + --bs-secondary-rgb: 211, 202, 189; + --bs-success-rgb: 8, 217, 214; + --bs-info-rgb: 13, 202, 240; + --bs-warning-rgb: 255, 193, 7; + --bs-danger-rgb: 254, 46, 99; + --bs-light-rgb: 248, 249, 250; + --bs-dark-rgb: 33, 37, 41; + --bs-primary-text-emphasis: #661228; + --bs-secondary-text-emphasis: #54514c; + --bs-success-text-emphasis: #035756; + --bs-info-text-emphasis: #055160; + --bs-warning-text-emphasis: #664d03; + --bs-danger-text-emphasis: #661228; + --bs-light-text-emphasis: #495057; + --bs-dark-text-emphasis: #495057; + --bs-primary-bg-subtle: #ffd5e0; + --bs-secondary-bg-subtle: #f6f4f2; + --bs-success-bg-subtle: #cef7f7; + --bs-info-bg-subtle: #cff4fc; + --bs-warning-bg-subtle: #fff3cd; + --bs-danger-bg-subtle: #ffd5e0; + --bs-light-bg-subtle: #fcfcfd; + --bs-dark-bg-subtle: #ced4da; + --bs-primary-border-subtle: #ffabc1; + --bs-secondary-border-subtle: #edeae5; + --bs-success-border-subtle: #9cf0ef; + --bs-info-border-subtle: #9eeaf9; + --bs-warning-border-subtle: #ffe69c; + --bs-danger-border-subtle: #ffabc1; + --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: #FE2E63; + --bs-link-color-rgb: 254, 46, 99; + --bs-link-decoration: underline; + --bs-link-hover-color: #cb254f; + --bs-link-hover-color-rgb: 203, 37, 79; + --bs-code-color: #d63384; + --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(254, 46, 99, 0.25); + --bs-form-valid-color: #08D9D6; + --bs-form-valid-border-color: #08D9D6; + --bs-form-invalid-color: #FE2E63; + --bs-form-invalid-border-color: #FE2E63; +} + +[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: #fe82a1; + --bs-secondary-text-emphasis: #e5dfd7; + --bs-success-text-emphasis: #6be8e6; + --bs-info-text-emphasis: #6edff6; + --bs-warning-text-emphasis: #ffda6a; + --bs-danger-text-emphasis: #fe82a1; + --bs-light-text-emphasis: #f8f9fa; + --bs-dark-text-emphasis: #dee2e6; + --bs-primary-bg-subtle: #330914; + --bs-secondary-bg-subtle: #2a2826; + --bs-success-bg-subtle: #022b2b; + --bs-info-bg-subtle: #032830; + --bs-warning-bg-subtle: #332701; + --bs-danger-bg-subtle: #330914; + --bs-light-bg-subtle: #343a40; + --bs-dark-bg-subtle: #1a1d20; + --bs-primary-border-subtle: #981c3b; + --bs-secondary-border-subtle: #7f7971; + --bs-success-border-subtle: #058280; + --bs-info-border-subtle: #087990; + --bs-warning-border-subtle: #997404; + --bs-danger-border-subtle: #981c3b; + --bs-light-border-subtle: #495057; + --bs-dark-border-subtle: #343a40; + --bs-heading-color: inherit; + --bs-link-color: #fe82a1; + --bs-link-hover-color: #fe9bb4; + --bs-link-color-rgb: 254, 130, 161; + --bs-link-hover-color-rgb: 254, 155, 180; + --bs-code-color: #e685b5; + --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; +} + +*, +*::before, +*::after { + 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: rgba(0, 0, 0, 0); +} + +hr { + margin: 1rem 0; + color: inherit; + border: 0; + border-top: var(--bs-border-width) solid; + opacity: 0.25; +} + +h6, .h6, h5, .h5, h4, .h4, h3, .h3, h2, .h2, h1, .h1 { + margin-top: 0; + margin-bottom: 0.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 + 0.9vw); +} +@media (min-width: 1200px) { + h2, .h2 { + font-size: 2rem; + } +} + +h3, .h3 { + font-size: calc(1.3rem + 0.6vw); +} +@media (min-width: 1200px) { + h3, .h3 { + font-size: 1.75rem; + } +} + +h4, .h4 { + font-size: calc(1.275rem + 0.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] { + text-decoration: underline dotted; + cursor: help; + text-decoration-skip-ink: none; +} + +address { + margin-bottom: 1rem; + font-style: normal; + line-height: inherit; +} + +ol, +ul { + padding-left: 2rem; +} + +ol, +ul, +dl { + margin-top: 0; + margin-bottom: 1rem; +} + +ol ol, +ul ul, +ol ul, +ul ol { + margin-bottom: 0; +} + +dt { + font-weight: 700; +} + +dd { + margin-bottom: 0.5rem; + margin-left: 0; +} + +blockquote { + margin: 0 0 1rem; +} + +b, +strong { + font-weight: bolder; +} + +small, .small { + font-size: 0.875em; +} + +mark, .mark { + padding: 0.1875em; + background-color: var(--bs-highlight-bg); +} + +sub, +sup { + position: relative; + font-size: 0.75em; + line-height: 0; + vertical-align: baseline; +} + +sub { + bottom: -0.25em; +} + +sup { + top: -0.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; +} + +pre, +code, +kbd, +samp { + font-family: var(--bs-font-monospace); + font-size: 1em; +} + +pre { + display: block; + margin-top: 0; + margin-bottom: 1rem; + overflow: auto; + font-size: 0.875em; +} +pre code { + font-size: inherit; + color: inherit; + word-break: normal; +} + +code { + font-size: 0.875em; + color: var(--bs-code-color); + word-wrap: break-word; +} +a > code { + color: inherit; +} + +kbd { + padding: 0.1875rem 0.375rem; + font-size: 0.875em; + color: var(--bs-body-bg); + background-color: var(--bs-body-color); + border-radius: 0.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: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-secondary-color); + text-align: left; +} + +th { + text-align: inherit; + text-align: -webkit-match-parent; +} + +thead, +tbody, +tfoot, +tr, +td, +th { + border-color: inherit; + border-style: solid; + border-width: 0; +} + +label { + display: inline-block; +} + +button { + border-radius: 0; +} + +button:focus:not(:focus-visible) { + outline: 0; +} + +input, +button, +select, +optgroup, +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; +} + +button, +[type=button], +[type=reset], +[type=submit] { + -webkit-appearance: button; +} +button:not(:disabled), +[type=button]:not(:disabled), +[type=reset]:not(:disabled), +[type=submit]: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: 0.5rem; + font-size: calc(1.275rem + 0.3vw); + line-height: inherit; +} +@media (min-width: 1200px) { + legend { + font-size: 1.5rem; + } +} +legend + * { + clear: left; +} + +::-webkit-datetime-edit-fields-wrapper, +::-webkit-datetime-edit-text, +::-webkit-datetime-edit-minute, +::-webkit-datetime-edit-hour-field, +::-webkit-datetime-edit-day-field, +::-webkit-datetime-edit-month-field, +::-webkit-datetime-edit-year-field { + padding: 0; +} + +::-webkit-inner-spin-button { + height: auto; +} + +[type=search] { + -webkit-appearance: textfield; + outline-offset: -2px; +} + +/* rtl:raw: +[type="tel"], +[type="url"], +[type="email"], +[type="number"] { + direction: ltr; +} +*/ +::-webkit-search-decoration { + -webkit-appearance: none; +} + +::-webkit-color-swatch-wrapper { + padding: 0; +} + +::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: 0.5rem; +} + +.initialism { + font-size: 0.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: 0.875em; + color: #6c757d; +} +.blockquote-footer::before { + content: "— "; +} + +.img-fluid { + max-width: 100%; + height: auto; +} + +.img-thumbnail { + padding: 0.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: 0.5rem; + line-height: 1; +} + +.figure-caption { + font-size: 0.875em; + color: var(--bs-secondary-color); +} + +.container, +.container-fluid, +.container-xxl, +.container-xl, +.container-lg, +.container-md, +.container-sm { + --bs-gutter-x: 1.5rem; + --bs-gutter-y: 0; + width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.5); + margin-right: auto; + margin-left: auto; +} + +@media (min-width: 576px) { + .container-sm, .container { + max-width: 540px; + } +} +@media (min-width: 768px) { + .container-md, .container-sm, .container { + max-width: 720px; + } +} +@media (min-width: 992px) { + .container-lg, .container-md, .container-sm, .container { + max-width: 960px; + } +} +@media (min-width: 1200px) { + .container-xl, .container-lg, .container-md, .container-sm, .container { + max-width: 1140px; + } +} +@media (min-width: 1400px) { + .container-xxl, .container-xl, .container-lg, .container-md, .container-sm, .container { + 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(-0.5 * var(--bs-gutter-x)); + margin-left: calc(-0.5 * var(--bs-gutter-x)); +} +.row > * { + flex-shrink: 0; + width: 100%; + max-width: 100%; + padding-right: calc(var(--bs-gutter-x) * 0.5); + padding-left: calc(var(--bs-gutter-x) * 0.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.3333333333%; +} + +.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.6666666667%; +} + +.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.3333333333%; + } + .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.6666666667%; + } + .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.3333333333%; + } + .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.6666666667%; + } + .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.3333333333%; + } + .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.6666666667%; + } + .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.3333333333%; + } + .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.6666666667%; + } + .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.3333333333%; + } + .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.6666666667%; + } + .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-body-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-body-color); + --bs-table-striped-bg: rgba(0, 0, 0, 0.05); + --bs-table-active-color: var(--bs-body-color); + --bs-table-active-bg: rgba(0, 0, 0, 0.1); + --bs-table-hover-color: var(--bs-body-color); + --bs-table-hover-bg: rgba(0, 0, 0, 0.075); + width: 100%; + margin-bottom: 1rem; + vertical-align: top; + border-color: var(--bs-table-border-color); +} +.table > :not(caption) > * > * { + padding: 0.5rem 0.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: 0.25rem 0.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(even) { + --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: #ffd5e0; + --bs-table-border-color: #e6c0ca; + --bs-table-striped-bg: #f2cad5; + --bs-table-striped-color: #000; + --bs-table-active-bg: #e6c0ca; + --bs-table-active-color: #000; + --bs-table-hover-bg: #ecc5cf; + --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: #f6f4f2; + --bs-table-border-color: #dddcda; + --bs-table-striped-bg: #eae8e6; + --bs-table-striped-color: #000; + --bs-table-active-bg: #dddcda; + --bs-table-active-color: #000; + --bs-table-hover-bg: #e4e2e0; + --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: #cef7f7; + --bs-table-border-color: #b9dede; + --bs-table-striped-bg: #c4ebeb; + --bs-table-striped-color: #000; + --bs-table-active-bg: #b9dede; + --bs-table-active-color: #000; + --bs-table-hover-bg: #bfe4e4; + --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: #badce3; + --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: #e6dbb9; + --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: #ffd5e0; + --bs-table-border-color: #e6c0ca; + --bs-table-striped-bg: #f2cad5; + --bs-table-striped-color: #000; + --bs-table-active-bg: #e6c0ca; + --bs-table-active-color: #000; + --bs-table-hover-bg: #ecc5cf; + --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: #dfe0e1; + --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: #373b3e; + --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: 0.5rem; +} + +.col-form-label { + padding-top: calc(0.375rem + var(--bs-border-width)); + padding-bottom: calc(0.375rem + var(--bs-border-width)); + margin-bottom: 0; + font-size: inherit; + line-height: 1.5; +} + +.col-form-label-lg { + padding-top: calc(0.5rem + var(--bs-border-width)); + padding-bottom: calc(0.5rem + var(--bs-border-width)); + font-size: 1.25rem; +} + +.col-form-label-sm { + padding-top: calc(0.25rem + var(--bs-border-width)); + padding-bottom: calc(0.25rem + var(--bs-border-width)); + font-size: 0.875rem; +} + +.form-text { + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-secondary-color); +} + +.form-control { + display: block; + width: 100%; + padding: 0.375rem 0.75rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + 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 0.15s ease-in-out, box-shadow 0.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: #ff97b1; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(254, 46, 99, 0.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::placeholder { + color: var(--bs-secondary-color); + opacity: 1; +} +.form-control:disabled { + background-color: var(--bs-secondary-bg); + opacity: 1; +} +.form-control::file-selector-button { + padding: 0.375rem 0.75rem; + margin: -0.375rem -0.75rem; + margin-inline-end: 0.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 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; +} +@media (prefers-reduced-motion: reduce) { + .form-control::file-selector-button { + transition: none; + } +} +.form-control:hover:not(:disabled):not([readonly])::file-selector-button { + background-color: var(--bs-secondary-bg); +} + +.form-control-plaintext { + display: block; + width: 100%; + padding: 0.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-sm, .form-control-plaintext.form-control-lg { + padding-right: 0; + padding-left: 0; +} + +.form-control-sm { + min-height: calc(1.5em + 0.5rem + calc(var(--bs-border-width) * 2)); + padding: 0.25rem 0.5rem; + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm); +} +.form-control-sm::file-selector-button { + padding: 0.25rem 0.5rem; + margin: -0.25rem -0.5rem; + margin-inline-end: 0.5rem; +} + +.form-control-lg { + min-height: calc(1.5em + 1rem + calc(var(--bs-border-width) * 2)); + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: var(--bs-border-radius-lg); +} +.form-control-lg::file-selector-button { + padding: 0.5rem 1rem; + margin: -0.5rem -1rem; + margin-inline-end: 1rem; +} + +textarea.form-control { + min-height: calc(1.5em + 0.75rem + calc(var(--bs-border-width) * 2)); +} +textarea.form-control-sm { + min-height: calc(1.5em + 0.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 + 0.75rem + calc(var(--bs-border-width) * 2)); + padding: 0.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 + 0.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: 0.375rem 2.25rem 0.375rem 0.75rem; + font-size: 1rem; + font-weight: 400; + line-height: 1.5; + color: var(--bs-body-color); + 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 0.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 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .form-select { + transition: none; + } +} +.form-select:focus { + border-color: #ff97b1; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(254, 46, 99, 0.25); +} +.form-select[multiple], .form-select[size]:not([size="1"]) { + padding-right: 0.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: 0.25rem; + padding-bottom: 0.25rem; + padding-left: 0.5rem; + font-size: 0.875rem; + border-radius: var(--bs-border-radius-sm); +} + +.form-select-lg { + padding-top: 0.5rem; + padding-bottom: 0.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: 0.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); + width: 1em; + height: 1em; + margin-top: 0.25em; + vertical-align: top; + 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); + print-color-adjust: exact; +} +.form-check-input[type=checkbox] { + border-radius: 0.25em; +} +.form-check-input[type=radio] { + border-radius: 50%; +} +.form-check-input:active { + filter: brightness(90%); +} +.form-check-input:focus { + border-color: #ff97b1; + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(254, 46, 99, 0.25); +} +.form-check-input:checked { + background-color: #FE2E63; + border-color: #FE2E63; +} +.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: #FE2E63; + border-color: #FE2E63; + --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: 0.5; +} +.form-check-input[disabled] ~ .form-check-label, .form-check-input:disabled ~ .form-check-label { + cursor: default; + opacity: 0.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 0.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='%23ff97b1'/%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: 0.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; + 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 0.25rem rgba(254, 46, 99, 0.25); +} +.form-range:focus::-moz-range-thumb { + box-shadow: 0 0 0 1px #fff, 0 0 0 0.25rem rgba(254, 46, 99, 0.25); +} +.form-range::-moz-focus-outer { + border: 0; +} +.form-range::-webkit-slider-thumb { + width: 1rem; + height: 1rem; + margin-top: -0.25rem; + appearance: none; + background-color: #FE2E63; + border: 0; + border-radius: 1rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .form-range::-webkit-slider-thumb { + transition: none; + } +} +.form-range::-webkit-slider-thumb:active { + background-color: #ffc0d0; +} +.form-range::-webkit-slider-runnable-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: var(--bs-tertiary-bg); + border-color: transparent; + border-radius: 1rem; +} +.form-range::-moz-range-thumb { + width: 1rem; + height: 1rem; + appearance: none; + background-color: #FE2E63; + border: 0; + border-radius: 1rem; + transition: background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .form-range::-moz-range-thumb { + transition: none; + } +} +.form-range::-moz-range-thumb:active { + background-color: #ffc0d0; +} +.form-range::-moz-range-track { + width: 100%; + height: 0.5rem; + color: transparent; + cursor: pointer; + background-color: var(--bs-tertiary-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 0.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 0.1s ease-in-out, transform 0.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 0.75rem; +} +.form-floating > .form-control::placeholder, +.form-floating > .form-control-plaintext::placeholder { + color: transparent; +} +.form-floating > .form-control:focus, .form-floating > .form-control:not(:placeholder-shown), +.form-floating > .form-control-plaintext:focus, +.form-floating > .form-control-plaintext:not(:placeholder-shown) { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-control:-webkit-autofill, +.form-floating > .form-control-plaintext:-webkit-autofill { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-select { + padding-top: 1.625rem; + padding-bottom: 0.625rem; +} +.form-floating > .form-control:focus ~ label, +.form-floating > .form-control:not(:placeholder-shown) ~ label, +.form-floating > .form-control-plaintext ~ label, +.form-floating > .form-select ~ label { + color: rgba(var(--bs-body-color-rgb), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} +.form-floating > .form-control:focus ~ label::after, +.form-floating > .form-control:not(:placeholder-shown) ~ label::after, +.form-floating > .form-control-plaintext ~ 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), 0.65); + transform: scale(0.85) translateY(-0.5rem) translateX(0.15rem); +} +.form-floating > .form-control-plaintext ~ label { + border-width: var(--bs-border-width) 0; +} +.form-floating > :disabled ~ label, +.form-floating > .form-control:disabled ~ label { + color: #6c757d; +} +.form-floating > :disabled ~ label::after, +.form-floating > .form-control: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-select, +.input-group > .form-floating { + position: relative; + flex: 1 1 auto; + width: 1%; + min-width: 0; +} +.input-group > .form-control:focus, +.input-group > .form-select:focus, +.input-group > .form-floating:focus-within { + 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: 0.375rem 0.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 > .form-control, +.input-group-lg > .form-select, +.input-group-lg > .input-group-text, +.input-group-lg > .btn { + padding: 0.5rem 1rem; + font-size: 1.25rem; + border-radius: var(--bs-border-radius-lg); +} + +.input-group-sm > .form-control, +.input-group-sm > .form-select, +.input-group-sm > .input-group-text, +.input-group-sm > .btn { + padding: 0.25rem 0.5rem; + font-size: 0.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) > :not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), +.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 { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.input-group.has-validation > :nth-last-child(n+3):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating), +.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 { + 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: 0.25rem; + font-size: 0.875em; + color: var(--bs-form-valid-color); +} + +.valid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.875rem; + color: #fff; + background-color: var(--bs-success); + border-radius: var(--bs-border-radius); +} + +.was-validated :valid ~ .valid-feedback, +.was-validated :valid ~ .valid-tooltip, +.is-valid ~ .valid-feedback, +.is-valid ~ .valid-tooltip { + display: block; +} + +.was-validated .form-control:valid, .form-control.is-valid { + border-color: var(--bs-form-valid-border-color); + padding-right: calc(1.5em + 0.75rem); + background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 8 8'%3e%3cpath fill='%2308D9D6' 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(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-control:valid:focus, .form-control.is-valid:focus { + border-color: var(--bs-form-valid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} + +.was-validated textarea.form-control:valid, textarea.form-control.is-valid { + padding-right: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} + +.was-validated .form-select:valid, .form-select.is-valid { + border-color: var(--bs-form-valid-border-color); +} +.was-validated .form-select:valid:not([multiple]):not([size]), .was-validated .form-select:valid:not([multiple])[size="1"], .form-select.is-valid:not([multiple]):not([size]), .form-select.is-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='%2308D9D6' 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 0.75rem center, center right 2.25rem; + background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-select:valid:focus, .form-select.is-valid:focus { + border-color: var(--bs-form-valid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} + +.was-validated .form-control-color:valid, .form-control-color.is-valid { + width: calc(3rem + calc(1.5em + 0.75rem)); +} + +.was-validated .form-check-input:valid, .form-check-input.is-valid { + border-color: var(--bs-form-valid-border-color); +} +.was-validated .form-check-input:valid:checked, .form-check-input.is-valid:checked { + background-color: var(--bs-form-valid-color); +} +.was-validated .form-check-input:valid:focus, .form-check-input.is-valid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-success-rgb), 0.25); +} +.was-validated .form-check-input:valid ~ .form-check-label, .form-check-input.is-valid ~ .form-check-label { + color: var(--bs-form-valid-color); +} + +.form-check-inline .form-check-input ~ .valid-feedback { + margin-left: 0.5em; +} + +.was-validated .input-group > .form-control:not(:focus):valid, .input-group > .form-control:not(:focus).is-valid, +.was-validated .input-group > .form-select:not(:focus):valid, +.input-group > .form-select:not(:focus).is-valid, +.was-validated .input-group > .form-floating:not(:focus-within):valid, +.input-group > .form-floating:not(:focus-within).is-valid { + z-index: 3; +} + +.invalid-feedback { + display: none; + width: 100%; + margin-top: 0.25rem; + font-size: 0.875em; + color: var(--bs-form-invalid-color); +} + +.invalid-tooltip { + position: absolute; + top: 100%; + z-index: 5; + display: none; + max-width: 100%; + padding: 0.25rem 0.5rem; + margin-top: 0.1rem; + font-size: 0.875rem; + color: #fff; + background-color: var(--bs-danger); + border-radius: var(--bs-border-radius); +} + +.was-validated :invalid ~ .invalid-feedback, +.was-validated :invalid ~ .invalid-tooltip, +.is-invalid ~ .invalid-feedback, +.is-invalid ~ .invalid-tooltip { + display: block; +} + +.was-validated .form-control:invalid, .form-control.is-invalid { + border-color: var(--bs-form-invalid-border-color); + padding-right: calc(1.5em + 0.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='%23FE2E63'%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='%23FE2E63' stroke='none'/%3e%3c/svg%3e"); + background-repeat: no-repeat; + background-position: right calc(0.375em + 0.1875rem) center; + background-size: calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-control:invalid:focus, .form-control.is-invalid:focus { + border-color: var(--bs-form-invalid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} + +.was-validated textarea.form-control:invalid, textarea.form-control.is-invalid { + padding-right: calc(1.5em + 0.75rem); + background-position: top calc(0.375em + 0.1875rem) right calc(0.375em + 0.1875rem); +} + +.was-validated .form-select:invalid, .form-select.is-invalid { + border-color: var(--bs-form-invalid-border-color); +} +.was-validated .form-select:invalid:not([multiple]):not([size]), .was-validated .form-select:invalid:not([multiple])[size="1"], .form-select.is-invalid:not([multiple]):not([size]), .form-select.is-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='%23FE2E63'%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='%23FE2E63' stroke='none'/%3e%3c/svg%3e"); + padding-right: 4.125rem; + background-position: right 0.75rem center, center right 2.25rem; + background-size: 16px 12px, calc(0.75em + 0.375rem) calc(0.75em + 0.375rem); +} +.was-validated .form-select:invalid:focus, .form-select.is-invalid:focus { + border-color: var(--bs-form-invalid-border-color); + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} + +.was-validated .form-control-color:invalid, .form-control-color.is-invalid { + width: calc(3rem + calc(1.5em + 0.75rem)); +} + +.was-validated .form-check-input:invalid, .form-check-input.is-invalid { + border-color: var(--bs-form-invalid-border-color); +} +.was-validated .form-check-input:invalid:checked, .form-check-input.is-invalid:checked { + background-color: var(--bs-form-invalid-color); +} +.was-validated .form-check-input:invalid:focus, .form-check-input.is-invalid:focus { + box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), 0.25); +} +.was-validated .form-check-input:invalid ~ .form-check-label, .form-check-input.is-invalid ~ .form-check-label { + color: var(--bs-form-invalid-color); +} + +.form-check-inline .form-check-input ~ .invalid-feedback { + margin-left: 0.5em; +} + +.was-validated .input-group > .form-control:not(:focus):invalid, .input-group > .form-control:not(:focus).is-invalid, +.was-validated .input-group > .form-select:not(:focus):invalid, +.input-group > .form-select:not(:focus).is-invalid, +.was-validated .input-group > .form-floating:not(:focus-within):invalid, +.input-group > .form-floating:not(:focus-within).is-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; + 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 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; +} +@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, :not(.btn-check) + .btn:active, .btn:first-child:active, .btn.active, .btn.show { + 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, :not(.btn-check) + .btn:active:focus-visible, .btn:first-child:active:focus-visible, .btn.active:focus-visible, .btn.show:focus-visible { + 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-baca2_blue { + --bs-btn-color: #000; + --bs-btn-bg: #08D9D6; + --bs-btn-border-color: #08D9D6; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #2ddfdc; + --bs-btn-hover-border-color: #21ddda; + --bs-btn-focus-shadow-rgb: 7, 184, 182; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #39e1de; + --bs-btn-active-border-color: #21ddda; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #08D9D6; + --bs-btn-disabled-border-color: #08D9D6; +} + +.btn-baca2_beige { + --bs-btn-color: #000; + --bs-btn-bg: #D3CABD; + --bs-btn-border-color: #D3CABD; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #dad2c7; + --bs-btn-hover-border-color: #d7cfc4; + --bs-btn-focus-shadow-rgb: 179, 172, 161; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #dcd5ca; + --bs-btn-active-border-color: #d7cfc4; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #D3CABD; + --bs-btn-disabled-border-color: #D3CABD; +} + +.btn-baca2_pink { + --bs-btn-color: #000; + --bs-btn-bg: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #fe4d7a; + --bs-btn-hover-border-color: #fe4373; + --bs-btn-focus-shadow-rgb: 216, 39, 84; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #fe5882; + --bs-btn-active-border-color: #fe4373; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #FE2E63; + --bs-btn-disabled-border-color: #FE2E63; +} + +.btn-dark_muted { + --bs-btn-color: #fff; + --bs-btn-bg: #3e4042; + --bs-btn-border-color: #3e4042; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #353638; + --bs-btn-hover-border-color: #323335; + --bs-btn-focus-shadow-rgb: 91, 93, 94; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #323335; + --bs-btn-active-border-color: #2f3032; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #fff; + --bs-btn-disabled-bg: #3e4042; + --bs-btn-disabled-border-color: #3e4042; +} + +.btn-light_muted { + --bs-btn-color: #000; + --bs-btn-bg: #a0a0a0; + --bs-btn-border-color: #a0a0a0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #aeaeae; + --bs-btn-hover-border-color: #aaaaaa; + --bs-btn-focus-shadow-rgb: 136, 136, 136; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #b3b3b3; + --bs-btn-active-border-color: #aaaaaa; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #a0a0a0; + --bs-btn-disabled-border-color: #a0a0a0; +} + +.btn-pale_muted { + --bs-btn-color: #000; + --bs-btn-bg: #d0d0d0; + --bs-btn-border-color: #d0d0d0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #d7d7d7; + --bs-btn-hover-border-color: #d5d5d5; + --bs-btn-focus-shadow-rgb: 177, 177, 177; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #d9d9d9; + --bs-btn-active-border-color: #d5d5d5; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #d0d0d0; + --bs-btn-disabled-border-color: #d0d0d0; +} + +.btn-darker { + --bs-btn-color: #fff; + --bs-btn-bg: #171a1d; + --bs-btn-border-color: #171a1d; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #141619; + --bs-btn-hover-border-color: #121517; + --bs-btn-focus-shadow-rgb: 58, 60, 63; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #121517; + --bs-btn-active-border-color: #111416; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #fff; + --bs-btn-disabled-bg: #171a1d; + --bs-btn-disabled-border-color: #171a1d; +} + +.btn-primary { + --bs-btn-color: #000; + --bs-btn-bg: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #fe4d7a; + --bs-btn-hover-border-color: #fe4373; + --bs-btn-focus-shadow-rgb: 216, 39, 84; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #fe5882; + --bs-btn-active-border-color: #fe4373; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #FE2E63; + --bs-btn-disabled-border-color: #FE2E63; +} + +.btn-secondary { + --bs-btn-color: #000; + --bs-btn-bg: #D3CABD; + --bs-btn-border-color: #D3CABD; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #dad2c7; + --bs-btn-hover-border-color: #d7cfc4; + --bs-btn-focus-shadow-rgb: 179, 172, 161; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #dcd5ca; + --bs-btn-active-border-color: #d7cfc4; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #D3CABD; + --bs-btn-disabled-border-color: #D3CABD; +} + +.btn-success { + --bs-btn-color: #000; + --bs-btn-bg: #08D9D6; + --bs-btn-border-color: #08D9D6; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #2ddfdc; + --bs-btn-hover-border-color: #21ddda; + --bs-btn-focus-shadow-rgb: 7, 184, 182; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #39e1de; + --bs-btn-active-border-color: #21ddda; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #08D9D6; + --bs-btn-disabled-border-color: #08D9D6; +} + +.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: #000; + --bs-btn-bg: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #fe4d7a; + --bs-btn-hover-border-color: #fe4373; + --bs-btn-focus-shadow-rgb: 216, 39, 84; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #fe5882; + --bs-btn-active-border-color: #fe4373; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #000; + --bs-btn-disabled-bg: #FE2E63; + --bs-btn-disabled-border-color: #FE2E63; +} + +.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-baca2_blue { + --bs-btn-color: #08D9D6; + --bs-btn-border-color: #08D9D6; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #08D9D6; + --bs-btn-hover-border-color: #08D9D6; + --bs-btn-focus-shadow-rgb: 8, 217, 214; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #08D9D6; + --bs-btn-active-border-color: #08D9D6; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #08D9D6; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #08D9D6; + --bs-gradient: none; +} + +.btn-outline-baca2_beige { + --bs-btn-color: #D3CABD; + --bs-btn-border-color: #D3CABD; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #D3CABD; + --bs-btn-hover-border-color: #D3CABD; + --bs-btn-focus-shadow-rgb: 211, 202, 189; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #D3CABD; + --bs-btn-active-border-color: #D3CABD; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #D3CABD; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #D3CABD; + --bs-gradient: none; +} + +.btn-outline-baca2_pink { + --bs-btn-color: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #FE2E63; + --bs-btn-hover-border-color: #FE2E63; + --bs-btn-focus-shadow-rgb: 254, 46, 99; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #FE2E63; + --bs-btn-active-border-color: #FE2E63; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #FE2E63; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #FE2E63; + --bs-gradient: none; +} + +.btn-outline-dark_muted { + --bs-btn-color: #3e4042; + --bs-btn-border-color: #3e4042; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #3e4042; + --bs-btn-hover-border-color: #3e4042; + --bs-btn-focus-shadow-rgb: 62, 64, 66; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #3e4042; + --bs-btn-active-border-color: #3e4042; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #3e4042; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #3e4042; + --bs-gradient: none; +} + +.btn-outline-light_muted { + --bs-btn-color: #a0a0a0; + --bs-btn-border-color: #a0a0a0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #a0a0a0; + --bs-btn-hover-border-color: #a0a0a0; + --bs-btn-focus-shadow-rgb: 160, 160, 160; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #a0a0a0; + --bs-btn-active-border-color: #a0a0a0; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #a0a0a0; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #a0a0a0; + --bs-gradient: none; +} + +.btn-outline-pale_muted { + --bs-btn-color: #d0d0d0; + --bs-btn-border-color: #d0d0d0; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #d0d0d0; + --bs-btn-hover-border-color: #d0d0d0; + --bs-btn-focus-shadow-rgb: 208, 208, 208; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #d0d0d0; + --bs-btn-active-border-color: #d0d0d0; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #d0d0d0; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #d0d0d0; + --bs-gradient: none; +} + +.btn-outline-darker { + --bs-btn-color: #171a1d; + --bs-btn-border-color: #171a1d; + --bs-btn-hover-color: #fff; + --bs-btn-hover-bg: #171a1d; + --bs-btn-hover-border-color: #171a1d; + --bs-btn-focus-shadow-rgb: 23, 26, 29; + --bs-btn-active-color: #fff; + --bs-btn-active-bg: #171a1d; + --bs-btn-active-border-color: #171a1d; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #171a1d; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #171a1d; + --bs-gradient: none; +} + +.btn-outline-primary { + --bs-btn-color: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #FE2E63; + --bs-btn-hover-border-color: #FE2E63; + --bs-btn-focus-shadow-rgb: 254, 46, 99; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #FE2E63; + --bs-btn-active-border-color: #FE2E63; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #FE2E63; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #FE2E63; + --bs-gradient: none; +} + +.btn-outline-secondary { + --bs-btn-color: #D3CABD; + --bs-btn-border-color: #D3CABD; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #D3CABD; + --bs-btn-hover-border-color: #D3CABD; + --bs-btn-focus-shadow-rgb: 211, 202, 189; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #D3CABD; + --bs-btn-active-border-color: #D3CABD; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #D3CABD; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #D3CABD; + --bs-gradient: none; +} + +.btn-outline-success { + --bs-btn-color: #08D9D6; + --bs-btn-border-color: #08D9D6; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #08D9D6; + --bs-btn-hover-border-color: #08D9D6; + --bs-btn-focus-shadow-rgb: 8, 217, 214; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #08D9D6; + --bs-btn-active-border-color: #08D9D6; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #08D9D6; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #08D9D6; + --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: #FE2E63; + --bs-btn-border-color: #FE2E63; + --bs-btn-hover-color: #000; + --bs-btn-hover-bg: #FE2E63; + --bs-btn-hover-border-color: #FE2E63; + --bs-btn-focus-shadow-rgb: 254, 46, 99; + --bs-btn-active-color: #000; + --bs-btn-active-bg: #FE2E63; + --bs-btn-active-border-color: #FE2E63; + --bs-btn-active-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125); + --bs-btn-disabled-color: #FE2E63; + --bs-btn-disabled-bg: transparent; + --bs-btn-disabled-border-color: #FE2E63; + --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: 216, 39, 84; + text-decoration: underline; +} +.btn-link:focus-visible { + color: var(--bs-btn-color); +} +.btn-link:hover { + color: var(--bs-btn-hover-color); +} + +.btn-lg, .btn-group-lg > .btn { + --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-sm, .btn-group-sm > .btn { + --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 0.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 0.35s ease; +} +@media (prefers-reduced-motion: reduce) { + .collapsing { + transition: none; + } +} +.collapsing.collapse-horizontal { + width: 0; + height: auto; + transition: width 0.35s ease; +} +@media (prefers-reduced-motion: reduce) { + .collapsing.collapse-horizontal { + transition: none; + } +} + +.dropup, +.dropend, +.dropdown, +.dropstart, +.dropup-center, +.dropdown-center { + position: relative; +} + +.dropdown-toggle { + white-space: nowrap; +} +.dropdown-toggle::after { + display: inline-block; + margin-left: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid; + border-right: 0.3em solid transparent; + border-bottom: 0; + border-left: 0.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: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --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: #FE2E63; + --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: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0; + border-right: 0.3em solid transparent; + border-bottom: 0.3em solid; + border-left: 0.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: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0; + border-bottom: 0.3em solid transparent; + border-left: 0.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: 0.255em; + vertical-align: 0.255em; + content: ""; +} +.dropstart .dropdown-toggle::after { + display: none; +} +.dropstart .dropdown-toggle::before { + display: inline-block; + margin-right: 0.255em; + vertical-align: 0.255em; + content: ""; + border-top: 0.3em solid transparent; + border-right: 0.3em solid; + border-bottom: 0.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:hover, .dropdown-item:focus { + 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: 0.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: #FE2E63; + --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 > .btn, +.btn-group-vertical > .btn { + position: relative; + flex: 1 1 auto; +} +.btn-group > .btn-check:checked + .btn, +.btn-group > .btn-check:focus + .btn, +.btn-group > .btn:hover, +.btn-group > .btn:focus, +.btn-group > .btn:active, +.btn-group > .btn.active, +.btn-group-vertical > .btn-check:checked + .btn, +.btn-group-vertical > .btn-check:focus + .btn, +.btn-group-vertical > .btn:hover, +.btn-group-vertical > .btn:focus, +.btn-group-vertical > .btn:active, +.btn-group-vertical > .btn.active { + 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 > :not(.btn-check:first-child) + .btn, +.btn-group > .btn-group:not(:first-child) { + margin-left: calc(var(--bs-border-width) * -1); +} +.btn-group > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group > .btn.dropdown-toggle-split:first-child, +.btn-group > .btn-group:not(:last-child) > .btn { + border-top-right-radius: 0; + border-bottom-right-radius: 0; +} +.btn-group > .btn:nth-child(n+3), +.btn-group > :not(.btn-check) + .btn, +.btn-group > .btn-group:not(:first-child) > .btn { + border-top-left-radius: 0; + border-bottom-left-radius: 0; +} + +.dropdown-toggle-split { + padding-right: 0.5625rem; + padding-left: 0.5625rem; +} +.dropdown-toggle-split::after, .dropup .dropdown-toggle-split::after, .dropend .dropdown-toggle-split::after { + margin-left: 0; +} +.dropstart .dropdown-toggle-split::before { + margin-right: 0; +} + +.btn-sm + .dropdown-toggle-split, .btn-group-sm > .btn + .dropdown-toggle-split { + padding-right: 0.375rem; + padding-left: 0.375rem; +} + +.btn-lg + .dropdown-toggle-split, .btn-group-lg > .btn + .dropdown-toggle-split { + padding-right: 0.75rem; + padding-left: 0.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:not(:first-child), +.btn-group-vertical > .btn-group:not(:first-child) { + margin-top: calc(var(--bs-border-width) * -1); +} +.btn-group-vertical > .btn:not(:last-child):not(.dropdown-toggle), +.btn-group-vertical > .btn-group:not(:last-child) > .btn { + border-bottom-right-radius: 0; + border-bottom-left-radius: 0; +} +.btn-group-vertical > .btn ~ .btn, +.btn-group-vertical > .btn-group:not(:first-child) > .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: none; + border: 0; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .nav-link { + transition: none; + } +} +.nav-link:hover, .nav-link:focus { + color: var(--bs-nav-link-hover-color); +} +.nav-link:focus-visible { + outline: 0; + box-shadow: 0 0 0 0.25rem rgba(254, 46, 99, 0.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:hover, .nav-tabs .nav-link:focus { + isolation: isolate; + border-color: var(--bs-nav-tabs-link-hover-border-color); +} +.nav-tabs .nav-link.active, +.nav-tabs .nav-item.show .nav-link { + 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: #FE2E63; +} +.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:hover, .nav-underline .nav-link:focus { + 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-link, +.nav-fill .nav-item { + flex: 1 1 auto; + text-align: center; +} + +.nav-justified > .nav-link, +.nav-justified .nav-item { + 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-sm, +.navbar > .container-md, +.navbar > .container-lg, +.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:hover, .navbar-brand:focus { + 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: 0.5rem; + padding-bottom: 0.5rem; + color: var(--bs-navbar-color); +} +.navbar-text a, +.navbar-text a:hover, +.navbar-text a:focus { + 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(-0.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(-0.5 * var(--bs-card-cap-padding-x)); + margin-bottom: calc(-1 * var(--bs-card-cap-padding-y)); + margin-left: calc(-0.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(-0.5 * var(--bs-card-cap-padding-x)); + margin-left: calc(-0.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-top, +.card-img-bottom { + 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-img-top, + .card-group > .card:not(:last-child) .card-header { + border-top-right-radius: 0; + } + .card-group > .card:not(:last-child) .card-img-bottom, + .card-group > .card:not(:last-child) .card-footer { + 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-img-top, + .card-group > .card:not(:first-child) .card-header { + border-top-left-radius: 0; + } + .card-group > .card:not(:first-child) .card-img-bottom, + .card-group > .card:not(:first-child) .card-footer { + 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='%23212529'%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-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='%23661228'%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-focus-border-color: #ff97b1; + --bs-accordion-btn-focus-box-shadow: 0 0 0 0.25rem rgba(254, 46, 99, 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; + border-color: var(--bs-accordion-btn-focus-border-color); + 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-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-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-collapse { + border-width: 0; +} +.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-button, .accordion-flush .accordion-item .accordion-button.collapsed { + 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='%23fe82a1'%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='%23fe82a1'%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, "/") /* rtl: 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(254, 46, 99, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #FE2E63; + --bs-pagination-active-border-color: #FE2E63; + --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 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; +} +@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); +} +.page-link.active, .active > .page-link { + z-index: 3; + color: var(--bs-pagination-active-color); + background-color: var(--bs-pagination-active-bg); + border-color: var(--bs-pagination-active-border-color); +} +.page-link.disabled, .disabled > .page-link { + 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-baca2_blue { + --bs-alert-color: var(--bs-baca2_blue-text-emphasis); + --bs-alert-bg: var(--bs-baca2_blue-bg-subtle); + --bs-alert-border-color: var(--bs-baca2_blue-border-subtle); + --bs-alert-link-color: var(--bs-baca2_blue-text-emphasis); +} + +.alert-baca2_beige { + --bs-alert-color: var(--bs-baca2_beige-text-emphasis); + --bs-alert-bg: var(--bs-baca2_beige-bg-subtle); + --bs-alert-border-color: var(--bs-baca2_beige-border-subtle); + --bs-alert-link-color: var(--bs-baca2_beige-text-emphasis); +} + +.alert-baca2_pink { + --bs-alert-color: var(--bs-baca2_pink-text-emphasis); + --bs-alert-bg: var(--bs-baca2_pink-bg-subtle); + --bs-alert-border-color: var(--bs-baca2_pink-border-subtle); + --bs-alert-link-color: var(--bs-baca2_pink-text-emphasis); +} + +.alert-dark_muted { + --bs-alert-color: var(--bs-dark_muted-text-emphasis); + --bs-alert-bg: var(--bs-dark_muted-bg-subtle); + --bs-alert-border-color: var(--bs-dark_muted-border-subtle); + --bs-alert-link-color: var(--bs-dark_muted-text-emphasis); +} + +.alert-light_muted { + --bs-alert-color: var(--bs-light_muted-text-emphasis); + --bs-alert-bg: var(--bs-light_muted-bg-subtle); + --bs-alert-border-color: var(--bs-light_muted-border-subtle); + --bs-alert-link-color: var(--bs-light_muted-text-emphasis); +} + +.alert-pale_muted { + --bs-alert-color: var(--bs-pale_muted-text-emphasis); + --bs-alert-bg: var(--bs-pale_muted-bg-subtle); + --bs-alert-border-color: var(--bs-pale_muted-border-subtle); + --bs-alert-link-color: var(--bs-pale_muted-text-emphasis); +} + +.alert-darker { + --bs-alert-color: var(--bs-darker-text-emphasis); + --bs-alert-bg: var(--bs-darker-bg-subtle); + --bs-alert-border-color: var(--bs-darker-border-subtle); + --bs-alert-link-color: var(--bs-darker-text-emphasis); +} + +.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: #FE2E63; + --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, 0.15) 25%, transparent 25%, transparent 50%, rgba(255, 255, 255, 0.15) 50%, rgba(255, 255, 255, 0.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: #FE2E63; + --bs-list-group-active-border-color: #FE2E63; + 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:hover, .list-group-item-action:focus { + 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-baca2_blue { + --bs-list-group-color: var(--bs-baca2_blue-text-emphasis); + --bs-list-group-bg: var(--bs-baca2_blue-bg-subtle); + --bs-list-group-border-color: var(--bs-baca2_blue-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-baca2_blue-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-baca2_blue-border-subtle); + --bs-list-group-active-color: var(--bs-baca2_blue-bg-subtle); + --bs-list-group-active-bg: var(--bs-baca2_blue-text-emphasis); + --bs-list-group-active-border-color: var(--bs-baca2_blue-text-emphasis); +} + +.list-group-item-baca2_beige { + --bs-list-group-color: var(--bs-baca2_beige-text-emphasis); + --bs-list-group-bg: var(--bs-baca2_beige-bg-subtle); + --bs-list-group-border-color: var(--bs-baca2_beige-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-baca2_beige-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-baca2_beige-border-subtle); + --bs-list-group-active-color: var(--bs-baca2_beige-bg-subtle); + --bs-list-group-active-bg: var(--bs-baca2_beige-text-emphasis); + --bs-list-group-active-border-color: var(--bs-baca2_beige-text-emphasis); +} + +.list-group-item-baca2_pink { + --bs-list-group-color: var(--bs-baca2_pink-text-emphasis); + --bs-list-group-bg: var(--bs-baca2_pink-bg-subtle); + --bs-list-group-border-color: var(--bs-baca2_pink-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-baca2_pink-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-baca2_pink-border-subtle); + --bs-list-group-active-color: var(--bs-baca2_pink-bg-subtle); + --bs-list-group-active-bg: var(--bs-baca2_pink-text-emphasis); + --bs-list-group-active-border-color: var(--bs-baca2_pink-text-emphasis); +} + +.list-group-item-dark_muted { + --bs-list-group-color: var(--bs-dark_muted-text-emphasis); + --bs-list-group-bg: var(--bs-dark_muted-bg-subtle); + --bs-list-group-border-color: var(--bs-dark_muted-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-dark_muted-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-dark_muted-border-subtle); + --bs-list-group-active-color: var(--bs-dark_muted-bg-subtle); + --bs-list-group-active-bg: var(--bs-dark_muted-text-emphasis); + --bs-list-group-active-border-color: var(--bs-dark_muted-text-emphasis); +} + +.list-group-item-light_muted { + --bs-list-group-color: var(--bs-light_muted-text-emphasis); + --bs-list-group-bg: var(--bs-light_muted-bg-subtle); + --bs-list-group-border-color: var(--bs-light_muted-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-light_muted-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-light_muted-border-subtle); + --bs-list-group-active-color: var(--bs-light_muted-bg-subtle); + --bs-list-group-active-bg: var(--bs-light_muted-text-emphasis); + --bs-list-group-active-border-color: var(--bs-light_muted-text-emphasis); +} + +.list-group-item-pale_muted { + --bs-list-group-color: var(--bs-pale_muted-text-emphasis); + --bs-list-group-bg: var(--bs-pale_muted-bg-subtle); + --bs-list-group-border-color: var(--bs-pale_muted-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-pale_muted-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-pale_muted-border-subtle); + --bs-list-group-active-color: var(--bs-pale_muted-bg-subtle); + --bs-list-group-active-bg: var(--bs-pale_muted-text-emphasis); + --bs-list-group-active-border-color: var(--bs-pale_muted-text-emphasis); +} + +.list-group-item-darker { + --bs-list-group-color: var(--bs-darker-text-emphasis); + --bs-list-group-bg: var(--bs-darker-bg-subtle); + --bs-list-group-border-color: var(--bs-darker-border-subtle); + --bs-list-group-action-hover-color: var(--bs-emphasis-color); + --bs-list-group-action-hover-bg: var(--bs-darker-border-subtle); + --bs-list-group-action-active-color: var(--bs-emphasis-color); + --bs-list-group-action-active-bg: var(--bs-darker-border-subtle); + --bs-list-group-active-color: var(--bs-darker-bg-subtle); + --bs-list-group-active-bg: var(--bs-darker-text-emphasis); + --bs-list-group-active-border-color: var(--bs-darker-text-emphasis); +} + +.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(254, 46, 99, 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: 0.25em 0.25em; + color: var(--bs-btn-close-color); + background: transparent var(--bs-btn-close-bg) center/1em auto no-repeat; + border: 0; + border-radius: 0.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; + 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: 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(-0.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: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --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 0.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; + justify-content: space-between; + 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) * 0.5) calc(var(--bs-modal-header-padding-x) * 0.5); + margin: calc(-0.5 * var(--bs-modal-header-padding-y)) calc(-0.5 * var(--bs-modal-header-padding-x)) calc(-0.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) * 0.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) * 0.5); +} + +@media (min-width: 576px) { + .modal { + --bs-modal-margin: 1.75rem; + --bs-modal-box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + } + .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-header, +.modal-fullscreen .modal-footer { + 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-header, + .modal-fullscreen-sm-down .modal-footer { + 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-header, + .modal-fullscreen-md-down .modal-footer { + 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-header, + .modal-fullscreen-lg-down .modal-footer { + 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-header, + .modal-fullscreen-xl-down .modal-footer { + 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-header, + .modal-fullscreen-xxl-down .modal-footer { + 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-top .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow { + bottom: calc(-1 * var(--bs-tooltip-arrow-height)); +} +.bs-tooltip-top .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=top] .tooltip-arrow::before { + top: -1px; + border-width: var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0; + border-top-color: var(--bs-tooltip-bg); +} + +/* rtl:begin:ignore */ +.bs-tooltip-end .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow { + left: calc(-1 * var(--bs-tooltip-arrow-height)); + width: var(--bs-tooltip-arrow-height); + height: var(--bs-tooltip-arrow-width); +} +.bs-tooltip-end .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=right] .tooltip-arrow::before { + right: -1px; + border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height) calc(var(--bs-tooltip-arrow-width) * 0.5) 0; + border-right-color: var(--bs-tooltip-bg); +} + +/* rtl:end:ignore */ +.bs-tooltip-bottom .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow { + top: calc(-1 * var(--bs-tooltip-arrow-height)); +} +.bs-tooltip-bottom .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=bottom] .tooltip-arrow::before { + bottom: -1px; + border-width: 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height); + border-bottom-color: var(--bs-tooltip-bg); +} + +/* rtl:begin:ignore */ +.bs-tooltip-start .tooltip-arrow, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow { + right: calc(-1 * var(--bs-tooltip-arrow-height)); + width: var(--bs-tooltip-arrow-height); + height: var(--bs-tooltip-arrow-width); +} +.bs-tooltip-start .tooltip-arrow::before, .bs-tooltip-auto[data-popper-placement^=left] .tooltip-arrow::before { + left: -1px; + border-width: calc(var(--bs-tooltip-arrow-width) * 0.5) 0 calc(var(--bs-tooltip-arrow-width) * 0.5) var(--bs-tooltip-arrow-height); + border-left-color: var(--bs-tooltip-bg); +} + +/* rtl:end:ignore */ +.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: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + --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::before, .popover .popover-arrow::after { + position: absolute; + display: block; + content: ""; + border-color: transparent; + border-style: solid; + border-width: 0; +} + +.bs-popover-top > .popover-arrow, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow { + bottom: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); +} +.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before, .bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after { + border-width: var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0; +} +.bs-popover-top > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::before { + bottom: 0; + border-top-color: var(--bs-popover-arrow-border); +} +.bs-popover-top > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=top] > .popover-arrow::after { + bottom: var(--bs-popover-border-width); + border-top-color: var(--bs-popover-bg); +} + +/* rtl:begin:ignore */ +.bs-popover-end > .popover-arrow, .bs-popover-auto[data-popper-placement^=right] > .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-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before, .bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after { + border-width: calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height) calc(var(--bs-popover-arrow-width) * 0.5) 0; +} +.bs-popover-end > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::before { + left: 0; + border-right-color: var(--bs-popover-arrow-border); +} +.bs-popover-end > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=right] > .popover-arrow::after { + left: var(--bs-popover-border-width); + border-right-color: var(--bs-popover-bg); +} + +/* rtl:end:ignore */ +.bs-popover-bottom > .popover-arrow, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow { + top: calc(-1 * (var(--bs-popover-arrow-height)) - var(--bs-popover-border-width)); +} +.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before, .bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after { + border-width: 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height); +} +.bs-popover-bottom > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::before { + top: 0; + border-bottom-color: var(--bs-popover-arrow-border); +} +.bs-popover-bottom > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=bottom] > .popover-arrow::after { + top: var(--bs-popover-border-width); + border-bottom-color: var(--bs-popover-bg); +} +.bs-popover-bottom .popover-header::before, .bs-popover-auto[data-popper-placement^=bottom] .popover-header::before { + position: absolute; + top: 0; + left: 50%; + display: block; + width: var(--bs-popover-arrow-width); + margin-left: calc(-0.5 * var(--bs-popover-arrow-width)); + content: ""; + border-bottom: var(--bs-popover-border-width) solid var(--bs-popover-header-bg); +} + +/* rtl:begin:ignore */ +.bs-popover-start > .popover-arrow, .bs-popover-auto[data-popper-placement^=left] > .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-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before, .bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after { + border-width: calc(var(--bs-popover-arrow-width) * 0.5) 0 calc(var(--bs-popover-arrow-width) * 0.5) var(--bs-popover-arrow-height); +} +.bs-popover-start > .popover-arrow::before, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::before { + right: 0; + border-left-color: var(--bs-popover-arrow-border); +} +.bs-popover-start > .popover-arrow::after, .bs-popover-auto[data-popper-placement^=left] > .popover-arrow::after { + right: var(--bs-popover-border-width); + border-left-color: var(--bs-popover-bg); +} + +/* rtl:end:ignore */ +.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%; + backface-visibility: hidden; + transition: transform 0.6s ease-in-out; +} +@media (prefers-reduced-motion: reduce) { + .carousel-item { + transition: none; + } +} + +.carousel-item.active, +.carousel-item-next, +.carousel-item-prev { + display: block; +} + +.carousel-item-next:not(.carousel-item-start), +.active.carousel-item-end { + transform: translateX(100%); +} + +.carousel-item-prev:not(.carousel-item-end), +.active.carousel-item-start { + transform: translateX(-100%); +} + +.carousel-fade .carousel-item { + opacity: 0; + transition-property: opacity; + transform: none; +} +.carousel-fade .carousel-item.active, +.carousel-fade .carousel-item-next.carousel-item-start, +.carousel-fade .carousel-item-prev.carousel-item-end { + z-index: 1; + opacity: 1; +} +.carousel-fade .active.carousel-item-start, +.carousel-fade .active.carousel-item-end { + z-index: 0; + opacity: 0; + transition: opacity 0s 0.6s; +} +@media (prefers-reduced-motion: reduce) { + .carousel-fade .active.carousel-item-start, + .carousel-fade .active.carousel-item-end { + transition: none; + } +} + +.carousel-control-prev, +.carousel-control-next { + 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: none; + border: 0; + opacity: 0.5; + transition: opacity 0.15s ease; +} +@media (prefers-reduced-motion: reduce) { + .carousel-control-prev, + .carousel-control-next { + transition: none; + } +} +.carousel-control-prev:hover, .carousel-control-prev:focus, +.carousel-control-next:hover, +.carousel-control-next:focus { + color: #fff; + text-decoration: none; + outline: 0; + opacity: 0.9; +} + +.carousel-control-prev { + left: 0; +} + +.carousel-control-next { + right: 0; +} + +.carousel-control-prev-icon, +.carousel-control-next-icon { + display: inline-block; + width: 2rem; + height: 2rem; + background-repeat: no-repeat; + background-position: 50%; + background-size: 100% 100%; +} + +/* rtl:options: { + "autoRename": true, + "stringMap":[ { + "name" : "prev-next", + "search" : "prev", + "replace" : "next" + } ] +} */ +.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: 0.5; + transition: opacity 0.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-prev-icon, +.carousel-dark .carousel-control-next-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-prev-icon, +[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 { + 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-grow, +.spinner-border { + 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) /* rtl:ignore */; + } +} +.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-xxl, .offcanvas-xl, .offcanvas-lg, .offcanvas-md, .offcanvas-sm { + --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: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + --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.showing, .offcanvas-sm.show:not(.hiding) { + transform: none; + } + .offcanvas-sm.showing, .offcanvas-sm.hiding, .offcanvas-sm.show { + 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.showing, .offcanvas-md.show:not(.hiding) { + transform: none; + } + .offcanvas-md.showing, .offcanvas-md.hiding, .offcanvas-md.show { + 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.showing, .offcanvas-lg.show:not(.hiding) { + transform: none; + } + .offcanvas-lg.showing, .offcanvas-lg.hiding, .offcanvas-lg.show { + 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.showing, .offcanvas-xl.show:not(.hiding) { + transform: none; + } + .offcanvas-xl.showing, .offcanvas-xl.hiding, .offcanvas-xl.show { + 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.showing, .offcanvas-xxl.show:not(.hiding) { + transform: none; + } + .offcanvas-xxl.showing, .offcanvas-xxl.hiding, .offcanvas-xxl.show { + 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.showing, .offcanvas.show:not(.hiding) { + transform: none; +} +.offcanvas.showing, .offcanvas.hiding, .offcanvas.show { + 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: 0.5; +} + +.offcanvas-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: var(--bs-offcanvas-padding-y) var(--bs-offcanvas-padding-x); +} +.offcanvas-header .btn-close { + padding: calc(var(--bs-offcanvas-padding-y) * 0.5) calc(var(--bs-offcanvas-padding-x) * 0.5); + margin-top: calc(-0.5 * var(--bs-offcanvas-padding-y)); + margin-right: calc(-0.5 * var(--bs-offcanvas-padding-x)); + margin-bottom: calc(-0.5 * var(--bs-offcanvas-padding-y)); +} + +.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: 0.5; +} +.placeholder.btn::before { + display: inline-block; + content: ""; +} + +.placeholder-xs { + min-height: 0.6em; +} + +.placeholder-sm { + min-height: 0.8em; +} + +.placeholder-lg { + min-height: 1.2em; +} + +.placeholder-glow .placeholder { + animation: placeholder-glow 2s ease-in-out infinite; +} + +@keyframes placeholder-glow { + 50% { + opacity: 0.2; + } +} +.placeholder-wave { + mask-image: linear-gradient(130deg, #000 55%, rgba(0, 0, 0, 0.8) 75%, #000 95%); + mask-size: 200% 100%; + animation: placeholder-wave 2s linear infinite; +} + +@keyframes placeholder-wave { + 100% { + mask-position: -200% 0%; + } +} +.clearfix::after { + display: block; + clear: both; + content: ""; +} + +.text-bg-baca2_blue { + color: #000 !important; + background-color: RGBA(var(--bs-baca2_blue-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-baca2_beige { + color: #000 !important; + background-color: RGBA(var(--bs-baca2_beige-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-baca2_pink { + color: #000 !important; + background-color: RGBA(var(--bs-baca2_pink-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-dark_muted { + color: #fff !important; + background-color: RGBA(var(--bs-dark_muted-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-light_muted { + color: #000 !important; + background-color: RGBA(var(--bs-light_muted-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-pale_muted { + color: #000 !important; + background-color: RGBA(var(--bs-pale_muted-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-darker { + color: #fff !important; + background-color: RGBA(var(--bs-darker-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-primary { + color: #000 !important; + background-color: RGBA(var(--bs-primary-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-secondary { + color: #000 !important; + background-color: RGBA(var(--bs-secondary-rgb), var(--bs-bg-opacity, 1)) !important; +} + +.text-bg-success { + color: #000 !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: #000 !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-baca2_blue { + color: RGBA(var(--bs-baca2_blue-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-baca2_blue-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-baca2_blue:hover, .link-baca2_blue:focus { + color: RGBA(57, 225, 222, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(57, 225, 222, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-baca2_beige { + color: RGBA(var(--bs-baca2_beige-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-baca2_beige-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-baca2_beige:hover, .link-baca2_beige:focus { + color: RGBA(220, 213, 202, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(220, 213, 202, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-baca2_pink { + color: RGBA(var(--bs-baca2_pink-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-baca2_pink-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-baca2_pink:hover, .link-baca2_pink:focus { + color: RGBA(254, 88, 130, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(254, 88, 130, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-dark_muted { + color: RGBA(var(--bs-dark_muted-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-dark_muted-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-dark_muted:hover, .link-dark_muted:focus { + color: RGBA(50, 51, 53, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(50, 51, 53, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-light_muted { + color: RGBA(var(--bs-light_muted-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-light_muted-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-light_muted:hover, .link-light_muted:focus { + color: RGBA(179, 179, 179, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(179, 179, 179, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-pale_muted { + color: RGBA(var(--bs-pale_muted-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-pale_muted-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-pale_muted:hover, .link-pale_muted:focus { + color: RGBA(217, 217, 217, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(217, 217, 217, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-darker { + color: RGBA(var(--bs-darker-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-darker-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-darker:hover, .link-darker:focus { + color: RGBA(18, 21, 23, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(18, 21, 23, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-primary { + color: RGBA(var(--bs-primary-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-primary-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-primary:hover, .link-primary:focus { + color: RGBA(254, 88, 130, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(254, 88, 130, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-secondary { + color: RGBA(var(--bs-secondary-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-secondary-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-secondary:hover, .link-secondary:focus { + color: RGBA(220, 213, 202, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(220, 213, 202, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-success { + color: RGBA(var(--bs-success-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-success-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-success:hover, .link-success:focus { + color: RGBA(57, 225, 222, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(57, 225, 222, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-info { + color: RGBA(var(--bs-info-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-info-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-info:hover, .link-info:focus { + color: RGBA(61, 213, 243, var(--bs-link-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; + text-decoration-color: RGBA(var(--bs-warning-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-warning:hover, .link-warning:focus { + color: RGBA(255, 205, 57, var(--bs-link-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; + text-decoration-color: RGBA(var(--bs-danger-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-danger:hover, .link-danger:focus { + color: RGBA(254, 88, 130, var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(254, 88, 130, var(--bs-link-underline-opacity, 1)) !important; +} + +.link-light { + color: RGBA(var(--bs-light-rgb), var(--bs-link-opacity, 1)) !important; + text-decoration-color: RGBA(var(--bs-light-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-light:hover, .link-light:focus { + color: RGBA(249, 250, 251, var(--bs-link-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; + text-decoration-color: RGBA(var(--bs-dark-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-dark:hover, .link-dark:focus { + color: RGBA(26, 30, 33, var(--bs-link-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; + text-decoration-color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-underline-opacity, 1)) !important; +} +.link-body-emphasis:hover, .link-body-emphasis:focus { + color: RGBA(var(--bs-emphasis-color-rgb), var(--bs-link-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: 0.375rem; + align-items: center; + text-decoration-color: rgba(var(--bs-link-color-rgb), var(--bs-link-opacity, 0.5)); + text-underline-offset: 0.25em; + backface-visibility: hidden; +} +.icon-link > .bi { + flex-shrink: 0; + width: 1em; + height: 1em; + fill: currentcolor; + transition: 0.2s ease-in-out transform; +} +@media (prefers-reduced-motion: reduce) { + .icon-link > .bi { + transition: none; + } +} + +.icon-link-hover:hover > .bi, .icon-link-hover:focus-visible > .bi { + transform: var(--bs-icon-link-transform, translate3d(0.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: sticky; + top: 0; + z-index: 1020; +} + +.sticky-bottom { + position: sticky; + bottom: 0; + z-index: 1020; +} + +@media (min-width: 576px) { + .sticky-sm-top { + position: sticky; + top: 0; + z-index: 1020; + } + .sticky-sm-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} +@media (min-width: 768px) { + .sticky-md-top { + position: sticky; + top: 0; + z-index: 1020; + } + .sticky-md-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} +@media (min-width: 992px) { + .sticky-lg-top { + position: sticky; + top: 0; + z-index: 1020; + } + .sticky-lg-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} +@media (min-width: 1200px) { + .sticky-xl-top { + position: sticky; + top: 0; + z-index: 1020; + } + .sticky-xl-bottom { + position: sticky; + bottom: 0; + z-index: 1020; + } +} +@media (min-width: 1400px) { + .sticky-xxl-top { + position: sticky; + top: 0; + z-index: 1020; + } + .sticky-xxl-bottom { + 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:not(caption), +.visually-hidden-focusable:not(:focus):not(:focus-within):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: 0.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 { + object-fit: contain !important; +} + +.object-fit-cover { + object-fit: cover !important; +} + +.object-fit-fill { + object-fit: fill !important; +} + +.object-fit-scale { + object-fit: scale-down !important; +} + +.object-fit-none { + object-fit: none !important; +} + +.opacity-0 { + opacity: 0 !important; +} + +.opacity-25 { + opacity: 0.25 !important; +} + +.opacity-50 { + opacity: 0.5 !important; +} + +.opacity-75 { + opacity: 0.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: 0 0.5rem 1rem rgba(0, 0, 0, 0.15) !important; +} + +.shadow-sm { + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075) !important; +} + +.shadow-lg { + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.175) !important; +} + +.shadow-none { + box-shadow: none !important; +} + +.focus-ring-baca2_blue { + --bs-focus-ring-color: rgba(var(--bs-baca2_blue-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-baca2_beige { + --bs-focus-ring-color: rgba(var(--bs-baca2_beige-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-baca2_pink { + --bs-focus-ring-color: rgba(var(--bs-baca2_pink-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-dark_muted { + --bs-focus-ring-color: rgba(var(--bs-dark_muted-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-light_muted { + --bs-focus-ring-color: rgba(var(--bs-light_muted-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-pale_muted { + --bs-focus-ring-color: rgba(var(--bs-pale_muted-rgb), var(--bs-focus-ring-opacity)); +} + +.focus-ring-darker { + --bs-focus-ring-color: rgba(var(--bs-darker-rgb), var(--bs-focus-ring-opacity)); +} + +.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: 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-baca2_blue { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-baca2_blue-rgb), var(--bs-border-opacity)) !important; +} + +.border-baca2_beige { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-baca2_beige-rgb), var(--bs-border-opacity)) !important; +} + +.border-baca2_pink { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-baca2_pink-rgb), var(--bs-border-opacity)) !important; +} + +.border-dark_muted { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-dark_muted-rgb), var(--bs-border-opacity)) !important; +} + +.border-light_muted { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-light_muted-rgb), var(--bs-border-opacity)) !important; +} + +.border-pale_muted { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-pale_muted-rgb), var(--bs-border-opacity)) !important; +} + +.border-darker { + --bs-border-opacity: 1; + border-color: rgba(var(--bs-darker-rgb), var(--bs-border-opacity)) !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: 0.25rem !important; +} + +.m-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; +} + +.mx-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; +} + +.my-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; +} + +.mt-2 { + margin-top: 0.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: 0.25rem !important; +} + +.me-2 { + margin-right: 0.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: 0.25rem !important; +} + +.mb-2 { + margin-bottom: 0.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: 0.25rem !important; +} + +.ms-2 { + margin-left: 0.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: 0.25rem !important; +} + +.p-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; +} + +.px-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; +} + +.py-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; +} + +.pt-2 { + padding-top: 0.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: 0.25rem !important; +} + +.pe-2 { + padding-right: 0.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: 0.25rem !important; +} + +.pb-2 { + padding-bottom: 0.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: 0.25rem !important; +} + +.ps-2 { + padding-left: 0.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: 0.25rem !important; +} + +.gap-2 { + gap: 0.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: 0.25rem !important; +} + +.row-gap-2 { + row-gap: 0.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 { + column-gap: 0 !important; +} + +.column-gap-1 { + column-gap: 0.25rem !important; +} + +.column-gap-2 { + column-gap: 0.5rem !important; +} + +.column-gap-3 { + column-gap: 1rem !important; +} + +.column-gap-4 { + column-gap: 1.5rem !important; +} + +.column-gap-5 { + 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 + 0.9vw) !important; +} + +.fs-3 { + font-size: calc(1.3rem + 0.6vw) !important; +} + +.fs-4 { + font-size: calc(1.275rem + 0.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; +} + +/* rtl:begin:remove */ +.text-break { + word-wrap: break-word !important; + word-break: break-word !important; +} + +/* rtl:end:remove */ +.text-baca2_blue { + --bs-text-opacity: 1; + color: rgba(var(--bs-baca2_blue-rgb), var(--bs-text-opacity)) !important; +} + +.text-baca2_beige { + --bs-text-opacity: 1; + color: rgba(var(--bs-baca2_beige-rgb), var(--bs-text-opacity)) !important; +} + +.text-baca2_pink { + --bs-text-opacity: 1; + color: rgba(var(--bs-baca2_pink-rgb), var(--bs-text-opacity)) !important; +} + +.text-dark_muted { + --bs-text-opacity: 1; + color: rgba(var(--bs-dark_muted-rgb), var(--bs-text-opacity)) !important; +} + +.text-light_muted { + --bs-text-opacity: 1; + color: rgba(var(--bs-light_muted-rgb), var(--bs-text-opacity)) !important; +} + +.text-pale_muted { + --bs-text-opacity: 1; + color: rgba(var(--bs-pale_muted-rgb), var(--bs-text-opacity)) !important; +} + +.text-darker { + --bs-text-opacity: 1; + color: rgba(var(--bs-darker-rgb), var(--bs-text-opacity)) !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, 0.5) !important; +} + +.text-white-50 { + --bs-text-opacity: 1; + color: rgba(255, 255, 255, 0.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-baca2_blue { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-baca2_blue-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-baca2_beige { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-baca2_beige-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-baca2_pink { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-baca2_pink-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-dark_muted { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-dark_muted-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-light_muted { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-light_muted-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-pale_muted { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-pale_muted-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-darker { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-darker-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-primary { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-primary-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-secondary { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-secondary-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-success { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-success-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-info { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-info-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-warning { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-warning-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-danger { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-danger-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-light { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-light-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline-dark { + --bs-link-underline-opacity: 1; + text-decoration-color: rgba(var(--bs-dark-rgb), var(--bs-link-underline-opacity)) !important; +} + +.link-underline { + --bs-link-underline-opacity: 1; + 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-baca2_blue { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-baca2_blue-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-baca2_beige { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-baca2_beige-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-baca2_pink { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-baca2_pink-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-dark_muted { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-dark_muted-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-light_muted { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-light_muted-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-pale_muted { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-pale_muted-rgb), var(--bs-bg-opacity)) !important; +} + +.bg-darker { + --bs-bg-opacity: 1; + background-color: rgba(var(--bs-darker-rgb), var(--bs-bg-opacity)) !important; +} + +.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 { + user-select: all !important; +} + +.user-select-auto { + user-select: auto !important; +} + +.user-select-none { + 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 { + object-fit: contain !important; + } + .object-fit-sm-cover { + object-fit: cover !important; + } + .object-fit-sm-fill { + object-fit: fill !important; + } + .object-fit-sm-scale { + object-fit: scale-down !important; + } + .object-fit-sm-none { + 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: 0.25rem !important; + } + .m-sm-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-sm-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-sm-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; + } + .mt-sm-2 { + margin-top: 0.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: 0.25rem !important; + } + .me-sm-2 { + margin-right: 0.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: 0.25rem !important; + } + .mb-sm-2 { + margin-bottom: 0.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: 0.25rem !important; + } + .ms-sm-2 { + margin-left: 0.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: 0.25rem !important; + } + .p-sm-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-sm-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-sm-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; + } + .pt-sm-2 { + padding-top: 0.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: 0.25rem !important; + } + .pe-sm-2 { + padding-right: 0.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: 0.25rem !important; + } + .pb-sm-2 { + padding-bottom: 0.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: 0.25rem !important; + } + .ps-sm-2 { + padding-left: 0.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: 0.25rem !important; + } + .gap-sm-2 { + gap: 0.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: 0.25rem !important; + } + .row-gap-sm-2 { + row-gap: 0.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 { + column-gap: 0 !important; + } + .column-gap-sm-1 { + column-gap: 0.25rem !important; + } + .column-gap-sm-2 { + column-gap: 0.5rem !important; + } + .column-gap-sm-3 { + column-gap: 1rem !important; + } + .column-gap-sm-4 { + column-gap: 1.5rem !important; + } + .column-gap-sm-5 { + 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 { + object-fit: contain !important; + } + .object-fit-md-cover { + object-fit: cover !important; + } + .object-fit-md-fill { + object-fit: fill !important; + } + .object-fit-md-scale { + object-fit: scale-down !important; + } + .object-fit-md-none { + 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: 0.25rem !important; + } + .m-md-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-md-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-md-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; + } + .mt-md-2 { + margin-top: 0.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: 0.25rem !important; + } + .me-md-2 { + margin-right: 0.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: 0.25rem !important; + } + .mb-md-2 { + margin-bottom: 0.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: 0.25rem !important; + } + .ms-md-2 { + margin-left: 0.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: 0.25rem !important; + } + .p-md-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-md-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-md-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; + } + .pt-md-2 { + padding-top: 0.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: 0.25rem !important; + } + .pe-md-2 { + padding-right: 0.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: 0.25rem !important; + } + .pb-md-2 { + padding-bottom: 0.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: 0.25rem !important; + } + .ps-md-2 { + padding-left: 0.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: 0.25rem !important; + } + .gap-md-2 { + gap: 0.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: 0.25rem !important; + } + .row-gap-md-2 { + row-gap: 0.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 { + column-gap: 0 !important; + } + .column-gap-md-1 { + column-gap: 0.25rem !important; + } + .column-gap-md-2 { + column-gap: 0.5rem !important; + } + .column-gap-md-3 { + column-gap: 1rem !important; + } + .column-gap-md-4 { + column-gap: 1.5rem !important; + } + .column-gap-md-5 { + 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 { + object-fit: contain !important; + } + .object-fit-lg-cover { + object-fit: cover !important; + } + .object-fit-lg-fill { + object-fit: fill !important; + } + .object-fit-lg-scale { + object-fit: scale-down !important; + } + .object-fit-lg-none { + 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: 0.25rem !important; + } + .m-lg-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-lg-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-lg-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; + } + .mt-lg-2 { + margin-top: 0.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: 0.25rem !important; + } + .me-lg-2 { + margin-right: 0.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: 0.25rem !important; + } + .mb-lg-2 { + margin-bottom: 0.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: 0.25rem !important; + } + .ms-lg-2 { + margin-left: 0.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: 0.25rem !important; + } + .p-lg-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-lg-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-lg-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; + } + .pt-lg-2 { + padding-top: 0.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: 0.25rem !important; + } + .pe-lg-2 { + padding-right: 0.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: 0.25rem !important; + } + .pb-lg-2 { + padding-bottom: 0.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: 0.25rem !important; + } + .ps-lg-2 { + padding-left: 0.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: 0.25rem !important; + } + .gap-lg-2 { + gap: 0.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: 0.25rem !important; + } + .row-gap-lg-2 { + row-gap: 0.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 { + column-gap: 0 !important; + } + .column-gap-lg-1 { + column-gap: 0.25rem !important; + } + .column-gap-lg-2 { + column-gap: 0.5rem !important; + } + .column-gap-lg-3 { + column-gap: 1rem !important; + } + .column-gap-lg-4 { + column-gap: 1.5rem !important; + } + .column-gap-lg-5 { + 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 { + object-fit: contain !important; + } + .object-fit-xl-cover { + object-fit: cover !important; + } + .object-fit-xl-fill { + object-fit: fill !important; + } + .object-fit-xl-scale { + object-fit: scale-down !important; + } + .object-fit-xl-none { + 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: 0.25rem !important; + } + .m-xl-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xl-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; + } + .mt-xl-2 { + margin-top: 0.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: 0.25rem !important; + } + .me-xl-2 { + margin-right: 0.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: 0.25rem !important; + } + .mb-xl-2 { + margin-bottom: 0.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: 0.25rem !important; + } + .ms-xl-2 { + margin-left: 0.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: 0.25rem !important; + } + .p-xl-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xl-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; + } + .pt-xl-2 { + padding-top: 0.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: 0.25rem !important; + } + .pe-xl-2 { + padding-right: 0.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: 0.25rem !important; + } + .pb-xl-2 { + padding-bottom: 0.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: 0.25rem !important; + } + .ps-xl-2 { + padding-left: 0.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: 0.25rem !important; + } + .gap-xl-2 { + gap: 0.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: 0.25rem !important; + } + .row-gap-xl-2 { + row-gap: 0.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 { + column-gap: 0 !important; + } + .column-gap-xl-1 { + column-gap: 0.25rem !important; + } + .column-gap-xl-2 { + column-gap: 0.5rem !important; + } + .column-gap-xl-3 { + column-gap: 1rem !important; + } + .column-gap-xl-4 { + column-gap: 1.5rem !important; + } + .column-gap-xl-5 { + 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 { + object-fit: contain !important; + } + .object-fit-xxl-cover { + object-fit: cover !important; + } + .object-fit-xxl-fill { + object-fit: fill !important; + } + .object-fit-xxl-scale { + object-fit: scale-down !important; + } + .object-fit-xxl-none { + 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: 0.25rem !important; + } + .m-xxl-2 { + margin: 0.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: 0.25rem !important; + margin-left: 0.25rem !important; + } + .mx-xxl-2 { + margin-right: 0.5rem !important; + margin-left: 0.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: 0.25rem !important; + margin-bottom: 0.25rem !important; + } + .my-xxl-2 { + margin-top: 0.5rem !important; + margin-bottom: 0.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: 0.25rem !important; + } + .mt-xxl-2 { + margin-top: 0.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: 0.25rem !important; + } + .me-xxl-2 { + margin-right: 0.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: 0.25rem !important; + } + .mb-xxl-2 { + margin-bottom: 0.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: 0.25rem !important; + } + .ms-xxl-2 { + margin-left: 0.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: 0.25rem !important; + } + .p-xxl-2 { + padding: 0.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: 0.25rem !important; + padding-left: 0.25rem !important; + } + .px-xxl-2 { + padding-right: 0.5rem !important; + padding-left: 0.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: 0.25rem !important; + padding-bottom: 0.25rem !important; + } + .py-xxl-2 { + padding-top: 0.5rem !important; + padding-bottom: 0.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: 0.25rem !important; + } + .pt-xxl-2 { + padding-top: 0.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: 0.25rem !important; + } + .pe-xxl-2 { + padding-right: 0.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: 0.25rem !important; + } + .pb-xxl-2 { + padding-bottom: 0.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: 0.25rem !important; + } + .ps-xxl-2 { + padding-left: 0.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: 0.25rem !important; + } + .gap-xxl-2 { + gap: 0.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: 0.25rem !important; + } + .row-gap-xxl-2 { + row-gap: 0.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 { + column-gap: 0 !important; + } + .column-gap-xxl-1 { + column-gap: 0.25rem !important; + } + .column-gap-xxl-2 { + column-gap: 0.5rem !important; + } + .column-gap-xxl-3 { + column-gap: 1rem !important; + } + .column-gap-xxl-4 { + column-gap: 1.5rem !important; + } + .column-gap-xxl-5 { + 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=baca2_theme.css.map */ diff --git a/BaCa2/assets/css/baca2_theme.css.map b/BaCa2/assets/css/baca2_theme.css.map new file mode 100644 index 00000000..5bfd8ef0 --- /dev/null +++ b/BaCa2/assets/css/baca2_theme.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../scss/_baca2_variables.scss","../../../node_modules/bootstrap/scss/mixins/_banner.scss","../../../node_modules/bootstrap/scss/_root.scss","../../../node_modules/bootstrap/scss/vendor/_rfs.scss","../../../node_modules/bootstrap/scss/mixins/_color-mode.scss","../../../node_modules/bootstrap/scss/_reboot.scss","../../../node_modules/bootstrap/scss/_variables.scss","../../../node_modules/bootstrap/scss/mixins/_border-radius.scss","../../../node_modules/bootstrap/scss/_type.scss","../../../node_modules/bootstrap/scss/mixins/_lists.scss","../../../node_modules/bootstrap/scss/_images.scss","../../../node_modules/bootstrap/scss/mixins/_image.scss","../../../node_modules/bootstrap/scss/_containers.scss","../../../node_modules/bootstrap/scss/mixins/_container.scss","../../../node_modules/bootstrap/scss/mixins/_breakpoints.scss","../../../node_modules/bootstrap/scss/_grid.scss","../../../node_modules/bootstrap/scss/mixins/_grid.scss","../../../node_modules/bootstrap/scss/_tables.scss","../../../node_modules/bootstrap/scss/mixins/_table-variants.scss","../../../node_modules/bootstrap/scss/forms/_labels.scss","../../../node_modules/bootstrap/scss/forms/_form-text.scss","../../../node_modules/bootstrap/scss/forms/_form-control.scss","../../../node_modules/bootstrap/scss/mixins/_transition.scss","../../../node_modules/bootstrap/scss/mixins/_gradients.scss","../../../node_modules/bootstrap/scss/forms/_form-select.scss","../../../node_modules/bootstrap/scss/forms/_form-check.scss","../../../node_modules/bootstrap/scss/forms/_form-range.scss","../../../node_modules/bootstrap/scss/forms/_floating-labels.scss","../../../node_modules/bootstrap/scss/forms/_input-group.scss","../../../node_modules/bootstrap/scss/mixins/_forms.scss","../../../node_modules/bootstrap/scss/_buttons.scss","../../../node_modules/bootstrap/scss/mixins/_buttons.scss","../../../node_modules/bootstrap/scss/_transitions.scss","../../../node_modules/bootstrap/scss/_dropdown.scss","../../../node_modules/bootstrap/scss/mixins/_caret.scss","../../../node_modules/bootstrap/scss/_button-group.scss","../../../node_modules/bootstrap/scss/_nav.scss","../../../node_modules/bootstrap/scss/_navbar.scss","../../../node_modules/bootstrap/scss/_card.scss","../../../node_modules/bootstrap/scss/_accordion.scss","../../../node_modules/bootstrap/scss/_breadcrumb.scss","../../../node_modules/bootstrap/scss/_pagination.scss","../../../node_modules/bootstrap/scss/mixins/_pagination.scss","../../../node_modules/bootstrap/scss/_badge.scss","../../../node_modules/bootstrap/scss/_alert.scss","../../../node_modules/bootstrap/scss/_progress.scss","../../../node_modules/bootstrap/scss/_list-group.scss","../../../node_modules/bootstrap/scss/_close.scss","../../../node_modules/bootstrap/scss/_toasts.scss","../../../node_modules/bootstrap/scss/_modal.scss","../../../node_modules/bootstrap/scss/mixins/_backdrop.scss","../../../node_modules/bootstrap/scss/_tooltip.scss","../../../node_modules/bootstrap/scss/mixins/_reset-text.scss","../../../node_modules/bootstrap/scss/_popover.scss","../../../node_modules/bootstrap/scss/_carousel.scss","../../../node_modules/bootstrap/scss/mixins/_clearfix.scss","../../../node_modules/bootstrap/scss/_spinners.scss","../../../node_modules/bootstrap/scss/_offcanvas.scss","../../../node_modules/bootstrap/scss/_placeholders.scss","../../../node_modules/bootstrap/scss/helpers/_color-bg.scss","../../../node_modules/bootstrap/scss/helpers/_colored-links.scss","../../../node_modules/bootstrap/scss/helpers/_focus-ring.scss","../../../node_modules/bootstrap/scss/helpers/_icon-link.scss","../../../node_modules/bootstrap/scss/helpers/_ratio.scss","../../../node_modules/bootstrap/scss/helpers/_position.scss","../../../node_modules/bootstrap/scss/helpers/_stacks.scss","../../../node_modules/bootstrap/scss/helpers/_visually-hidden.scss","../../../node_modules/bootstrap/scss/mixins/_visually-hidden.scss","../../../node_modules/bootstrap/scss/helpers/_stretched-link.scss","../../../node_modules/bootstrap/scss/helpers/_text-truncation.scss","../../../node_modules/bootstrap/scss/mixins/_text-truncate.scss","../../../node_modules/bootstrap/scss/helpers/_vr.scss","../../../node_modules/bootstrap/scss/mixins/_utilities.scss","../../../node_modules/bootstrap/scss/utilities/_api.scss"],"names":[],"mappings":";AA4CA;EACI;;;AC5CF;AAAA;AAAA;AAAA;AAAA;ACDF;AAAA;EASI;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAGF;EACA;EAMA;EACA;EACA;EAOA;EC2OI,qBALI;EDpOR;EACA;EAKA;EACA;EACA;EACA;EAEA;EACA;EAEA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EAGA;EAEA;EACA;EACA;EAEA;EACA;EAMA;EACA;EAGA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EAIA;EACA;EACA;EAIA;EACA;EACA;EACA;;;AE/GE;EFqHA;EAGA;EACA;EACA;EACA;EAEA;EACA;EAEA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EAGE;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAIA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAAA;EAGF;EAEA;EACA;EACA;EACA;EAEA;EAEA;EACA;EAEA;EACA;EACA;EACA;;;AGrKJ;AAAA;AAAA;EAGE;;;AAeE;EANJ;IAOM;;;;AAcN;EACE;EACA;EF6OI,WALI;EEtOR;EACA;EACA;EACA;EACA;EACA;EACA;;;AASF;EACE;EACA,OCmnB4B;EDlnB5B;EACA;EACA,SCynB4B;;;AD/mB9B;EACE;EACA,eCwjB4B;EDrjB5B,aCwjB4B;EDvjB5B,aCwjB4B;EDvjB5B;;;AAGF;EFuMQ;;AA5JJ;EE3CJ;IF8MQ;;;;AEzMR;EFkMQ;;AA5JJ;EEtCJ;IFyMQ;;;;AEpMR;EF6LQ;;AA5JJ;EEjCJ;IFoMQ;;;;AE/LR;EFwLQ;;AA5JJ;EE5BJ;IF+LQ;;;;AE1LR;EF+KM,WALI;;;AErKV;EF0KM,WALI;;;AE1JV;EACE;EACA,eCwV0B;;;AD9U5B;EACE;EACA;EACA;;;AAMF;EACE;EACA;EACA;;;AAMF;AAAA;EAEE;;;AAGF;AAAA;AAAA;EAGE;EACA;;;AAGF;AAAA;AAAA;AAAA;EAIE;;;AAGF;EACE,aC6b4B;;;ADxb9B;EACE;EACA;;;AAMF;EACE;;;AAQF;AAAA;EAEE,aCsa4B;;;AD9Z9B;EF6EM,WALI;;;AEjEV;EACE,SCqf4B;EDpf5B;;;AASF;AAAA;EAEE;EFyDI,WALI;EElDR;EACA;;;AAGF;EAAM;;;AACN;EAAM;;;AAKN;EACE;EACA,iBCiNwC;;AD/MxC;EACE;;;AAWF;EAEE;EACA;;;AAOJ;AAAA;AAAA;AAAA;EAIE,aCiV4B;EHlUxB,WALI;;;AEFV;EACE;EACA;EACA;EACA;EFGI,WALI;;AEOR;EFFI,WALI;EESN;EACA;;;AAIJ;EFTM,WALI;EEgBR;EACA;;AAGA;EACE;;;AAIJ;EACE;EFrBI,WALI;EE4BR,OCs5CkC;EDr5ClC,kBCs5CkC;EC1rDhC;;AFuSF;EACE;EF5BE,WALI;;;AE4CV;EACE;;;AAMF;AAAA;EAEE;;;AAQF;EACE;EACA;;;AAGF;EACE,aC4X4B;ED3X5B,gBC2X4B;ED1X5B,OC4Z4B;ED3Z5B;;;AAOF;EAEE;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;EACA;EACA;;;AAQF;EACE;;;AAMF;EAEE;;;AAQF;EACE;;;AAKF;AAAA;AAAA;AAAA;AAAA;EAKE;EACA;EF3HI,WALI;EEkIR;;;AAIF;AAAA;EAEE;;;AAKF;EACE;;;AAGF;EAGE;;AAGA;EACE;;;AAOJ;EACE;;;AAQF;AAAA;AAAA;AAAA;EAIE;;AAGE;AAAA;AAAA;AAAA;EACE;;;AAON;EACE;EACA;;;AAKF;EACE;;;AAUF;EACE;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA;EACA,eCoN4B;EHpatB;EEmNN;;AF/WE;EEwWJ;IFrMQ;;;AE8MN;EACE;;;AAOJ;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAOE;;;AAGF;EACE;;;AASF;EACE;EACA;;;AAQF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAWA;EACE;;;AAKF;EACE;;;AAOF;EACE;EACA;;;AAKF;EACE;;;AAKF;EACE;;;AAOF;EACE;EACA;;;AAQF;EACE;;;AAQF;EACE;;;AGpkBF;ELmQM,WALI;EK5PR,aFwoB4B;;;AEnoB5B;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AKvQN;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AKvQN;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AKvQN;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AKvQN;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AKvQN;ELgQM;EK5PJ,aFynBkB;EExnBlB,aFwmB0B;;AHzgB1B;EKpGF;ILuQM;;;;AK/OR;ECvDE;EACA;;;AD2DF;EC5DE;EACA;;;AD8DF;EACE;;AAEA;EACE,cFsoB0B;;;AE5nB9B;EL8MM,WALI;EKvMR;;;AAIF;EACE,eFiUO;EH1HH,WALI;;AK/LR;EACE;;;AAIJ;EACE;EACA,eFuTO;EH1HH,WALI;EKtLR,OFtFS;;AEwFT;EACE;;;AEhGJ;ECIE;EAGA;;;ADDF;EACE,SJ2jDkC;EI1jDlC,kBJ2jDkC;EI1jDlC;EHGE;EIRF;EAGA;;;ADcF;EAEE;;;AAGF;EACE;EACA;;;AAGF;EPyPM,WALI;EOlPR,OJ8iDkC;;;AMhlDlC;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;ECHA;EACA;EACA;EACA;EACA;EACA;EACA;;;ACsDE;EF5CE;IACE,WNkee;;;AQvbnB;EF5CE;IACE,WNkee;;;AQvbnB;EF5CE;IACE,WNkee;;;AQvbnB;EF5CE;IACE,WNkee;;;AQvbnB;EF5CE;IACE,WNkee;;;ASlfvB;EAEI;EAAA;EAAA;EAAA;EAAA;EAAA;;;AAKF;ECNA;EACA;EACA;EACA;EAEA;EACA;EACA;;ADEE;ECOF;EACA;EACA;EACA;EACA;EACA;;;AA+CI;EACE;;;AAGF;EApCJ;EACA;;;AAcA;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AA+BE;EAhDJ;EACA;;;AAqDQ;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AA+DM;EAhEN;EACA;;;AAuEQ;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAwDU;EAxDV;;;AAmEM;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AAPF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;AF1DN;EEUE;IACE;;EAGF;IApCJ;IACA;;EAcA;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EAFF;IACE;IACA;;EA+BE;IAhDJ;IACA;;EAqDQ;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EA+DM;IAhEN;IACA;;EAuEQ;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAwDU;IAxDV;;EAmEM;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;EAPF;AAAA;IAEE;;EAGF;AAAA;IAEE;;;ACrHV;EAEE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA,eXkYO;EWjYP,gBXssB4B;EWrsB5B;;AAOA;EACE;EAEA;EACA;EACA,qBX8sB0B;EW7sB1B;;AAGF;EACE;;AAGF;EACE;;;AAIJ;EACE;;;AAOF;EACE;;;AAUA;EACE;;;AAeF;EACE;;AAGA;EACE;;;AAOJ;EACE;;AAGF;EACE;;;AAUF;EACE;EACA;;;AAMF;EACE;EACA;;;AAQJ;EACE;EACA;;;AAQA;EACE;EACA;;;AC5IF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;AAlBF;EAOE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA;EACA;;;ADiJA;EACE;EACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AH3FF;EGyFA;IACE;IACA;;;AEnKN;EACE,ebq2BsC;;;Aa51BxC;EACE;EACA;EACA;EhB8QI,WALI;EgBrQR,ab+lB4B;;;Aa3lB9B;EACE;EACA;EhBoQI,WALI;;;AgB3PV;EACE;EACA;EhB8PI,WALI;;;AiBtRV;EACE,Yd61BsC;EHnkBlC,WALI;EiBjRR,Od61BsC;;;Ael2BxC;EACE;EACA;EACA;ElBwRI,WALI;EkBhRR,afkmB4B;EejmB5B,afymB4B;EexmB5B,Of03BsC;Eez3BtC;EACA,kBfm3BsC;Eel3BtC;EACA;EdGE;EeHE,YDMJ;;ACFI;EDhBN;ICiBQ;;;ADGN;EACE;;AAEA;EACE;;AAKJ;EACE,Ofo2BoC;Een2BpC,kBf81BoC;Ee71BpC,cf42BoC;Ee32BpC;EAKE,YfkhBkB;;Ae9gBtB;EAME;EAMA;EAKA;;AAKF;EACE;EACA;;AAIF;EACE,Of00BoC;Eex0BpC;;AAQF;EAEE,kBf4yBoC;EezyBpC;;AAIF;EACE;EACA;EACA,mBfmrB0B;EelrB1B,OfoyBoC;EiBl4BtC,kBjBmiCgC;Een8B9B;EACA;EACA;EACA;EACA,yBf+rB0B;Ee9rB1B;ECzFE,YD0FF;;ACtFE;ED0EJ;ICzEM;;;ADwFN;EACE,kBf07B8B;;;Aej7BlC;EACE;EACA;EACA;EACA;EACA,afwf4B;Eevf5B,OfyxBsC;EexxBtC;EACA;EACA;;AAEA;EACE;;AAGF;EAEE;EACA;;;AAWJ;EACE,Yf0wBsC;EezwBtC;ElByII,WALI;EIvQN;;AcuIF;EACE;EACA;EACA,mBfmoB0B;;;Ae/nB9B;EACE,Yf8vBsC;Ee7vBtC;ElB4HI,WALI;EIvQN;;AcoJF;EACE;EACA;EACA,mBf0nB0B;;;AelnB5B;EACE,Yf2uBoC;;AexuBtC;EACE,YfwuBoC;;AeruBtC;EACE,YfquBoC;;;AehuBxC;EACE,OfmuBsC;EeluBtC,Qf4tBsC;Ee3tBtC,SfglB4B;;Ae9kB5B;EACE;;AAGF;EACE;EdvLA;;Ac2LF;EACE;Ed5LA;;AcgMF;EAAoB,Qf4sBkB;;Ae3sBtC;EAAoB,Qf4sBkB;;;AkB35BxC;EACE;EAEA;EACA;EACA;ErBqRI,WALI;EqB7QR,alB+lB4B;EkB9lB5B,alBsmB4B;EkBrmB5B,OlBu3BsC;EkBt3BtC;EACA,kBlBg3BsC;EkB/2BtC;EACA;EACA,qBlB69BkC;EkB59BlC,iBlB69BkC;EkB59BlC;EjBHE;EeHE,YESJ;;AFLI;EEfN;IFgBQ;;;AEMN;EACE,clBo3BoC;EkBn3BpC;EAKE,YlB+9B4B;;AkB39BhC;EAEE,elB4uB0B;EkB3uB1B;;AAGF;EAEE,kBlBq1BoC;;AkBh1BtC;EACE;EACA;;;AAIJ;EACE,alBquB4B;EkBpuB5B,gBlBouB4B;EkBnuB5B,clBouB4B;EHjgBxB,WALI;EIvQN;;;AiB8CJ;EACE,alBiuB4B;EkBhuB5B,gBlBguB4B;EkB/tB5B,clBguB4B;EHrgBxB,WALI;EIvQN;;;AiBwDA;EACE;;;ACxEN;EACE;EACA,YnBm6BwC;EmBl6BxC,cnBm6BwC;EmBl6BxC,enBm6BwC;;AmBj6BxC;EACE;EACA;;;AAIJ;EACE,enBy5BwC;EmBx5BxC;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;EACE;EAEA,OnBy4BwC;EmBx4BxC,QnBw4BwC;EmBv4BxC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA,QnB04BwC;EmBz4BxC;;AAGA;ElB1BE;;AkB8BF;EAEE,enBk4BsC;;AmB/3BxC;EACE,QnBy3BsC;;AmBt3BxC;EACE,cnBq1BoC;EmBp1BpC;EACA,YnB+foB;;AmB5ftB;EACE,kBzBzDM;EyB0DN,czB1DM;;AyB4DN;EAII;;AAIJ;EAII;;AAKN;EACE,kBzB9EM;EyB+EN,czB/EM;EyBoFJ;;AAIJ;EACE;EACA;EACA,SnBi2BuC;;AmB11BvC;EACE;EACA,SnBw1BqC;;;AmB10B3C;EACE,cnBm1BgC;;AmBj1BhC;EACE;EAEA,OnB60B8B;EmB50B9B;EACA;EACA;ElBhHA;EeHE,YGqHF;;AHjHE;EGyGJ;IHxGM;;;AGkHJ;EACE;;AAGF;EACE,qBnB40B4B;EmBv0B1B;;AAKN;EACE,enBuzB8B;EmBtzB9B;;AAEA;EACE;EACA;;;AAKN;EACE;EACA,cnBqyBgC;;;AmBlyBlC;EACE;EACA;EACA;;AAIE;EACE;EACA;EACA,SnBspBwB;;;AmB/oB1B;EACE;;;AClLN;EACE;EACA;EACA;EACA;EACA;;AAEA;EACE;;AAIA;EAA0B,YpB4gCa;;AoB3gCvC;EAA0B,YpB2gCa;;AoBxgCzC;EACE;;AAGF;EACE,OpB6/BuC;EoB5/BvC,QpB4/BuC;EoB3/BvC;EACA;EH1BF,kBvBIQ;E0BwBN,QpB2/BuC;ECxgCvC;EeHE,YImBF;;AJfE;EIMJ;IJLM;;;AIgBJ;EHjCF,kBjB4hCyC;;AoBt/BzC;EACE,OpBs+B8B;EoBr+B9B,QpBs+B8B;EoBr+B9B;EACA,QpBq+B8B;EoBp+B9B,kBpBq+B8B;EoBp+B9B;EnB7BA;;AmBkCF;EACE,OpBk+BuC;EoBj+BvC,QpBi+BuC;EoBh+BvC;EHpDF,kBvBIQ;E0BkDN,QpBi+BuC;ECxgCvC;EeHE,YI6CF;;AJzCE;EIiCJ;IJhCM;;;AI0CJ;EH3DF,kBjB4hCyC;;AoB59BzC;EACE,OpB48B8B;EoB38B9B,QpB48B8B;EoB38B9B;EACA,QpB28B8B;EoB18B9B,kBpB28B8B;EoB18B9B;EnBvDA;;AmB4DF;EACE;;AAEA;EACE,kBpB88BqC;;AoB38BvC;EACE,kBpB08BqC;;;AqBjiC3C;EACE;;AAEA;AAAA;AAAA;EAGE,QrBsiCoC;EqBriCpC,YrBqiCoC;EqBpiCpC,arBqiCoC;;AqBliCtC;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;ELRE,YKSF;;ALLE;EKTJ;ILUM;;;AKON;AAAA;EAEE;;AAEA;AAAA;EACE;;AAGF;AAAA;AAAA;EAEE,arB0gCkC;EqBzgClC,gBrB0gCkC;;AqBvgCpC;AAAA;EACE,arBqgCkC;EqBpgClC,gBrBqgCkC;;AqBjgCtC;EACE,arB+/BoC;EqB9/BpC,gBrB+/BoC;;AqBx/BpC;AAAA;AAAA;AAAA;EACE;EACA,WrBy/BkC;;AqBv/BlC;AAAA;AAAA;AAAA;EACE;EACA;EACA;EACA,QrBi/BgC;EqBh/BhC;EACA,kBrB8zBgC;EC92BpC;;AoBuDA;EACE;EACA,WrBw+BkC;;AqBn+BpC;EACE;;AAIJ;AAAA;EAEE,OrB1EO;;AqB4EP;AAAA;EACE,kBrBwyBkC;;;AsB/3BxC;EACE;EACA;EACA;EACA;EACA;;AAEA;AAAA;AAAA;EAGE;EACA;EACA;EACA;;AAIF;AAAA;AAAA;EAGE;;AAMF;EACE;EACA;;AAEA;EACE;;;AAWN;EACE;EACA;EACA;EzB8OI,WALI;EyBvOR,atByjB4B;EsBxjB5B,atBgkB4B;EsB/jB5B,OtBi1BsC;EsBh1BtC;EACA;EACA,kBtBw6BsC;EsBv6BtC;ErBtCE;;;AqBgDJ;AAAA;AAAA;AAAA;EAIE;EzBwNI,WALI;EIvQN;;;AqByDJ;AAAA;AAAA;AAAA;EAIE;EzB+MI,WALI;EIvQN;;;AqBkEJ;AAAA;EAEE;;;AAaE;AAAA;AAAA;AAAA;ErBjEA;EACA;;AqByEA;AAAA;AAAA;AAAA;ErB1EA;EACA;;AqBsFF;EACE;ErB1EA;EACA;;AqB6EF;AAAA;ErB9EE;EACA;;;AsBxBF;EACE;EACA;EACA,YvBq0BoC;EHnkBlC,WALI;E0B1PN,OvBgjCqB;;;AuB7iCvB;EACE;EACA;EACA;EACA;EACA;EACA;EACA;E1BqPE,WALI;E0B7ON,OvBmiCqB;EuBliCrB,kBvBkiCqB;EC7jCrB;;;AsBgCA;AAAA;AAAA;AAAA;EAEE;;;AA/CF;EAqDE,cvBqhCmB;EuBlhCjB,evB41BgC;EuB31BhC;EACA;EACA;EACA;;AAGF;EACE,cvB0gCiB;EuBzgCjB,YvBygCiB;;;AuB1kCrB;EA0EI,evB00BgC;EuBz0BhC;;;AA3EJ;EAkFE,cvBw/BmB;;AuBr/BjB;EAEE;EACA,evBw5B8B;EuBv5B9B;EACA;;AAIJ;EACE,cvB2+BiB;EuB1+BjB,YvB0+BiB;;;AuB1kCrB;EAwGI;;;AAxGJ;EA+GE,cvB29BmB;;AuBz9BnB;EACE,kBvBw9BiB;;AuBr9BnB;EACE,YvBo9BiB;;AuBj9BnB;EACE,OvBg9BiB;;;AuB38BrB;EACE;;;AAhIF;AAAA;AAAA;AAAA;AAAA;EA0IM;;;AAtHR;EACE;EACA;EACA,YvBq0BoC;EHnkBlC,WALI;E0B1PN,OvBgjCqB;;;AuB7iCvB;EACE;EACA;EACA;EACA;EACA;EACA;EACA;E1BqPE,WALI;E0B7ON,OvBmiCqB;EuBliCrB,kBvBkiCqB;EC7jCrB;;;AsBgCA;AAAA;AAAA;AAAA;EAEE;;;AA/CF;EAqDE,cvBqhCmB;EuBlhCjB,evB41BgC;EuB31BhC;EACA;EACA;EACA;;AAGF;EACE,cvB0gCiB;EuBzgCjB,YvBygCiB;;;AuB1kCrB;EA0EI,evB00BgC;EuBz0BhC;;;AA3EJ;EAkFE,cvBw/BmB;;AuBr/BjB;EAEE;EACA,evBw5B8B;EuBv5B9B;EACA;;AAIJ;EACE,cvB2+BiB;EuB1+BjB,YvB0+BiB;;;AuB1kCrB;EAwGI;;;AAxGJ;EA+GE,cvB29BmB;;AuBz9BnB;EACE,kBvBw9BiB;;AuBr9BnB;EACE,YvBo9BiB;;AuBj9BnB;EACE,OvBg9BiB;;;AuB38BrB;EACE;;;AAhIF;AAAA;AAAA;AAAA;AAAA;EA4IM;;;AC9IV;EAEE;EACA;EACA;E3BuRI,oBALI;E2BhRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;E3BsQI,WALI;E2B/PR;EACA;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EvBjBE;EgBfF,kBOkCqB;ERtBjB,YQwBJ;;ARpBI;EQhBN;IRiBQ;;;AQqBN;EACE;EAEA;EACA;;AAGF;EAEE;EACA;EACA;;AAGF;EACE;EPrDF,kBOsDuB;EACrB;EACA;EAKE;;AAIJ;EACE;EACA;EAKE;;AAIJ;EAKE;EACA;EAGA;;AAGA;EAKI;;AAKN;EAGE;EACA;EACA;EAEA;EACA;;;AAYF;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADyFA;ECtGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADmHA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AD0FA;ECvGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;ADsGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA,iBxBuRwC;;AwB7QxC;EACE;;AAGF;EACE;;;AAWJ;ECxIE;EACA;E5B8NI,oBALI;E4BvNR;;;ADyIF;EC5IE;EACA;E5B8NI,oBALI;E4BvNR;;;ACnEF;EVgBM,YUfJ;;AVmBI;EUpBN;IVqBQ;;;AUlBN;EACE;;;AAMF;EACE;;;AAIJ;EACE;EACA;EVDI,YUEJ;;AVEI;EULN;IVMQ;;;AUDN;EACE;EACA;EVNE,YUOF;;AVHE;EUAJ;IVCM;;;;AWpBR;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;;;AAGF;EACE;;ACwBE;EACE;EACA,a5B6hBwB;E4B5hBxB,gB5B2hBwB;E4B1hBxB;EArCJ;EACA;EACA;EACA;;AA0DE;EACE;;;AD9CN;EAEE;EACA;EACA;EACA;EACA;E9BuQI,yBALI;E8BhQR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;E9B0OI,WALI;E8BnOR;EACA;EACA;EACA;EACA;EACA;E1BzCE;;A0B6CF;EACE;EACA;EACA;;;AAwBA;EACE;;AAEA;EACE;EACA;;;AAIJ;EACE;;AAEA;EACE;EACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AnB1CJ;EmB4BA;IACE;;EAEA;IACE;IACA;;EAIJ;IACE;;EAEA;IACE;IACA;;;AAUN;EACE;EACA;EACA;EACA;;ACpFA;EACE;EACA,a5B6hBwB;E4B5hBxB,gB5B2hBwB;E4B1hBxB;EA9BJ;EACA;EACA;EACA;;AAmDE;EACE;;;ADgEJ;EACE;EACA;EACA;EACA;EACA;;AClGA;EACE;EACA,a5B6hBwB;E4B5hBxB,gB5B2hBwB;E4B1hBxB;EAvBJ;EACA;EACA;EACA;;AA4CE;EACE;;AD0EF;EACE;;;AAMJ;EACE;EACA;EACA;EACA;EACA;;ACnHA;EACE;EACA,a5B6hBwB;E4B5hBxB,gB5B2hBwB;E4B1hBxB;;AAWA;EACE;;AAGF;EACE;EACA,c5B0gBsB;E4BzgBtB,gB5BwgBsB;E4BvgBtB;EAnCN;EACA;EACA;;AAsCE;EACE;;AD2FF;EACE;;;AAON;EACE;EACA;EACA;EACA;EACA;;;AAMF;EACE;EACA;EACA;EACA;EACA,a3Byb4B;E2Bxb5B;EACA;EACA;EACA;EACA;EACA;E1BtKE;;A0ByKF;EAEE;EV1LF,kBU4LuB;;AAGvB;EAEE;EACA;EVlMF,kBUmMuB;;AAGvB;EAEE;EACA;EACA;;;AAMJ;EACE;;;AAIF;EACE;EACA;EACA;E9BmEI,WALI;E8B5DR;EACA;;;AAIF;EACE;EACA;EACA;;;AAIF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AEtPF;AAAA;EAEE;EACA;EACA;;AAEA;AAAA;EACE;EACA;;AAKF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EAME;;;AAKJ;EACE;EACA;EACA;;AAEA;EACE;;;AAIJ;E5BhBI;;A4BoBF;AAAA;EAEE;;AAIF;AAAA;AAAA;E5BVE;EACA;;A4BmBF;AAAA;AAAA;E5BNE;EACA;;;A4BwBJ;EACE;EACA;;AAEA;EAGE;;AAGF;EACE;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;;;AAoBF;EACE;EACA;EACA;;AAEA;AAAA;EAEE;;AAGF;AAAA;EAEE;;AAIF;AAAA;E5B1FE;EACA;;A4B8FF;AAAA;E5B7GE;EACA;;;A6BxBJ;EAEE;EACA;EAEA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;EjCsQI,WALI;EiC/PR;EACA;EACA;EACA;EACA;EdfI,YcgBJ;;AdZI;EcGN;IdFQ;;;AcaN;EAEE;;AAIF;EACE;EACA,Y9BkhBoB;;A8B9gBtB;EAEE;EACA;EACA;;;AAQJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;;AAEA;EACE;EACA;E7B7CA;EACA;;A6B+CA;EAGE;EACA;;AAIJ;AAAA;EAEE;EACA;EACA;;AAGF;EAEE;E7BjEA;EACA;;;A6B2EJ;EAEE;EACA;EACA;;AAGA;E7B5FE;;A6BgGF;AAAA;EAEE;EbjHF,kBakHuB;;;AASzB;EAEE;EACA;EACA;EAGA;;AAEA;EACE;EACA;EACA;;AAEA;EAEE;;AAIJ;AAAA;EAEE,a9B0d0B;E8Bzd1B;EACA;;;AAUF;AAAA;EAEE;EACA;;;AAKF;AAAA;EAEE;EACA;EACA;;;AAMF;AAAA;EACE;;;AAUF;EACE;;AAEF;EACE;;;AC7LJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;;AAMA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;EACE;EACA;EACA;EACA;;AAoBJ;EACE;EACA;EACA;ElC4NI,WALI;EkCrNR;EACA;EACA;;AAEA;EAEE;;;AAUJ;EAEE;EACA;EAEA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;;AAGE;EAEE;;AAIJ;EACE;;;AASJ;EACE,a/B4gCkC;E+B3gClC,gB/B2gCkC;E+B1gClC;;AAEA;AAAA;AAAA;EAGE;;;AAaJ;EACE;EACA;EAGA;;;AAIF;EACE;ElCyII,WALI;EkClIR;EACA;EACA;EACA;E9BxIE;EeHE,Ye6IJ;;AfzII;EeiIN;IfhIQ;;;Ae0IN;EACE;;AAGF;EACE;EACA;EACA;;;AAMJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAGF;EACE;EACA;;;AvB1HE;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AvB5LR;EuBsIA;IAEI;IACA;;EAEA;IACE;;EAEA;IACE;;EAGF;IACE;IACA;;EAIJ;IACE;;EAGF;IACE;IACA;;EAGF;IACE;;EAGF;IAEE;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;If9NJ,YegOI;;EAGA;IACE;;EAGF;IACE;IACA;IACA;IACA;;;AAtDR;EAEI;EACA;;AAEA;EACE;;AAEA;EACE;;AAGF;EACE;EACA;;AAIJ;EACE;;AAGF;EACE;EACA;;AAGF;EACE;;AAGF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;Ef9NJ,YegOI;;AAGA;EACE;;AAGF;EACE;EACA;EACA;EACA;;;AAiBZ;AAAA;EAGE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAME;EACE;;;ACzRN;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;E/BjBE;;A+BqBF;EACE;EACA;;AAGF;EACE;EACA;;AAEA;EACE;E/BtBF;EACA;;A+ByBA;EACE;E/BbF;EACA;;A+BmBF;AAAA;EAEE;;;AAIJ;EAGE;EACA;EACA;;;AAGF;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AAGF;EACE;;;AAQA;EACE;;;AAQJ;EACE;EACA;EACA;EACA;EACA;;AAEA;E/B7FE;;;A+BkGJ;EACE;EACA;EACA;EACA;;AAEA;E/BxGE;;;A+BkHJ;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;;;AAIJ;EACE;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;E/B1IE;;;A+B8IJ;AAAA;AAAA;EAGE;;;AAGF;AAAA;E/B3II;EACA;;;A+B+IJ;AAAA;E/BlII;EACA;;;A+B8IF;EACE;;AxB3HA;EwBuHJ;IAQI;IACA;;EAGA;IAEE;IACA;;EAEA;IACE;IACA;;EAKA;I/B3KJ;IACA;;E+B6KM;AAAA;IAGE;;EAEF;AAAA;IAGE;;EAIJ;I/B5KJ;IACA;;E+B8KM;AAAA;IAGE;;EAEF;AAAA;IAGE;;;;ACpOZ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EpC2PI,WALI;EoCpPR;EACA;EACA;EACA;EhCtBE;EgCwBF;EjB3BI,YiB4BJ;;AjBxBI;EiBWN;IjBVQ;;;AiByBN;EACE;EACA;EACA;;AAEA;EACE;EACA;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EjBlDE,YiBmDF;;AjB/CE;EiBsCJ;IjBrCM;;;AiBiDN;EACE;;AAGF;EACE;EACA;EACA;EACA;;;AAIJ;EACE;;;AAGF;EACE;EACA;EACA;;AAEA;EhC/DE;EACA;;AgCiEA;EhClEA;EACA;;AgCsEF;EACE;;AAIF;EhC9DE;EACA;;AgCiEE;EhClEF;EACA;;AgCsEA;EhCvEA;EACA;;;AgC4EJ;EACE;;;AASA;EACE;;AAGF;EACE;EACA;EhCpHA;;AgCuHA;EAAgB;;AAChB;EAAe;;AAGb;EhC3HF;;;AgCqIA;EACE;EACA;;;AC1JN;EAEE;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;ErC+QI,WALI;EqCxQR;EACA;EjCAE;;;AiCMF;EACE;;AAEA;EACE;EACA;EACA;EACA;;AAIJ;EACE;;;ACrCJ;EAEE;EACA;EtC4RI,2BALI;EsCrRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EhCpBA;EACA;;;AgCuBF;EACE;EACA;EACA;EtCgQI,WALI;EsCzPR;EACA;EACA;EACA;EnBpBI,YmBqBJ;;AnBjBI;EmBQN;InBPQ;;;AmBkBN;EACE;EACA;EAEA;EACA;;AAGF;EACE;EACA;EACA;EACA,SnCyuCgC;EmCxuChC;;AAGF;EAEE;EACA;ElBtDF,kBkBuDuB;EACrB;;AAGF;EAEE;EACA;EACA;EACA;;;AAKF;EACE,anC4sCgC;;AmCvsC9B;ElC9BF;EACA;;AkCmCE;ElClDF;EACA;;;AkCkEJ;EClGE;EACA;EvC0RI,2BALI;EuCnRR;;;ADmGF;ECtGE;EACA;EvC0RI,2BALI;EuCnRR;;;ACFF;EAEE;EACA;ExCuRI,sBALI;EwChRR;EACA;EACA;EAGA;EACA;ExC+QI,WALI;EwCxQR;EACA;EACA;EACA;EACA;EACA;EpCJE;;AoCSF;EACE;;;AAKJ;EACE;EACA;;;AChCF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;ErCHE;;;AqCQJ;EAEE;;;AAIF;EACE,atC6kB4B;EsC5kB5B;;;AAQF;EACE,etCk+C8B;;AsC/9C9B;EACE;EACA;EACA;EACA;EACA;;;AAQF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AAJF;EACE;EACA;EACA;EACA;;;AC5DF;EACE;IAAK,uBvCqhD2B;;;AuChhDpC;AAAA;EAGE;E1CkRI,yBALI;E0C3QR;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;E1CsQI,WALI;E0C/PR;EtCRE;;;AsCaJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EvBxBI,YuByBJ;;AvBrBI;EuBYN;IvBXQ;;;;AuBuBR;EtBAE;EsBEA;;;AAGF;EACE;;;AAGF;EACE;;;AAIA;EACE;;AAGE;EAJJ;IAKM;;;;AC3DR;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EAGA;EACA;EvCXE;;;AuCeJ;EACE;EACA;;AAEA;EAEE;EACA;;;AASJ;EACE;EACA;EACA;;AAGA;EAEE;EACA;EACA;EACA;;AAGF;EACE;EACA;;;AAQJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAEA;EvCvDE;EACA;;AuC0DF;EvC7CE;EACA;;AuCgDF;EAEE;EACA;EACA;;AAIF;EACE;EACA;EACA;EACA;;AAIF;EACE;;AAEA;EACE;EACA;;;AAaF;EACE;;AAGE;EvCvDJ;EAZA;;AuCwEI;EvCxEJ;EAYA;;AuCiEI;EACE;;AAGF;EACE;EACA;;AAEA;EACE;EACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AhCtFR;EgC8DA;IACE;;EAGE;IvCvDJ;IAZA;;EuCwEI;IvCxEJ;IAYA;;EuCiEI;IACE;;EAGF;IACE;IACA;;EAEA;IACE;IACA;;;AAcZ;EvChJI;;AuCmJF;EACE;;AAEA;EACE;;;AAaJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAVF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AC5LJ;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA,OzCipD2B;EyChpD3B,QzCgpD2B;EyC/oD3B;EACA;EACA;EACA;ExCJE;EwCMF;;AAGA;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;;AAGF;EAEE;EACA;EACA;;;AAQJ;EAHE;;;AASE;EATF;;;ACjDF;EAEE;EACA;EACA;EACA;EACA;E7CyRI,sBALI;E6ClRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;E7C2QI,WALI;E6CpQR;EACA;EACA;EACA;EACA;EACA;EzCRE;;AyCWF;EACE;;AAGF;EACE;;;AAIJ;EACE;EAEA;EACA;EACA;EACA;EACA;;AAEA;EACE;;;AAIJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EzChCE;EACA;;AyCkCF;EACE;EACA;;;AAIJ;EACE;EACA;;;AC9DF;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;;;AAOF;EACE;EACA;EACA;EAEA;;AAGA;E3B5CI,Y2B6CF;EACA,W3C87CgC;;AgBx+C9B;E2BwCJ;I3BvCM;;;A2B2CN;EACE,W3C47CgC;;A2Cx7ClC;EACE,W3Cy7CgC;;;A2Cr7CpC;EACE;;AAEA;EACE;EACA;;AAGF;EACE;;;AAIJ;EACE;EACA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA;EACA;E1CrFE;E0CyFF;;;AAIF;EAEE;EACA;EACA;EClHA;EACA;EACA;EACA,SDkH0B;ECjH1B;EACA;EACA,kBD+G4D;;AC5G5D;EAAS;;AACT;EAAS,SD2GiF;;;AAK5F;EACE;EACA;EACA;EACA;EACA;EACA;E1CtGE;EACA;;A0CwGF;EACE;EACA;;;AAKJ;EACE;EACA;;;AAKF;EACE;EAGA;EACA;;;AAIF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;E1C1HE;EACA;;A0C+HF;EACE;;;AnC5GA;EmCkHF;IACE;IACA;;EAIF;IACE;IACA;IACA;;EAGF;IACE;;;AnC/HA;EmCoIF;AAAA;IAEE;;;AnCtIA;EmC2IF;IACE;;;AAUA;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;E1C1MJ;;A0C8ME;AAAA;E1C9MF;;A0CmNE;EACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AnC3JJ;EmCyIA;IACE;IACA;IACA;IACA;;EAEA;IACE;IACA;I1C1MJ;;E0C8ME;AAAA;I1C9MF;;E0CmNE;IACE;;;AEtOR;EAEE;EACA;EACA;EACA;EACA;EhDwRI,wBALI;EgDjRR;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EClBA,a9C+lB4B;E8C7lB5B;EACA,a9CwmB4B;E8CvmB5B,a9C+mB4B;E8C9mB5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EjDgRI,WALI;EgDhQR;EACA;;AAEA;EAAS;;AAET;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;;AAKN;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;AACA;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;AAEA;EACE;;AAEA;EACE;EACA;EACA;;;AAIJ;AACA;EACE;EACA;EACA;;AAEA;EACE;EACA;EACA;;;AAIJ;AAkBA;EACE;EACA;EACA;EACA;EACA;E5CjGE;;;A8CnBJ;EAEE;EACA;ElD4RI,wBALI;EkDrRR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;ElDmRI,+BALI;EkD5QR;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;EACA;EDzBA,a9C+lB4B;E8C7lB5B;EACA,a9CwmB4B;E8CvmB5B,a9C+mB4B;E8C9mB5B;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EjDgRI,WALI;EkD1PR;EACA;EACA;EACA;E9ChBE;;A8CoBF;EACE;EACA;EACA;;AAEA;EAEE;EACA;EACA;EACA;EACA;EACA;;;AAMJ;EACE;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAEE;EACE;EACA;EACA;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAGE;EACE;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;AAKJ;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAIJ;AAEE;EACE;EACA;EACA;;AAEA;EAEE;;AAGF;EACE;EACA;;AAGF;EACE;EACA;;;AAKN;AAkBA;EACE;EACA;ElD2GI,WALI;EkDpGR;EACA;EACA;E9C5JE;EACA;;A8C8JF;EACE;;;AAIJ;EACE;EACA;;;ACrLF;EACE;;;AAGF;EACE;;;AAGF;EACE;EACA;EACA;;ACtBA;EACE;EACA;EACA;;;ADuBJ;EACE;EACA;EACA;EACA;EACA;EACA;EhClBI,YgCmBJ;;AhCfI;EgCQN;IhCPQ;;;;AgCiBR;AAAA;AAAA;EAGE;;;AAGF;AAAA;EAEE;;;AAGF;AAAA;EAEE;;;AASA;EACE;EACA;EACA;;AAGF;AAAA;AAAA;EAGE;EACA;;AAGF;AAAA;EAEE;EACA;EhC5DE,YgC6DF;;AhCzDE;EgCqDJ;AAAA;IhCpDM;;;;AgCiER;AAAA;EAEE;EACA;EACA;EACA;EAEA;EACA;EACA;EACA,OhD8gDmC;EgD7gDnC;EACA,OhD1FS;EgD2FT;EACA;EACA;EACA,ShDygDmC;EgB/lD/B,YgCuFJ;;AhCnFI;EgCkEN;AAAA;IhCjEQ;;;AgCqFN;AAAA;AAAA;EAEE,OhDpGO;EgDqGP;EACA;EACA,ShDigDiC;;;AgD9/CrC;EACE;;;AAGF;EACE;;;AAKF;AAAA;EAEE;EACA,OhDkgDmC;EgDjgDnC,QhDigDmC;EgDhgDnC;EACA;EACA;;;AAGF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAQA;EACE;;;AAEF;EACE;;;AAQF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EAEA,chD08CmC;EgDz8CnC;EACA,ahDw8CmC;;AgDt8CnC;EACE;EACA;EACA,OhDw8CiC;EgDv8CjC,QhDw8CiC;EgDv8CjC;EACA,chDw8CiC;EgDv8CjC,ahDu8CiC;EgDt8CjC;EACA;EACA,kBhD1KO;EgD2KP;EACA;EAEA;EACA;EACA,ShD+7CiC;EgBvmD/B,YgCyKF;;AhCrKE;EgCoJJ;IhCnJM;;;AgCuKN;EACE,ShD47CiC;;;AgDn7CrC;EACE;EACA;EACA,QhDs7CmC;EgDr7CnC;EACA,ahDm7CmC;EgDl7CnC,gBhDk7CmC;EgDj7CnC,OhDrMS;EgDsMT;;;AAMA;AAAA;EAEE,QhDu7CiC;;AgDp7CnC;EACE,kBhDxMO;;AgD2MT;EACE,OhD5MO;;;AgDkMT;AAAA;AAAA;EAEE,QhDu7CiC;;AgDp7CnC;EACE,kBhDxMO;;AgD2MT;EACE,OhD5MO;;;AkDdX;AAAA;EAEE;EACA;EACA;EACA;EAEA;EACA;;;AAIF;EACE;IAAK;;;AAIP;EAEE;EACA;EACA;EACA;EACA;EACA;EAGA;EACA;;;AAGF;EAEE;EACA;EACA;;;AASF;EACE;IACE;;EAEF;IACE;IACA;;;AAKJ;EAEE;EACA;EACA;EACA;EACA;EAGA;EACA;;;AAGF;EACE;EACA;;;AAIA;EACE;AAAA;IAEE;;;AC/EN;EAEE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;A3C6DE;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;A3CnCN;E2C5CF;IAEI;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;IACA;InC5BA,YmC8BA;;;AnC1BA;EmCYJ;InCXM;;;ARuDJ;E2C5BE;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;IACA;;EAGF;IACE;IACA;IACA;IACA;IACA;IACA;;EAGF;IAEE;;EAGF;IAGE;;;A3C5BJ;E2C/BF;IAiEM;IACA;IACA;;EAEA;IACE;;EAGF;IACE;IACA;IACA;IACA;IAEA;;;;AA/ER;EAEI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EnC5BA,YmC8BA;;AnC1BA;EmCYJ;InCXM;;;AmC2BF;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;EACA;;AAGF;EAEE;;AAGF;EAGE;;;AA2BR;EPpHE;EACA;EACA;EACA,S5CwmCkC;E4CvmClC;EACA;EACA,kB5CUS;;A4CPT;EAAS;;AACT;EAAS,S5C+9CyB;;;AmDj3CpC;EACE;EACA;EACA;EACA;;AAEA;EACE;EACA;EACA;EACA;;;AAIJ;EACE;EACA;;;AAGF;EACE;EACA;EACA;;;AChJF;EACE;EACA;EACA;EACA;EACA;EACA,SpD8yCkC;;AoD5yClC;EACE;EACA;;;AAKJ;EACE;;;AAGF;EACE;;;AAGF;EACE;;;AAKA;EACE;;;AAIJ;EACE;IACE,SpDixCgC;;;AoD7wCpC;EACE;EACA;EACA;;;AAGF;EACE;IACE;;;AH9CF;EACE;EACA;EACA;;;AIHF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;AAFF;EACE;EACA;;;ACFF;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AATN;EACE;EACA;;AAGE;EAGE;EACA;;;AAOR;EACE;EACA;;AAGE;EAEE;EACA;;;AC1BN;EACE;EAEA;;;ACHF;EACE;EACA,KxD6c4B;EwD5c5B;EACA;EACA,uBxD2c4B;EwD1c5B;;AAEA;EACE;EACA,OxDuc0B;EwDtc1B,QxDsc0B;EwDrc1B;ExCIE,YwCHF;;AxCOE;EwCZJ;IxCaM;;;;AwCDJ;EACE;;;ACnBN;EACE;EACA;;AAEA;EACE;EACA;EACA;;AAGF;EACE;EACA;EACA;EACA;EACA;;;AAKF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;ACrBJ;EACE;EACA;EACA;EACA;EACA,S1DqmCkC;;;A0DlmCpC;EACE;EACA;EACA;EACA;EACA,S1D6lCkC;;;A0DrlChC;EACE;EACA;EACA,S1DilC8B;;;A0D9kChC;EACE;EACA;EACA,S1D2kC8B;;;AQ5iChC;EkDxCA;IACE;IACA;IACA,S1DilC8B;;E0D9kChC;IACE;IACA;IACA,S1D2kC8B;;;AQ5iChC;EkDxCA;IACE;IACA;IACA,S1DilC8B;;E0D9kChC;IACE;IACA;IACA,S1D2kC8B;;;AQ5iChC;EkDxCA;IACE;IACA;IACA,S1DilC8B;;E0D9kChC;IACE;IACA;IACA,S1D2kC8B;;;AQ5iChC;EkDxCA;IACE;IACA;IACA,S1DilC8B;;E0D9kChC;IACE;IACA;IACA,S1D2kC8B;;;AQ5iChC;EkDxCA;IACE;IACA;IACA,S1DilC8B;;E0D9kChC;IACE;IACA;IACA,S1D2kC8B;;;A2D1mCpC;EACE;EACA;EACA;EACA;;;AAGF;EACE;EACA;EACA;EACA;;;ACRF;AAAA;ECIE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGA;AAAA;EACE;;;ACdF;EACE;EACA;EACA;EACA;EACA;EACA,S9DgcsC;E8D/btC;;;ACRJ;ECAE;EACA;EACA;;;ACNF;EACE;EACA;EACA,OjEisB4B;EiEhsB5B;EACA;EACA,SjE2rB4B;;;AkE/nBtB;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AArBJ;AAcA;EAOI;EAAA;;;AAmBJ;AA1BA;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAjBJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AAIJ;EAOI;;;AAKF;EAOI;;;AAnBN;EAOI;;;AAKF;EAOI;;;AAnBN;EAOI;;;AAKF;EAOI;;;AAnBN;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AANJ;EACE;;;AAIA;EACE;;;AAIJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAPJ;EAIQ;EAGJ;;;AAjBJ;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AADF;EACE;;;AASF;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;EAAA;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;AAPJ;EAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;A1DVR;E0DGI;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;IAAA;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;ACtDZ;ED+CQ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;;ACnCZ;ED4BQ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI;;EAPJ;IAOI","file":"baca2_theme.css"} diff --git a/BaCa2/assets/css/base.css b/BaCa2/assets/css/base.css new file mode 100644 index 00000000..dfaece51 --- /dev/null +++ b/BaCa2/assets/css/base.css @@ -0,0 +1,9370 @@ +@charset "UTF-8"; +html, body { + height: 100%; +} + +:root { + --bs-form-valid-border-color: $ red; +} + +#version_footer { + position: fixed; + bottom: 10px; + left: 10px; + color: #D3CABD; + text-align: left; + font-size: 0.8em; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.toggleable button { + width: 35%; + font-size: 0.85rem; +} + +.invalid-feedback { + display: block; +} + +.table-select-field.is-valid .table-wrapper .card { + --bs-card-border-color: var(--bs-form-valid-border-color); +} +.table-select-field.is-invalid .table-wrapper .card { + --bs-card-border-color: var(--bs-form-invalid-border-color); +} + +.table-select-input { + display: none; +} + +.form-confirmation-popup .modal-header, .form-confirmation-popup .modal-body, .form-confirmation-popup .modal-footer { + padding-left: 1.5rem; + padding-right: 1.5rem; +} +.form-confirmation-popup .modal-footer .row { + margin: 0; +} +.form-confirmation-popup .modal-footer .submit-btn-wrapper { + padding-left: 0; +} +.form-confirmation-popup .modal-footer .cancel-btn-wrapper { + padding-right: 0; +} + +.submit-btn { + transition: background-color 0.3s ease-out, transform 0.3s ease-out; +} +.submit-btn.submit-enabled { + background-color: #fe5882; + transform: scale(1.01, 1.05); +} + +.form-observer .group-summary:not(.has-fields) { + display: none; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.logo-wrapper .logo-stroke { + fill: #000000; +} +.logo-wrapper .logo-large-triangle { + fill: #08D9D6; +} +.logo-wrapper .logo-medium-triangle { + fill: #D3CABD; +} +.logo-wrapper .logo-small-triangle { + fill: #FE2E63; +} + +[data-bs-theme=dark] .logo-wrapper .logo-stroke { + fill: #ffffff; +} + +.logo-wrapper .baca2-logo { + transition: height 0.8s ease; +} +.logo-wrapper .logo-horizontal { + display: none; +} +.logo-wrapper .logo-square { + display: none; +} +.logo-wrapper .logo-icon { + display: none; +} +.logo-wrapper .logo-horizontal { + display: block; +} +.logo-wrapper.logo-horizontal .logo-square { + display: none; +} +.logo-wrapper.logo-horizontal .logo-icon { + display: none; +} +.logo-wrapper.logo-horizontal .logo-horizontal { + display: block; +} +.logo-wrapper.logo-square .logo-horizontal { + display: none; +} +.logo-wrapper.logo-square .logo-icon { + display: none; +} +.logo-wrapper.logo-square .logo-square { + display: block; +} +.logo-wrapper.logo-icon .logo-horizontal { + display: none; +} +.logo-wrapper.logo-icon .logo-square { + display: none; +} +.logo-wrapper.logo-icon .logo-icon { + display: block; +} +.logo-wrapper.logo-xs .logo-horizontal { + width: auto; + height: 1.5rem; +} +.logo-wrapper.logo-xs .logo-square { + width: auto; + height: 2rem; +} +.logo-wrapper.logo-xs .logo-icon { + width: auto; + height: 1.5rem; +} +.logo-wrapper.logo-s .logo-horizontal { + width: auto; + height: 3rem; +} +.logo-wrapper.logo-s .logo-square { + width: auto; + height: 4rem; +} +.logo-wrapper.logo-s .logo-icon { + width: auto; + height: 2rem; +} +.logo-wrapper.logo-m .logo-horizontal { + width: auto; + height: 4rem; +} +.logo-wrapper.logo-m .logo-square { + width: auto; + height: 8rem; +} +.logo-wrapper.logo-m .logo-icon { + width: auto; + height: 4rem; +} +.logo-wrapper.logo-l .logo-horizontal { + width: auto; + height: 8rem; +} +.logo-wrapper.logo-l .logo-square { + width: auto; + height: 12rem; +} +.logo-wrapper.logo-l .logo-icon { + width: auto; + height: 8rem; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.nav-item .dropdown-toggle::after { + display: none; +} + +.nav-link { + position: relative; +} + +.navbar-padding { + padding-top: 4.25rem; +} + +[data-bs-theme=light] .navbar { + background-color: #f8f9fa; +} +[data-bs-theme=light] .navbar .links .nav-item a { + color: #212529; +} +[data-bs-theme=light] .navbar .links .nav-item.active a { + color: #FE2E63; +} +[data-bs-theme=light] .navbar .links .nav-divider { + width: 1px; + background-color: #a0a0a0; +} +[data-bs-theme=light] .navbar .icon-wrapper svg { + stroke: #212529; +} +[data-bs-theme=light] .navbar .hover-underline::after { + content: ""; + height: 2px; + width: 100%; + background-color: #FE2E63; + position: absolute; + bottom: 0; + left: 0; + opacity: 0; + transition: opacity 0.25s ease-in-out; +} +[data-bs-theme=light] .navbar .hover-underline:hover::after { + opacity: 1; +} +[data-bs-theme=light] .navbar .hover-highlight { + transition: color 0.25s ease-in-out; +} +[data-bs-theme=light] .navbar .hover-highlight svg { + transition: stroke 0.25s ease-in-out; +} +[data-bs-theme=light] .navbar .hover-highlight:hover { + color: #FE2E63; +} +[data-bs-theme=light] .navbar .hover-highlight:hover svg { + stroke: #FE2E63; +} +[data-bs-theme=light] .navbar .navbar-brand:hover .logo-wrapper .logo-stroke { + fill: #08D9D6; +} +[data-bs-theme=light] .navbar .navbar-brand:hover .logo-wrapper #triangle path { + fill: #08D9D6; +} +[data-bs-theme=light] .navbar .navbar-brand:hover .logo-wrapper path { + transition: fill 0.5s; +} + +[data-bs-theme=dark] .navbar { + background-color: #000; +} +[data-bs-theme=dark] .navbar .links .nav-item a { + color: gray; +} +[data-bs-theme=dark] .navbar .links .nav-item.active a { + color: #fff; +} +[data-bs-theme=dark] .navbar .links .nav-divider { + width: 1px; + background-color: #3e4042; +} +[data-bs-theme=dark] .navbar .icon-wrapper svg { + stroke: #fff; +} +[data-bs-theme=dark] .navbar .hover-underline::after { + content: ""; + height: 2px; + width: 100%; + background-color: #FE2E63; + position: absolute; + bottom: 0; + left: 0; + opacity: 0; + transition: opacity 0.25s ease-in-out; +} +[data-bs-theme=dark] .navbar .hover-underline:hover::after { + opacity: 1; +} +[data-bs-theme=dark] .navbar .hover-highlight { + transition: color 0.25s ease-in-out; +} +[data-bs-theme=dark] .navbar .hover-highlight svg { + transition: stroke 0.25s ease-in-out; +} +[data-bs-theme=dark] .navbar .hover-highlight:hover { + color: #FE2E63; +} +[data-bs-theme=dark] .navbar .hover-highlight:hover svg { + stroke: #FE2E63; +} +[data-bs-theme=dark] .navbar .navbar-brand:hover .logo-wrapper .logo-stroke { + fill: #08D9D6; +} +[data-bs-theme=dark] .navbar .navbar-brand:hover .logo-wrapper #triangle path { + fill: #08D9D6; +} +[data-bs-theme=dark] .navbar .navbar-brand:hover .logo-wrapper path { + transition: fill 0.5s; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.card.card-s { + width: 15rem; +} +.card.card-m { + width: 25rem; +} +.card.card-l { + width: 40rem; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.icon-wrapper { + width: fit-content; + height: fit-content; +} +.icon-wrapper.icon-s svg { + width: auto; + height: 1rem; +} +.icon-wrapper.icon-m svg { + width: auto; + height: 1.5rem; +} +.icon-wrapper.icon-l svg { + width: auto; + height: 2rem; +} +.icon-wrapper.icon-fill-baca2_blue svg { + fill: #08D9D6; +} +.icon-wrapper.icon-fill-baca2_beige svg { + fill: #D3CABD; +} +.icon-wrapper.icon-fill-baca2_pink svg { + fill: #FE2E63; +} +.icon-wrapper.icon-fill-dark_muted svg { + fill: #3e4042; +} +.icon-wrapper.icon-fill-light_muted svg { + fill: #a0a0a0; +} +.icon-wrapper.icon-fill-pale_muted svg { + fill: #d0d0d0; +} +.icon-wrapper.icon-fill-darker svg { + fill: #171a1d; +} +.icon-wrapper.icon-fill-primary svg { + fill: #FE2E63; +} +.icon-wrapper.icon-fill-secondary svg { + fill: #D3CABD; +} +.icon-wrapper.icon-fill-success svg { + fill: #08D9D6; +} +.icon-wrapper.icon-fill-info svg { + fill: #0dcaf0; +} +.icon-wrapper.icon-fill-warning svg { + fill: #ffc107; +} +.icon-wrapper.icon-fill-danger svg { + fill: #FE2E63; +} +.icon-wrapper.icon-fill-light svg { + fill: #f8f9fa; +} +.icon-wrapper.icon-fill-dark svg { + fill: #212529; +} +.icon-wrapper.icon-stroke-baca2_blue svg { + stroke: #08D9D6; +} +.icon-wrapper.icon-stroke-baca2_beige svg { + stroke: #D3CABD; +} +.icon-wrapper.icon-stroke-baca2_pink svg { + stroke: #FE2E63; +} +.icon-wrapper.icon-stroke-dark_muted svg { + stroke: #3e4042; +} +.icon-wrapper.icon-stroke-light_muted svg { + stroke: #a0a0a0; +} +.icon-wrapper.icon-stroke-pale_muted svg { + stroke: #d0d0d0; +} +.icon-wrapper.icon-stroke-darker svg { + stroke: #171a1d; +} +.icon-wrapper.icon-stroke-primary svg { + stroke: #FE2E63; +} +.icon-wrapper.icon-stroke-secondary svg { + stroke: #D3CABD; +} +.icon-wrapper.icon-stroke-success svg { + stroke: #08D9D6; +} +.icon-wrapper.icon-stroke-info svg { + stroke: #0dcaf0; +} +.icon-wrapper.icon-stroke-warning svg { + stroke: #ffc107; +} +.icon-wrapper.icon-stroke-danger svg { + stroke: #FE2E63; +} +.icon-wrapper.icon-stroke-light svg { + stroke: #f8f9fa; +} +.icon-wrapper.icon-stroke-dark svg { + stroke: #212529; +} + +[data-bs-theme=dark] .theme-button #moon svg { + display: none; +} +[data-bs-theme=dark] .theme-button #sun svg { + display: block; +} + +[data-bs-theme=light] .theme-button #moon svg { + display: block; +} +[data-bs-theme=light] .theme-button #sun svg { + display: none; +} + +.separator { + display: flex; + align-items: center; + text-align: center; +} + +.separator::before, +.separator::after { + content: ""; + flex: 1; + border-bottom: 1px solid; + border-bottom-color: lightgray; +} + +.separator:not(:empty)::before { + margin-right: 0.25em; +} + +.separator:not(:empty)::after { + margin-left: 0.25em; +} + +:root { + --bs-form-valid-border-color: $red; +} + +@media (min-width: 768px) { + .side-nav.sticky-side-nav { + position: sticky; + top: 4.25rem; + } +} +.side-nav .side-nav-content { + list-style: none; + display: flex; + flex-direction: column-reverse; +} +.side-nav .tab-button { + display: inline-block; + width: 100%; + box-sizing: border-box; +} +.side-nav .tab-button .side-nav-link { + text-decoration: none; + display: block; +} +.side-nav .tab-button .sub-tabs-content { + list-style: none; + overflow: hidden; +} +.side-nav .tab-button .sub-tabs-content .sub-tab-button { + display: inline-block; + width: 100%; + box-sizing: border-box; +} + +.tab-wrapper { + display: none; +} +.tab-wrapper .side-nav-tab { + overflow: hidden; +} + +.side-nav { + padding: 0; +} +.side-nav .side-nav-content { + padding: 0.5rem 0 0; + margin: 0; +} +.side-nav .side-nav-content .tab-button { + margin: 0; + padding: 0; +} +.side-nav .side-nav-content .tab-button .side-nav-link { + padding: 1rem; +} +.side-nav .side-nav-content .tab-button .sub-tabs-content { + padding: 0; + margin: 0; +} +.side-nav .side-nav-content .tab-button .sub-tabs-content .sub-tab-button { + margin: 0; + padding: 0; +} +.side-nav .side-nav-content .tab-button .sub-tabs-content .sub-tab-button .side-nav-link { + padding: 0.7rem 1rem 0.7rem 2rem; +} + +.tab-wrapper .side-nav-tab { + padding: 0.5rem; +} + +.side-nav .tab-button .sub-tabs-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.5s ease-out; +} +.side-nav .tab-button.expanded .sub-tabs-wrapper, .side-nav .tab-button:hover .sub-tabs-wrapper { + grid-template-rows: 1fr; +} +.side-nav.expanded .sub-tabs-wrapper { + grid-template-rows: 1fr; +} + +.side-nav { + border-radius: 6px; + height: 100%; +} +.side-nav .tab-button:last-child { + border-radius: 6px 6px 0 0; +} +.side-nav .tab-button:first-child { + border-radius: 0 0 6px 6px; +} +.side-nav .tab-button:first-child .sub-tab-button:last-child { + border-radius: 0 0 6px 6px; +} + +[data-bs-theme=light] .side-nav .tab-button { + border-bottom: #a0a0a0 1px solid; + transition: border-bottom-color ease 0.5s, border-top-color ease 0.5s; +} +[data-bs-theme=light] .side-nav .tab-button:first-child { + border-bottom: none; +} +[data-bs-theme=light] .side-nav .tab-button:hover { + border-bottom-color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button:hover:not(:last-child) + .tab-button { + border-bottom-color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button.active, [data-bs-theme=light] .side-nav .tab-button.expanded { + border-bottom-color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button.active:not(:last-child) + .tab-button, [data-bs-theme=light] .side-nav .tab-button.expanded:not(:last-child) + .tab-button { + border-bottom-color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button .sub-tab-button:first-child { + border-top: #a0a0a0 1px solid; +} + +[data-bs-theme=dark] .side-nav .tab-button { + border-bottom: gray 1px solid; + transition: border-bottom-color ease 0.5s, border-top-color ease 0.5s; +} +[data-bs-theme=dark] .side-nav .tab-button:first-child { + border-bottom: none; +} +[data-bs-theme=dark] .side-nav .tab-button:hover { + border-bottom-color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button:hover:not(:last-child) + .tab-button { + border-bottom-color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button.active, [data-bs-theme=dark] .side-nav .tab-button.expanded { + border-bottom-color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button.active:not(:last-child) + .tab-button, [data-bs-theme=dark] .side-nav .tab-button.expanded:not(:last-child) + .tab-button { + border-bottom-color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button .sub-tab-button:first-child { + border-top: gray 1px solid; +} + +[data-bs-theme=light] .side-nav .tab-button .side-nav-link { + color: gray; + transition: color ease 0.3s; +} +[data-bs-theme=light] .side-nav .tab-button:hover .side-nav-link { + color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button.active .side-nav-link, [data-bs-theme=light] .side-nav .tab-button.expanded .side-nav-link { + color: #212529; +} +[data-bs-theme=light] .side-nav .tab-button.active:hover .side-nav-link, [data-bs-theme=light] .side-nav .tab-button.expanded:hover .side-nav-link { + color: #212529; +} + +[data-bs-theme=dark] .side-nav .tab-button .side-nav-link { + color: gray; + transition: color ease 0.3s; +} +[data-bs-theme=dark] .side-nav .tab-button:hover .side-nav-link { + color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button.active .side-nav-link, [data-bs-theme=dark] .side-nav .tab-button.expanded .side-nav-link { + color: #fff; +} +[data-bs-theme=dark] .side-nav .tab-button.active:hover .side-nav-link, [data-bs-theme=dark] .side-nav .tab-button.expanded:hover .side-nav-link { + color: #fff; +} + +[data-bs-theme=light] .side-nav .tab-button { + background-color: #f8f9fa; + transition: background-color ease 0.4s; +} +[data-bs-theme=light] .side-nav .tab-button:hover { + background-color: #fff; +} +[data-bs-theme=light] .side-nav .tab-button.expanded { + background-color: #fff; +} +[data-bs-theme=light] .side-nav .tab-button.active { + background-color: #D3CABD; +} +[data-bs-theme=light] .side-nav .tab-button.active:hover { + background-color: #fff; +} +[data-bs-theme=light] .side-nav .tab-button.expanded:hover { + background-color: #fff; +} +[data-bs-theme=light] .side-nav .tab-button.expanded .sub-tab-button, [data-bs-theme=light] .side-nav .tab-button:hover .sub-tab-button { + background-color: #fff; +} +[data-bs-theme=light] .side-nav .tab-button .sub-tab-button { + background-color: #f8f9fa; + transition: background-color ease 0.3s; +} +[data-bs-theme=light] .side-nav .tab-button .sub-tab-button:hover { + background-color: #D3CABD; +} +[data-bs-theme=light] .side-nav .tab-button .sub-tab-button.active { + background-color: #D3CABD; +} + +[data-bs-theme=dark] .side-nav .tab-button { + background-color: #000; + transition: background-color ease 0.4s; +} +[data-bs-theme=dark] .side-nav .tab-button:hover { + background-color: #212529; +} +[data-bs-theme=dark] .side-nav .tab-button.expanded { + background-color: #212529; +} +[data-bs-theme=dark] .side-nav .tab-button.active { + background-color: #3e4042; +} +[data-bs-theme=dark] .side-nav .tab-button.active:hover { + background-color: #212529; +} +[data-bs-theme=dark] .side-nav .tab-button.expanded:hover { + background-color: #212529; +} +[data-bs-theme=dark] .side-nav .tab-button.expanded .sub-tab-button, [data-bs-theme=dark] .side-nav .tab-button:hover .sub-tab-button { + background-color: #000; +} +[data-bs-theme=dark] .side-nav .tab-button .sub-tab-button { + background-color: #000; + transition: background-color ease 0.3s; +} +[data-bs-theme=dark] .side-nav .tab-button .sub-tab-button:hover { + background-color: #3e4042; +} +[data-bs-theme=dark] .side-nav .tab-button .sub-tab-button.active { + background-color: #3e4042; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.link-records tbody tr[data-record-link] { + cursor: pointer; +} + +.no-header thead { + display: none; +} +.no-header.no-footer { + border-bottom: 0 !important; +} + +.table-wrapper .table-widget-forms-wrapper form { + display: none; +} +.table-wrapper .column-form-wrapper form { + display: none; +} +.table-wrapper td.form-submit { + padding-left: 0.25rem; + padding-right: 0.25rem; +} +.table-wrapper td.form-submit .btn { + padding: 0.25rem 0.5rem; + white-space: nowrap; +} +.table-wrapper th .icon-header { + height: 1.5rem; + width: auto; +} +.table-wrapper .table-buttons { + --bs-gutter-x: 0.5rem; +} +.table-wrapper .card { + overflow: hidden; +} +.table-wrapper .table-responsive[data-paging=false] .dataTables_info { + display: none; +} +.table-wrapper .resize-wrapper { + position: relative; +} +.table-wrapper .resize-wrapper .table-responsive, .table-wrapper .resize-wrapper .dataTables_wrapper, .table-wrapper .resize-wrapper .dataTables_scroll { + height: 100%; +} +.table-wrapper .resize-handle { + cursor: pointer; +} +.table-wrapper .dataTables_length select { + padding: 0.375rem 2.25rem 0.375rem 0.75rem !important; + color: var(--bs-body-color) !important; + border: var(--bs-border-width) solid var(--bs-border-color) !important; + border-radius: var(--bs-border-radius) !important; + background-color: var(--bs-body-bg) !important; +} + +[data-bs-theme=light] .table-wrapper .table-util-header { + background-color: #f8f9fa; +} +[data-bs-theme=light] .table-wrapper td.delete i { + color: gray; +} +[data-bs-theme=light] .table-wrapper td.delete:hover i { + color: #FE2E63; +} +[data-bs-theme=light] .table-wrapper .row-selected td { + background-color: rgba(0, 0, 0, 0.08); +} +[data-bs-theme=light] .table-wrapper .row-hover tr[data-record-link]:hover td { + background-color: rgba(0, 0, 0, 0.08); +} +[data-bs-theme=light] .table-wrapper .resize-handle { + background-color: #f8f9fa; +} +[data-bs-theme=light] .table-wrapper .resize-handle:hover { + background-color: rgba(0, 0, 0, 0.03); +} + +[data-bs-theme=dark] .table-wrapper .table-util-header { + background-color: rgba(0, 0, 0, 0.3); +} +[data-bs-theme=dark] .table-wrapper td.delete i { + color: gray; +} +[data-bs-theme=dark] .table-wrapper td.delete:hover i { + color: #FE2E63; +} +[data-bs-theme=dark] .table-wrapper .row-selected td { + background-color: rgba(255, 255, 255, 0.03); +} +[data-bs-theme=dark] .table-wrapper .row-hover tr[data-record-link]:hover td { + background-color: rgba(255, 255, 255, 0.03); +} +[data-bs-theme=dark] .table-wrapper .resize-handle { + background-color: rgba(0, 0, 0, 0.3); +} +[data-bs-theme=dark] .table-wrapper .resize-handle:hover { + background-color: rgba(0, 0, 0, 0.4); +} + +:root { + --bs-form-valid-border-color: $red; +} + +.btn-outline-secondary:hover { + background-color: rgba(211, 202, 189, 0.3); + color: white; +} + +.btn-outline-success:hover { + background-color: rgba(8, 217, 214, 0.3); + color: white; +} + +.btn-outline-danger:hover { + background-color: rgba(254, 46, 99, 0.3); + color: white; +} + +.btn-outline-light_muted:hover { + background-color: rgba(160, 160, 160, 0.3); + color: white; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.form-confirmation-popup p.popup-message { + margin-bottom: 0; +} +.form-confirmation-popup .popup-summary { + margin-top: 1rem; +} + +.form-success-popup .path { + stroke: #08D9D6; +} + +.form-failure-popup .path { + stroke: #FE2E63; +} + +.form-success-popup .modal-content svg, .form-failure-popup .modal-content svg { + width: 100px; +} +.form-success-popup .modal-content .path, .form-failure-popup .modal-content .path { + stroke-dasharray: 1000; + stroke-dashoffset: 0; +} +.form-success-popup .modal-content .path.circle, .form-failure-popup .modal-content .path.circle { + -webkit-animation: dash 0.9s ease-in-out; + animation: dash 0.9s ease-in-out; +} +.form-success-popup .modal-content .path.line, .form-failure-popup .modal-content .path.line { + stroke-dashoffset: 1000; + -webkit-animation: dash 0.95s 0.35s ease-in-out forwards; + animation: dash 0.95s 0.35s ease-in-out forwards; +} +.form-success-popup .modal-content .path.check, .form-failure-popup .modal-content .path.check { + stroke-dashoffset: -100; + -webkit-animation: dash-check 0.95s 0.35s ease-in-out forwards; + animation: dash-check 0.95s 0.35s ease-in-out forwards; +} + +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@-webkit-keyframes dash-check { + 0% { + stroke-dashoffset: -100; + } + 100% { + stroke-dashoffset: 900; + } +} +@keyframes dash-check { + 0% { + stroke-dashoffset: -100; + } + 100% { + stroke-dashoffset: 900; + } +} +.pdf-displayer .pdf-page-info { + display: flex; + line-height: 1.8rem; +} +.pdf-displayer .pdf-page-change { + display: flex; +} +.pdf-displayer .pdf-page-number { + flex: 1; + width: 2.5rem; +} + +:root { + --bs-form-valid-border-color: $red; +} + +.xdsoft_datetimepicker { + border-radius: 5px; +} +.xdsoft_datetimepicker .xdsoft_select { + border: none !important; +} +.xdsoft_datetimepicker .xdsoft_select .xdsoft_current { + box-shadow: none !important; +} +.xdsoft_datetimepicker .xdsoft_calendar th, .xdsoft_datetimepicker .xdsoft_calendar td { + box-shadow: none !important; + border: none !important; + box-sizing: border-box !important; +} +.xdsoft_datetimepicker .xdsoft_calendar .xdsoft_current { + box-shadow: none !important; +} +.xdsoft_datetimepicker .xdsoft_time_box { + border: none !important; +} +.xdsoft_datetimepicker .xdsoft_time_box .xdsoft_time { + border: none !important; +} +.xdsoft_datetimepicker .xdsoft_time_box .xdsoft_current { + box-shadow: none !important; +} + +[data-bs-theme=light] .xdsoft_datetimepicker { + background: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_label { + background-color: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_label i, [data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_prev, [data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_next, [data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_today_button { + background-image: url('../img/datetime_picker_icons_black.png') !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_label { + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_select .xdsoft_option { + background: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_select .xdsoft_option:hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_select .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar th { + background: #f8f9fa !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar td:not(.xdsoft_other_month) { + background: #fff !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar td:not(.xdsoft_other_month):hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_other_month { + background: #f8f9fa !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_other_month:hover { + background: #212529 !important; + color: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_time { + background: #fff !important; + color: #212529 !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_time:hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=light] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #212529 !important; +} + +[data-bs-theme=dark] .xdsoft_datetimepicker { + background: #000 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_label { + background-color: #000 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_label i, [data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_prev, [data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_next, [data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_today_button { + background-image: url('../img/datetime_picker_icons_white.png') !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_label { + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_select .xdsoft_option { + background: #000 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_select .xdsoft_option:hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_select .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar th { + background: #000 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar td:not(.xdsoft_other_month) { + background: #d0d0d0 !important; + color: #212529 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar td:not(.xdsoft_other_month):hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #212529 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_other_month { + background: #f8f9fa !important; + color: #212529 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_calendar .xdsoft_other_month:hover { + background: #212529 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_time { + background: #d0d0d0 !important; + color: #212529 !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_time:hover { + background: #FE2E63 !important; + color: #f8f9fa !important; +} +[data-bs-theme=dark] .xdsoft_datetimepicker .xdsoft_time_box .xdsoft_current { + border: 2px solid #FE2E63 !important; + color: #212529 !important; +} + +:root { + --bs-form-valid-border-color: $red; +} + +::-webkit-scrollbar { + width: 20px; +} + +::-webkit-scrollbar-thumb { + border-radius: 20px; + border: 6px solid transparent; + background-clip: content-box; +} + +[data-bs-theme=light] ::-webkit-scrollbar-thumb { + background-color: #d0d0d0; +} +[data-bs-theme=light] ::-webkit-scrollbar-thumb:hover { + background-color: #a0a0a0; +} + +[data-bs-theme=dark] ::-webkit-scrollbar-thumb { + background-color: #3e4042; +} +[data-bs-theme=dark] ::-webkit-scrollbar-thumb:hover { + background-color: #a0a0a0; +} + +:root { + --bs-form-valid-border-color: $red; +} + +pre.code-block.wrap-lines { + overflow: hidden; +} +pre.code-block.wrap-lines code { + white-space: pre-wrap; +} + +[data-bs-theme=light] pre.code-block { + background: rgb(245, 242, 240) !important; +} +[data-bs-theme=light] pre.code-block .token.operator { + background: rgb(245, 242, 240) !important; +} +[data-bs-theme=light] .test-summary .compile-tabs-nav .nav-link.active { + background-color: rgb(245, 242, 240); +} + +[data-bs-theme=dark] pre.code-block { + background: #171a1d !important; +} +[data-bs-theme=dark] pre.code-block .token.operator { + background: #171a1d !important; +} +[data-bs-theme=dark] .test-summary .compile-tabs-nav .nav-link.active { + background-color: #171a1d; +} + +.test-summary .compile-tabs .code-block { + border-top-left-radius: 0; + border-top-right-radius: 0; +} +.test-summary .compile-tabs-nav { + overflow: hidden; +} + +/*! + * Bootstrap Icons v1.11.3 (https://icons.getbootstrap.com/) + * Copyright 2019-2024 The Bootstrap Authors + * Licensed under MIT (https://github.com/twbs/icons/blob/main/LICENSE) + */ +@font-face { + font-display: block; + font-family: "bootstrap-icons"; + src: url("./fonts/bootstrap-icons.woff2?24e3eb84d0bcaf83d77f904c78ac1f47") format("woff2"), url("./fonts/bootstrap-icons.woff?24e3eb84d0bcaf83d77f904c78ac1f47") format("woff"); +} +.bi::before, +[class^=bi-]::before, +[class*=" bi-"]::before { + display: inline-block; + font-family: "bootstrap-icons" !important; + font-style: normal; + font-weight: normal !important; + font-variant: normal; + text-transform: none; + line-height: 1; + vertical-align: -0.125em; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +.bi-123::before { + content: "\f67f"; +} + +.bi-alarm-fill::before { + content: "\f101"; +} + +.bi-alarm::before { + content: "\f102"; +} + +.bi-align-bottom::before { + content: "\f103"; +} + +.bi-align-center::before { + content: "\f104"; +} + +.bi-align-end::before { + content: "\f105"; +} + +.bi-align-middle::before { + content: "\f106"; +} + +.bi-align-start::before { + content: "\f107"; +} + +.bi-align-top::before { + content: "\f108"; +} + +.bi-alt::before { + content: "\f109"; +} + +.bi-app-indicator::before { + content: "\f10a"; +} + +.bi-app::before { + content: "\f10b"; +} + +.bi-archive-fill::before { + content: "\f10c"; +} + +.bi-archive::before { + content: "\f10d"; +} + +.bi-arrow-90deg-down::before { + content: "\f10e"; +} + +.bi-arrow-90deg-left::before { + content: "\f10f"; +} + +.bi-arrow-90deg-right::before { + content: "\f110"; +} + +.bi-arrow-90deg-up::before { + content: "\f111"; +} + +.bi-arrow-bar-down::before { + content: "\f112"; +} + +.bi-arrow-bar-left::before { + content: "\f113"; +} + +.bi-arrow-bar-right::before { + content: "\f114"; +} + +.bi-arrow-bar-up::before { + content: "\f115"; +} + +.bi-arrow-clockwise::before { + content: "\f116"; +} + +.bi-arrow-counterclockwise::before { + content: "\f117"; +} + +.bi-arrow-down-circle-fill::before { + content: "\f118"; +} + +.bi-arrow-down-circle::before { + content: "\f119"; +} + +.bi-arrow-down-left-circle-fill::before { + content: "\f11a"; +} + +.bi-arrow-down-left-circle::before { + content: "\f11b"; +} + +.bi-arrow-down-left-square-fill::before { + content: "\f11c"; +} + +.bi-arrow-down-left-square::before { + content: "\f11d"; +} + +.bi-arrow-down-left::before { + content: "\f11e"; +} + +.bi-arrow-down-right-circle-fill::before { + content: "\f11f"; +} + +.bi-arrow-down-right-circle::before { + content: "\f120"; +} + +.bi-arrow-down-right-square-fill::before { + content: "\f121"; +} + +.bi-arrow-down-right-square::before { + content: "\f122"; +} + +.bi-arrow-down-right::before { + content: "\f123"; +} + +.bi-arrow-down-short::before { + content: "\f124"; +} + +.bi-arrow-down-square-fill::before { + content: "\f125"; +} + +.bi-arrow-down-square::before { + content: "\f126"; +} + +.bi-arrow-down-up::before { + content: "\f127"; +} + +.bi-arrow-down::before { + content: "\f128"; +} + +.bi-arrow-left-circle-fill::before { + content: "\f129"; +} + +.bi-arrow-left-circle::before { + content: "\f12a"; +} + +.bi-arrow-left-right::before { + content: "\f12b"; +} + +.bi-arrow-left-short::before { + content: "\f12c"; +} + +.bi-arrow-left-square-fill::before { + content: "\f12d"; +} + +.bi-arrow-left-square::before { + content: "\f12e"; +} + +.bi-arrow-left::before { + content: "\f12f"; +} + +.bi-arrow-repeat::before { + content: "\f130"; +} + +.bi-arrow-return-left::before { + content: "\f131"; +} + +.bi-arrow-return-right::before { + content: "\f132"; +} + +.bi-arrow-right-circle-fill::before { + content: "\f133"; +} + +.bi-arrow-right-circle::before { + content: "\f134"; +} + +.bi-arrow-right-short::before { + content: "\f135"; +} + +.bi-arrow-right-square-fill::before { + content: "\f136"; +} + +.bi-arrow-right-square::before { + content: "\f137"; +} + +.bi-arrow-right::before { + content: "\f138"; +} + +.bi-arrow-up-circle-fill::before { + content: "\f139"; +} + +.bi-arrow-up-circle::before { + content: "\f13a"; +} + +.bi-arrow-up-left-circle-fill::before { + content: "\f13b"; +} + +.bi-arrow-up-left-circle::before { + content: "\f13c"; +} + +.bi-arrow-up-left-square-fill::before { + content: "\f13d"; +} + +.bi-arrow-up-left-square::before { + content: "\f13e"; +} + +.bi-arrow-up-left::before { + content: "\f13f"; +} + +.bi-arrow-up-right-circle-fill::before { + content: "\f140"; +} + +.bi-arrow-up-right-circle::before { + content: "\f141"; +} + +.bi-arrow-up-right-square-fill::before { + content: "\f142"; +} + +.bi-arrow-up-right-square::before { + content: "\f143"; +} + +.bi-arrow-up-right::before { + content: "\f144"; +} + +.bi-arrow-up-short::before { + content: "\f145"; +} + +.bi-arrow-up-square-fill::before { + content: "\f146"; +} + +.bi-arrow-up-square::before { + content: "\f147"; +} + +.bi-arrow-up::before { + content: "\f148"; +} + +.bi-arrows-angle-contract::before { + content: "\f149"; +} + +.bi-arrows-angle-expand::before { + content: "\f14a"; +} + +.bi-arrows-collapse::before { + content: "\f14b"; +} + +.bi-arrows-expand::before { + content: "\f14c"; +} + +.bi-arrows-fullscreen::before { + content: "\f14d"; +} + +.bi-arrows-move::before { + content: "\f14e"; +} + +.bi-aspect-ratio-fill::before { + content: "\f14f"; +} + +.bi-aspect-ratio::before { + content: "\f150"; +} + +.bi-asterisk::before { + content: "\f151"; +} + +.bi-at::before { + content: "\f152"; +} + +.bi-award-fill::before { + content: "\f153"; +} + +.bi-award::before { + content: "\f154"; +} + +.bi-back::before { + content: "\f155"; +} + +.bi-backspace-fill::before { + content: "\f156"; +} + +.bi-backspace-reverse-fill::before { + content: "\f157"; +} + +.bi-backspace-reverse::before { + content: "\f158"; +} + +.bi-backspace::before { + content: "\f159"; +} + +.bi-badge-3d-fill::before { + content: "\f15a"; +} + +.bi-badge-3d::before { + content: "\f15b"; +} + +.bi-badge-4k-fill::before { + content: "\f15c"; +} + +.bi-badge-4k::before { + content: "\f15d"; +} + +.bi-badge-8k-fill::before { + content: "\f15e"; +} + +.bi-badge-8k::before { + content: "\f15f"; +} + +.bi-badge-ad-fill::before { + content: "\f160"; +} + +.bi-badge-ad::before { + content: "\f161"; +} + +.bi-badge-ar-fill::before { + content: "\f162"; +} + +.bi-badge-ar::before { + content: "\f163"; +} + +.bi-badge-cc-fill::before { + content: "\f164"; +} + +.bi-badge-cc::before { + content: "\f165"; +} + +.bi-badge-hd-fill::before { + content: "\f166"; +} + +.bi-badge-hd::before { + content: "\f167"; +} + +.bi-badge-tm-fill::before { + content: "\f168"; +} + +.bi-badge-tm::before { + content: "\f169"; +} + +.bi-badge-vo-fill::before { + content: "\f16a"; +} + +.bi-badge-vo::before { + content: "\f16b"; +} + +.bi-badge-vr-fill::before { + content: "\f16c"; +} + +.bi-badge-vr::before { + content: "\f16d"; +} + +.bi-badge-wc-fill::before { + content: "\f16e"; +} + +.bi-badge-wc::before { + content: "\f16f"; +} + +.bi-bag-check-fill::before { + content: "\f170"; +} + +.bi-bag-check::before { + content: "\f171"; +} + +.bi-bag-dash-fill::before { + content: "\f172"; +} + +.bi-bag-dash::before { + content: "\f173"; +} + +.bi-bag-fill::before { + content: "\f174"; +} + +.bi-bag-plus-fill::before { + content: "\f175"; +} + +.bi-bag-plus::before { + content: "\f176"; +} + +.bi-bag-x-fill::before { + content: "\f177"; +} + +.bi-bag-x::before { + content: "\f178"; +} + +.bi-bag::before { + content: "\f179"; +} + +.bi-bar-chart-fill::before { + content: "\f17a"; +} + +.bi-bar-chart-line-fill::before { + content: "\f17b"; +} + +.bi-bar-chart-line::before { + content: "\f17c"; +} + +.bi-bar-chart-steps::before { + content: "\f17d"; +} + +.bi-bar-chart::before { + content: "\f17e"; +} + +.bi-basket-fill::before { + content: "\f17f"; +} + +.bi-basket::before { + content: "\f180"; +} + +.bi-basket2-fill::before { + content: "\f181"; +} + +.bi-basket2::before { + content: "\f182"; +} + +.bi-basket3-fill::before { + content: "\f183"; +} + +.bi-basket3::before { + content: "\f184"; +} + +.bi-battery-charging::before { + content: "\f185"; +} + +.bi-battery-full::before { + content: "\f186"; +} + +.bi-battery-half::before { + content: "\f187"; +} + +.bi-battery::before { + content: "\f188"; +} + +.bi-bell-fill::before { + content: "\f189"; +} + +.bi-bell::before { + content: "\f18a"; +} + +.bi-bezier::before { + content: "\f18b"; +} + +.bi-bezier2::before { + content: "\f18c"; +} + +.bi-bicycle::before { + content: "\f18d"; +} + +.bi-binoculars-fill::before { + content: "\f18e"; +} + +.bi-binoculars::before { + content: "\f18f"; +} + +.bi-blockquote-left::before { + content: "\f190"; +} + +.bi-blockquote-right::before { + content: "\f191"; +} + +.bi-book-fill::before { + content: "\f192"; +} + +.bi-book-half::before { + content: "\f193"; +} + +.bi-book::before { + content: "\f194"; +} + +.bi-bookmark-check-fill::before { + content: "\f195"; +} + +.bi-bookmark-check::before { + content: "\f196"; +} + +.bi-bookmark-dash-fill::before { + content: "\f197"; +} + +.bi-bookmark-dash::before { + content: "\f198"; +} + +.bi-bookmark-fill::before { + content: "\f199"; +} + +.bi-bookmark-heart-fill::before { + content: "\f19a"; +} + +.bi-bookmark-heart::before { + content: "\f19b"; +} + +.bi-bookmark-plus-fill::before { + content: "\f19c"; +} + +.bi-bookmark-plus::before { + content: "\f19d"; +} + +.bi-bookmark-star-fill::before { + content: "\f19e"; +} + +.bi-bookmark-star::before { + content: "\f19f"; +} + +.bi-bookmark-x-fill::before { + content: "\f1a0"; +} + +.bi-bookmark-x::before { + content: "\f1a1"; +} + +.bi-bookmark::before { + content: "\f1a2"; +} + +.bi-bookmarks-fill::before { + content: "\f1a3"; +} + +.bi-bookmarks::before { + content: "\f1a4"; +} + +.bi-bookshelf::before { + content: "\f1a5"; +} + +.bi-bootstrap-fill::before { + content: "\f1a6"; +} + +.bi-bootstrap-reboot::before { + content: "\f1a7"; +} + +.bi-bootstrap::before { + content: "\f1a8"; +} + +.bi-border-all::before { + content: "\f1a9"; +} + +.bi-border-bottom::before { + content: "\f1aa"; +} + +.bi-border-center::before { + content: "\f1ab"; +} + +.bi-border-inner::before { + content: "\f1ac"; +} + +.bi-border-left::before { + content: "\f1ad"; +} + +.bi-border-middle::before { + content: "\f1ae"; +} + +.bi-border-outer::before { + content: "\f1af"; +} + +.bi-border-right::before { + content: "\f1b0"; +} + +.bi-border-style::before { + content: "\f1b1"; +} + +.bi-border-top::before { + content: "\f1b2"; +} + +.bi-border-width::before { + content: "\f1b3"; +} + +.bi-border::before { + content: "\f1b4"; +} + +.bi-bounding-box-circles::before { + content: "\f1b5"; +} + +.bi-bounding-box::before { + content: "\f1b6"; +} + +.bi-box-arrow-down-left::before { + content: "\f1b7"; +} + +.bi-box-arrow-down-right::before { + content: "\f1b8"; +} + +.bi-box-arrow-down::before { + content: "\f1b9"; +} + +.bi-box-arrow-in-down-left::before { + content: "\f1ba"; +} + +.bi-box-arrow-in-down-right::before { + content: "\f1bb"; +} + +.bi-box-arrow-in-down::before { + content: "\f1bc"; +} + +.bi-box-arrow-in-left::before { + content: "\f1bd"; +} + +.bi-box-arrow-in-right::before { + content: "\f1be"; +} + +.bi-box-arrow-in-up-left::before { + content: "\f1bf"; +} + +.bi-box-arrow-in-up-right::before { + content: "\f1c0"; +} + +.bi-box-arrow-in-up::before { + content: "\f1c1"; +} + +.bi-box-arrow-left::before { + content: "\f1c2"; +} + +.bi-box-arrow-right::before { + content: "\f1c3"; +} + +.bi-box-arrow-up-left::before { + content: "\f1c4"; +} + +.bi-box-arrow-up-right::before { + content: "\f1c5"; +} + +.bi-box-arrow-up::before { + content: "\f1c6"; +} + +.bi-box-seam::before { + content: "\f1c7"; +} + +.bi-box::before { + content: "\f1c8"; +} + +.bi-braces::before { + content: "\f1c9"; +} + +.bi-bricks::before { + content: "\f1ca"; +} + +.bi-briefcase-fill::before { + content: "\f1cb"; +} + +.bi-briefcase::before { + content: "\f1cc"; +} + +.bi-brightness-alt-high-fill::before { + content: "\f1cd"; +} + +.bi-brightness-alt-high::before { + content: "\f1ce"; +} + +.bi-brightness-alt-low-fill::before { + content: "\f1cf"; +} + +.bi-brightness-alt-low::before { + content: "\f1d0"; +} + +.bi-brightness-high-fill::before { + content: "\f1d1"; +} + +.bi-brightness-high::before { + content: "\f1d2"; +} + +.bi-brightness-low-fill::before { + content: "\f1d3"; +} + +.bi-brightness-low::before { + content: "\f1d4"; +} + +.bi-broadcast-pin::before { + content: "\f1d5"; +} + +.bi-broadcast::before { + content: "\f1d6"; +} + +.bi-brush-fill::before { + content: "\f1d7"; +} + +.bi-brush::before { + content: "\f1d8"; +} + +.bi-bucket-fill::before { + content: "\f1d9"; +} + +.bi-bucket::before { + content: "\f1da"; +} + +.bi-bug-fill::before { + content: "\f1db"; +} + +.bi-bug::before { + content: "\f1dc"; +} + +.bi-building::before { + content: "\f1dd"; +} + +.bi-bullseye::before { + content: "\f1de"; +} + +.bi-calculator-fill::before { + content: "\f1df"; +} + +.bi-calculator::before { + content: "\f1e0"; +} + +.bi-calendar-check-fill::before { + content: "\f1e1"; +} + +.bi-calendar-check::before { + content: "\f1e2"; +} + +.bi-calendar-date-fill::before { + content: "\f1e3"; +} + +.bi-calendar-date::before { + content: "\f1e4"; +} + +.bi-calendar-day-fill::before { + content: "\f1e5"; +} + +.bi-calendar-day::before { + content: "\f1e6"; +} + +.bi-calendar-event-fill::before { + content: "\f1e7"; +} + +.bi-calendar-event::before { + content: "\f1e8"; +} + +.bi-calendar-fill::before { + content: "\f1e9"; +} + +.bi-calendar-minus-fill::before { + content: "\f1ea"; +} + +.bi-calendar-minus::before { + content: "\f1eb"; +} + +.bi-calendar-month-fill::before { + content: "\f1ec"; +} + +.bi-calendar-month::before { + content: "\f1ed"; +} + +.bi-calendar-plus-fill::before { + content: "\f1ee"; +} + +.bi-calendar-plus::before { + content: "\f1ef"; +} + +.bi-calendar-range-fill::before { + content: "\f1f0"; +} + +.bi-calendar-range::before { + content: "\f1f1"; +} + +.bi-calendar-week-fill::before { + content: "\f1f2"; +} + +.bi-calendar-week::before { + content: "\f1f3"; +} + +.bi-calendar-x-fill::before { + content: "\f1f4"; +} + +.bi-calendar-x::before { + content: "\f1f5"; +} + +.bi-calendar::before { + content: "\f1f6"; +} + +.bi-calendar2-check-fill::before { + content: "\f1f7"; +} + +.bi-calendar2-check::before { + content: "\f1f8"; +} + +.bi-calendar2-date-fill::before { + content: "\f1f9"; +} + +.bi-calendar2-date::before { + content: "\f1fa"; +} + +.bi-calendar2-day-fill::before { + content: "\f1fb"; +} + +.bi-calendar2-day::before { + content: "\f1fc"; +} + +.bi-calendar2-event-fill::before { + content: "\f1fd"; +} + +.bi-calendar2-event::before { + content: "\f1fe"; +} + +.bi-calendar2-fill::before { + content: "\f1ff"; +} + +.bi-calendar2-minus-fill::before { + content: "\f200"; +} + +.bi-calendar2-minus::before { + content: "\f201"; +} + +.bi-calendar2-month-fill::before { + content: "\f202"; +} + +.bi-calendar2-month::before { + content: "\f203"; +} + +.bi-calendar2-plus-fill::before { + content: "\f204"; +} + +.bi-calendar2-plus::before { + content: "\f205"; +} + +.bi-calendar2-range-fill::before { + content: "\f206"; +} + +.bi-calendar2-range::before { + content: "\f207"; +} + +.bi-calendar2-week-fill::before { + content: "\f208"; +} + +.bi-calendar2-week::before { + content: "\f209"; +} + +.bi-calendar2-x-fill::before { + content: "\f20a"; +} + +.bi-calendar2-x::before { + content: "\f20b"; +} + +.bi-calendar2::before { + content: "\f20c"; +} + +.bi-calendar3-event-fill::before { + content: "\f20d"; +} + +.bi-calendar3-event::before { + content: "\f20e"; +} + +.bi-calendar3-fill::before { + content: "\f20f"; +} + +.bi-calendar3-range-fill::before { + content: "\f210"; +} + +.bi-calendar3-range::before { + content: "\f211"; +} + +.bi-calendar3-week-fill::before { + content: "\f212"; +} + +.bi-calendar3-week::before { + content: "\f213"; +} + +.bi-calendar3::before { + content: "\f214"; +} + +.bi-calendar4-event::before { + content: "\f215"; +} + +.bi-calendar4-range::before { + content: "\f216"; +} + +.bi-calendar4-week::before { + content: "\f217"; +} + +.bi-calendar4::before { + content: "\f218"; +} + +.bi-camera-fill::before { + content: "\f219"; +} + +.bi-camera-reels-fill::before { + content: "\f21a"; +} + +.bi-camera-reels::before { + content: "\f21b"; +} + +.bi-camera-video-fill::before { + content: "\f21c"; +} + +.bi-camera-video-off-fill::before { + content: "\f21d"; +} + +.bi-camera-video-off::before { + content: "\f21e"; +} + +.bi-camera-video::before { + content: "\f21f"; +} + +.bi-camera::before { + content: "\f220"; +} + +.bi-camera2::before { + content: "\f221"; +} + +.bi-capslock-fill::before { + content: "\f222"; +} + +.bi-capslock::before { + content: "\f223"; +} + +.bi-card-checklist::before { + content: "\f224"; +} + +.bi-card-heading::before { + content: "\f225"; +} + +.bi-card-image::before { + content: "\f226"; +} + +.bi-card-list::before { + content: "\f227"; +} + +.bi-card-text::before { + content: "\f228"; +} + +.bi-caret-down-fill::before { + content: "\f229"; +} + +.bi-caret-down-square-fill::before { + content: "\f22a"; +} + +.bi-caret-down-square::before { + content: "\f22b"; +} + +.bi-caret-down::before { + content: "\f22c"; +} + +.bi-caret-left-fill::before { + content: "\f22d"; +} + +.bi-caret-left-square-fill::before { + content: "\f22e"; +} + +.bi-caret-left-square::before { + content: "\f22f"; +} + +.bi-caret-left::before { + content: "\f230"; +} + +.bi-caret-right-fill::before { + content: "\f231"; +} + +.bi-caret-right-square-fill::before { + content: "\f232"; +} + +.bi-caret-right-square::before { + content: "\f233"; +} + +.bi-caret-right::before { + content: "\f234"; +} + +.bi-caret-up-fill::before { + content: "\f235"; +} + +.bi-caret-up-square-fill::before { + content: "\f236"; +} + +.bi-caret-up-square::before { + content: "\f237"; +} + +.bi-caret-up::before { + content: "\f238"; +} + +.bi-cart-check-fill::before { + content: "\f239"; +} + +.bi-cart-check::before { + content: "\f23a"; +} + +.bi-cart-dash-fill::before { + content: "\f23b"; +} + +.bi-cart-dash::before { + content: "\f23c"; +} + +.bi-cart-fill::before { + content: "\f23d"; +} + +.bi-cart-plus-fill::before { + content: "\f23e"; +} + +.bi-cart-plus::before { + content: "\f23f"; +} + +.bi-cart-x-fill::before { + content: "\f240"; +} + +.bi-cart-x::before { + content: "\f241"; +} + +.bi-cart::before { + content: "\f242"; +} + +.bi-cart2::before { + content: "\f243"; +} + +.bi-cart3::before { + content: "\f244"; +} + +.bi-cart4::before { + content: "\f245"; +} + +.bi-cash-stack::before { + content: "\f246"; +} + +.bi-cash::before { + content: "\f247"; +} + +.bi-cast::before { + content: "\f248"; +} + +.bi-chat-dots-fill::before { + content: "\f249"; +} + +.bi-chat-dots::before { + content: "\f24a"; +} + +.bi-chat-fill::before { + content: "\f24b"; +} + +.bi-chat-left-dots-fill::before { + content: "\f24c"; +} + +.bi-chat-left-dots::before { + content: "\f24d"; +} + +.bi-chat-left-fill::before { + content: "\f24e"; +} + +.bi-chat-left-quote-fill::before { + content: "\f24f"; +} + +.bi-chat-left-quote::before { + content: "\f250"; +} + +.bi-chat-left-text-fill::before { + content: "\f251"; +} + +.bi-chat-left-text::before { + content: "\f252"; +} + +.bi-chat-left::before { + content: "\f253"; +} + +.bi-chat-quote-fill::before { + content: "\f254"; +} + +.bi-chat-quote::before { + content: "\f255"; +} + +.bi-chat-right-dots-fill::before { + content: "\f256"; +} + +.bi-chat-right-dots::before { + content: "\f257"; +} + +.bi-chat-right-fill::before { + content: "\f258"; +} + +.bi-chat-right-quote-fill::before { + content: "\f259"; +} + +.bi-chat-right-quote::before { + content: "\f25a"; +} + +.bi-chat-right-text-fill::before { + content: "\f25b"; +} + +.bi-chat-right-text::before { + content: "\f25c"; +} + +.bi-chat-right::before { + content: "\f25d"; +} + +.bi-chat-square-dots-fill::before { + content: "\f25e"; +} + +.bi-chat-square-dots::before { + content: "\f25f"; +} + +.bi-chat-square-fill::before { + content: "\f260"; +} + +.bi-chat-square-quote-fill::before { + content: "\f261"; +} + +.bi-chat-square-quote::before { + content: "\f262"; +} + +.bi-chat-square-text-fill::before { + content: "\f263"; +} + +.bi-chat-square-text::before { + content: "\f264"; +} + +.bi-chat-square::before { + content: "\f265"; +} + +.bi-chat-text-fill::before { + content: "\f266"; +} + +.bi-chat-text::before { + content: "\f267"; +} + +.bi-chat::before { + content: "\f268"; +} + +.bi-check-all::before { + content: "\f269"; +} + +.bi-check-circle-fill::before { + content: "\f26a"; +} + +.bi-check-circle::before { + content: "\f26b"; +} + +.bi-check-square-fill::before { + content: "\f26c"; +} + +.bi-check-square::before { + content: "\f26d"; +} + +.bi-check::before { + content: "\f26e"; +} + +.bi-check2-all::before { + content: "\f26f"; +} + +.bi-check2-circle::before { + content: "\f270"; +} + +.bi-check2-square::before { + content: "\f271"; +} + +.bi-check2::before { + content: "\f272"; +} + +.bi-chevron-bar-contract::before { + content: "\f273"; +} + +.bi-chevron-bar-down::before { + content: "\f274"; +} + +.bi-chevron-bar-expand::before { + content: "\f275"; +} + +.bi-chevron-bar-left::before { + content: "\f276"; +} + +.bi-chevron-bar-right::before { + content: "\f277"; +} + +.bi-chevron-bar-up::before { + content: "\f278"; +} + +.bi-chevron-compact-down::before { + content: "\f279"; +} + +.bi-chevron-compact-left::before { + content: "\f27a"; +} + +.bi-chevron-compact-right::before { + content: "\f27b"; +} + +.bi-chevron-compact-up::before { + content: "\f27c"; +} + +.bi-chevron-contract::before { + content: "\f27d"; +} + +.bi-chevron-double-down::before { + content: "\f27e"; +} + +.bi-chevron-double-left::before { + content: "\f27f"; +} + +.bi-chevron-double-right::before { + content: "\f280"; +} + +.bi-chevron-double-up::before { + content: "\f281"; +} + +.bi-chevron-down::before { + content: "\f282"; +} + +.bi-chevron-expand::before { + content: "\f283"; +} + +.bi-chevron-left::before { + content: "\f284"; +} + +.bi-chevron-right::before { + content: "\f285"; +} + +.bi-chevron-up::before { + content: "\f286"; +} + +.bi-circle-fill::before { + content: "\f287"; +} + +.bi-circle-half::before { + content: "\f288"; +} + +.bi-circle-square::before { + content: "\f289"; +} + +.bi-circle::before { + content: "\f28a"; +} + +.bi-clipboard-check::before { + content: "\f28b"; +} + +.bi-clipboard-data::before { + content: "\f28c"; +} + +.bi-clipboard-minus::before { + content: "\f28d"; +} + +.bi-clipboard-plus::before { + content: "\f28e"; +} + +.bi-clipboard-x::before { + content: "\f28f"; +} + +.bi-clipboard::before { + content: "\f290"; +} + +.bi-clock-fill::before { + content: "\f291"; +} + +.bi-clock-history::before { + content: "\f292"; +} + +.bi-clock::before { + content: "\f293"; +} + +.bi-cloud-arrow-down-fill::before { + content: "\f294"; +} + +.bi-cloud-arrow-down::before { + content: "\f295"; +} + +.bi-cloud-arrow-up-fill::before { + content: "\f296"; +} + +.bi-cloud-arrow-up::before { + content: "\f297"; +} + +.bi-cloud-check-fill::before { + content: "\f298"; +} + +.bi-cloud-check::before { + content: "\f299"; +} + +.bi-cloud-download-fill::before { + content: "\f29a"; +} + +.bi-cloud-download::before { + content: "\f29b"; +} + +.bi-cloud-drizzle-fill::before { + content: "\f29c"; +} + +.bi-cloud-drizzle::before { + content: "\f29d"; +} + +.bi-cloud-fill::before { + content: "\f29e"; +} + +.bi-cloud-fog-fill::before { + content: "\f29f"; +} + +.bi-cloud-fog::before { + content: "\f2a0"; +} + +.bi-cloud-fog2-fill::before { + content: "\f2a1"; +} + +.bi-cloud-fog2::before { + content: "\f2a2"; +} + +.bi-cloud-hail-fill::before { + content: "\f2a3"; +} + +.bi-cloud-hail::before { + content: "\f2a4"; +} + +.bi-cloud-haze-fill::before { + content: "\f2a6"; +} + +.bi-cloud-haze::before { + content: "\f2a7"; +} + +.bi-cloud-haze2-fill::before { + content: "\f2a8"; +} + +.bi-cloud-lightning-fill::before { + content: "\f2a9"; +} + +.bi-cloud-lightning-rain-fill::before { + content: "\f2aa"; +} + +.bi-cloud-lightning-rain::before { + content: "\f2ab"; +} + +.bi-cloud-lightning::before { + content: "\f2ac"; +} + +.bi-cloud-minus-fill::before { + content: "\f2ad"; +} + +.bi-cloud-minus::before { + content: "\f2ae"; +} + +.bi-cloud-moon-fill::before { + content: "\f2af"; +} + +.bi-cloud-moon::before { + content: "\f2b0"; +} + +.bi-cloud-plus-fill::before { + content: "\f2b1"; +} + +.bi-cloud-plus::before { + content: "\f2b2"; +} + +.bi-cloud-rain-fill::before { + content: "\f2b3"; +} + +.bi-cloud-rain-heavy-fill::before { + content: "\f2b4"; +} + +.bi-cloud-rain-heavy::before { + content: "\f2b5"; +} + +.bi-cloud-rain::before { + content: "\f2b6"; +} + +.bi-cloud-slash-fill::before { + content: "\f2b7"; +} + +.bi-cloud-slash::before { + content: "\f2b8"; +} + +.bi-cloud-sleet-fill::before { + content: "\f2b9"; +} + +.bi-cloud-sleet::before { + content: "\f2ba"; +} + +.bi-cloud-snow-fill::before { + content: "\f2bb"; +} + +.bi-cloud-snow::before { + content: "\f2bc"; +} + +.bi-cloud-sun-fill::before { + content: "\f2bd"; +} + +.bi-cloud-sun::before { + content: "\f2be"; +} + +.bi-cloud-upload-fill::before { + content: "\f2bf"; +} + +.bi-cloud-upload::before { + content: "\f2c0"; +} + +.bi-cloud::before { + content: "\f2c1"; +} + +.bi-clouds-fill::before { + content: "\f2c2"; +} + +.bi-clouds::before { + content: "\f2c3"; +} + +.bi-cloudy-fill::before { + content: "\f2c4"; +} + +.bi-cloudy::before { + content: "\f2c5"; +} + +.bi-code-slash::before { + content: "\f2c6"; +} + +.bi-code-square::before { + content: "\f2c7"; +} + +.bi-code::before { + content: "\f2c8"; +} + +.bi-collection-fill::before { + content: "\f2c9"; +} + +.bi-collection-play-fill::before { + content: "\f2ca"; +} + +.bi-collection-play::before { + content: "\f2cb"; +} + +.bi-collection::before { + content: "\f2cc"; +} + +.bi-columns-gap::before { + content: "\f2cd"; +} + +.bi-columns::before { + content: "\f2ce"; +} + +.bi-command::before { + content: "\f2cf"; +} + +.bi-compass-fill::before { + content: "\f2d0"; +} + +.bi-compass::before { + content: "\f2d1"; +} + +.bi-cone-striped::before { + content: "\f2d2"; +} + +.bi-cone::before { + content: "\f2d3"; +} + +.bi-controller::before { + content: "\f2d4"; +} + +.bi-cpu-fill::before { + content: "\f2d5"; +} + +.bi-cpu::before { + content: "\f2d6"; +} + +.bi-credit-card-2-back-fill::before { + content: "\f2d7"; +} + +.bi-credit-card-2-back::before { + content: "\f2d8"; +} + +.bi-credit-card-2-front-fill::before { + content: "\f2d9"; +} + +.bi-credit-card-2-front::before { + content: "\f2da"; +} + +.bi-credit-card-fill::before { + content: "\f2db"; +} + +.bi-credit-card::before { + content: "\f2dc"; +} + +.bi-crop::before { + content: "\f2dd"; +} + +.bi-cup-fill::before { + content: "\f2de"; +} + +.bi-cup-straw::before { + content: "\f2df"; +} + +.bi-cup::before { + content: "\f2e0"; +} + +.bi-cursor-fill::before { + content: "\f2e1"; +} + +.bi-cursor-text::before { + content: "\f2e2"; +} + +.bi-cursor::before { + content: "\f2e3"; +} + +.bi-dash-circle-dotted::before { + content: "\f2e4"; +} + +.bi-dash-circle-fill::before { + content: "\f2e5"; +} + +.bi-dash-circle::before { + content: "\f2e6"; +} + +.bi-dash-square-dotted::before { + content: "\f2e7"; +} + +.bi-dash-square-fill::before { + content: "\f2e8"; +} + +.bi-dash-square::before { + content: "\f2e9"; +} + +.bi-dash::before { + content: "\f2ea"; +} + +.bi-diagram-2-fill::before { + content: "\f2eb"; +} + +.bi-diagram-2::before { + content: "\f2ec"; +} + +.bi-diagram-3-fill::before { + content: "\f2ed"; +} + +.bi-diagram-3::before { + content: "\f2ee"; +} + +.bi-diamond-fill::before { + content: "\f2ef"; +} + +.bi-diamond-half::before { + content: "\f2f0"; +} + +.bi-diamond::before { + content: "\f2f1"; +} + +.bi-dice-1-fill::before { + content: "\f2f2"; +} + +.bi-dice-1::before { + content: "\f2f3"; +} + +.bi-dice-2-fill::before { + content: "\f2f4"; +} + +.bi-dice-2::before { + content: "\f2f5"; +} + +.bi-dice-3-fill::before { + content: "\f2f6"; +} + +.bi-dice-3::before { + content: "\f2f7"; +} + +.bi-dice-4-fill::before { + content: "\f2f8"; +} + +.bi-dice-4::before { + content: "\f2f9"; +} + +.bi-dice-5-fill::before { + content: "\f2fa"; +} + +.bi-dice-5::before { + content: "\f2fb"; +} + +.bi-dice-6-fill::before { + content: "\f2fc"; +} + +.bi-dice-6::before { + content: "\f2fd"; +} + +.bi-disc-fill::before { + content: "\f2fe"; +} + +.bi-disc::before { + content: "\f2ff"; +} + +.bi-discord::before { + content: "\f300"; +} + +.bi-display-fill::before { + content: "\f301"; +} + +.bi-display::before { + content: "\f302"; +} + +.bi-distribute-horizontal::before { + content: "\f303"; +} + +.bi-distribute-vertical::before { + content: "\f304"; +} + +.bi-door-closed-fill::before { + content: "\f305"; +} + +.bi-door-closed::before { + content: "\f306"; +} + +.bi-door-open-fill::before { + content: "\f307"; +} + +.bi-door-open::before { + content: "\f308"; +} + +.bi-dot::before { + content: "\f309"; +} + +.bi-download::before { + content: "\f30a"; +} + +.bi-droplet-fill::before { + content: "\f30b"; +} + +.bi-droplet-half::before { + content: "\f30c"; +} + +.bi-droplet::before { + content: "\f30d"; +} + +.bi-earbuds::before { + content: "\f30e"; +} + +.bi-easel-fill::before { + content: "\f30f"; +} + +.bi-easel::before { + content: "\f310"; +} + +.bi-egg-fill::before { + content: "\f311"; +} + +.bi-egg-fried::before { + content: "\f312"; +} + +.bi-egg::before { + content: "\f313"; +} + +.bi-eject-fill::before { + content: "\f314"; +} + +.bi-eject::before { + content: "\f315"; +} + +.bi-emoji-angry-fill::before { + content: "\f316"; +} + +.bi-emoji-angry::before { + content: "\f317"; +} + +.bi-emoji-dizzy-fill::before { + content: "\f318"; +} + +.bi-emoji-dizzy::before { + content: "\f319"; +} + +.bi-emoji-expressionless-fill::before { + content: "\f31a"; +} + +.bi-emoji-expressionless::before { + content: "\f31b"; +} + +.bi-emoji-frown-fill::before { + content: "\f31c"; +} + +.bi-emoji-frown::before { + content: "\f31d"; +} + +.bi-emoji-heart-eyes-fill::before { + content: "\f31e"; +} + +.bi-emoji-heart-eyes::before { + content: "\f31f"; +} + +.bi-emoji-laughing-fill::before { + content: "\f320"; +} + +.bi-emoji-laughing::before { + content: "\f321"; +} + +.bi-emoji-neutral-fill::before { + content: "\f322"; +} + +.bi-emoji-neutral::before { + content: "\f323"; +} + +.bi-emoji-smile-fill::before { + content: "\f324"; +} + +.bi-emoji-smile-upside-down-fill::before { + content: "\f325"; +} + +.bi-emoji-smile-upside-down::before { + content: "\f326"; +} + +.bi-emoji-smile::before { + content: "\f327"; +} + +.bi-emoji-sunglasses-fill::before { + content: "\f328"; +} + +.bi-emoji-sunglasses::before { + content: "\f329"; +} + +.bi-emoji-wink-fill::before { + content: "\f32a"; +} + +.bi-emoji-wink::before { + content: "\f32b"; +} + +.bi-envelope-fill::before { + content: "\f32c"; +} + +.bi-envelope-open-fill::before { + content: "\f32d"; +} + +.bi-envelope-open::before { + content: "\f32e"; +} + +.bi-envelope::before { + content: "\f32f"; +} + +.bi-eraser-fill::before { + content: "\f330"; +} + +.bi-eraser::before { + content: "\f331"; +} + +.bi-exclamation-circle-fill::before { + content: "\f332"; +} + +.bi-exclamation-circle::before { + content: "\f333"; +} + +.bi-exclamation-diamond-fill::before { + content: "\f334"; +} + +.bi-exclamation-diamond::before { + content: "\f335"; +} + +.bi-exclamation-octagon-fill::before { + content: "\f336"; +} + +.bi-exclamation-octagon::before { + content: "\f337"; +} + +.bi-exclamation-square-fill::before { + content: "\f338"; +} + +.bi-exclamation-square::before { + content: "\f339"; +} + +.bi-exclamation-triangle-fill::before { + content: "\f33a"; +} + +.bi-exclamation-triangle::before { + content: "\f33b"; +} + +.bi-exclamation::before { + content: "\f33c"; +} + +.bi-exclude::before { + content: "\f33d"; +} + +.bi-eye-fill::before { + content: "\f33e"; +} + +.bi-eye-slash-fill::before { + content: "\f33f"; +} + +.bi-eye-slash::before { + content: "\f340"; +} + +.bi-eye::before { + content: "\f341"; +} + +.bi-eyedropper::before { + content: "\f342"; +} + +.bi-eyeglasses::before { + content: "\f343"; +} + +.bi-facebook::before { + content: "\f344"; +} + +.bi-file-arrow-down-fill::before { + content: "\f345"; +} + +.bi-file-arrow-down::before { + content: "\f346"; +} + +.bi-file-arrow-up-fill::before { + content: "\f347"; +} + +.bi-file-arrow-up::before { + content: "\f348"; +} + +.bi-file-bar-graph-fill::before { + content: "\f349"; +} + +.bi-file-bar-graph::before { + content: "\f34a"; +} + +.bi-file-binary-fill::before { + content: "\f34b"; +} + +.bi-file-binary::before { + content: "\f34c"; +} + +.bi-file-break-fill::before { + content: "\f34d"; +} + +.bi-file-break::before { + content: "\f34e"; +} + +.bi-file-check-fill::before { + content: "\f34f"; +} + +.bi-file-check::before { + content: "\f350"; +} + +.bi-file-code-fill::before { + content: "\f351"; +} + +.bi-file-code::before { + content: "\f352"; +} + +.bi-file-diff-fill::before { + content: "\f353"; +} + +.bi-file-diff::before { + content: "\f354"; +} + +.bi-file-earmark-arrow-down-fill::before { + content: "\f355"; +} + +.bi-file-earmark-arrow-down::before { + content: "\f356"; +} + +.bi-file-earmark-arrow-up-fill::before { + content: "\f357"; +} + +.bi-file-earmark-arrow-up::before { + content: "\f358"; +} + +.bi-file-earmark-bar-graph-fill::before { + content: "\f359"; +} + +.bi-file-earmark-bar-graph::before { + content: "\f35a"; +} + +.bi-file-earmark-binary-fill::before { + content: "\f35b"; +} + +.bi-file-earmark-binary::before { + content: "\f35c"; +} + +.bi-file-earmark-break-fill::before { + content: "\f35d"; +} + +.bi-file-earmark-break::before { + content: "\f35e"; +} + +.bi-file-earmark-check-fill::before { + content: "\f35f"; +} + +.bi-file-earmark-check::before { + content: "\f360"; +} + +.bi-file-earmark-code-fill::before { + content: "\f361"; +} + +.bi-file-earmark-code::before { + content: "\f362"; +} + +.bi-file-earmark-diff-fill::before { + content: "\f363"; +} + +.bi-file-earmark-diff::before { + content: "\f364"; +} + +.bi-file-earmark-easel-fill::before { + content: "\f365"; +} + +.bi-file-earmark-easel::before { + content: "\f366"; +} + +.bi-file-earmark-excel-fill::before { + content: "\f367"; +} + +.bi-file-earmark-excel::before { + content: "\f368"; +} + +.bi-file-earmark-fill::before { + content: "\f369"; +} + +.bi-file-earmark-font-fill::before { + content: "\f36a"; +} + +.bi-file-earmark-font::before { + content: "\f36b"; +} + +.bi-file-earmark-image-fill::before { + content: "\f36c"; +} + +.bi-file-earmark-image::before { + content: "\f36d"; +} + +.bi-file-earmark-lock-fill::before { + content: "\f36e"; +} + +.bi-file-earmark-lock::before { + content: "\f36f"; +} + +.bi-file-earmark-lock2-fill::before { + content: "\f370"; +} + +.bi-file-earmark-lock2::before { + content: "\f371"; +} + +.bi-file-earmark-medical-fill::before { + content: "\f372"; +} + +.bi-file-earmark-medical::before { + content: "\f373"; +} + +.bi-file-earmark-minus-fill::before { + content: "\f374"; +} + +.bi-file-earmark-minus::before { + content: "\f375"; +} + +.bi-file-earmark-music-fill::before { + content: "\f376"; +} + +.bi-file-earmark-music::before { + content: "\f377"; +} + +.bi-file-earmark-person-fill::before { + content: "\f378"; +} + +.bi-file-earmark-person::before { + content: "\f379"; +} + +.bi-file-earmark-play-fill::before { + content: "\f37a"; +} + +.bi-file-earmark-play::before { + content: "\f37b"; +} + +.bi-file-earmark-plus-fill::before { + content: "\f37c"; +} + +.bi-file-earmark-plus::before { + content: "\f37d"; +} + +.bi-file-earmark-post-fill::before { + content: "\f37e"; +} + +.bi-file-earmark-post::before { + content: "\f37f"; +} + +.bi-file-earmark-ppt-fill::before { + content: "\f380"; +} + +.bi-file-earmark-ppt::before { + content: "\f381"; +} + +.bi-file-earmark-richtext-fill::before { + content: "\f382"; +} + +.bi-file-earmark-richtext::before { + content: "\f383"; +} + +.bi-file-earmark-ruled-fill::before { + content: "\f384"; +} + +.bi-file-earmark-ruled::before { + content: "\f385"; +} + +.bi-file-earmark-slides-fill::before { + content: "\f386"; +} + +.bi-file-earmark-slides::before { + content: "\f387"; +} + +.bi-file-earmark-spreadsheet-fill::before { + content: "\f388"; +} + +.bi-file-earmark-spreadsheet::before { + content: "\f389"; +} + +.bi-file-earmark-text-fill::before { + content: "\f38a"; +} + +.bi-file-earmark-text::before { + content: "\f38b"; +} + +.bi-file-earmark-word-fill::before { + content: "\f38c"; +} + +.bi-file-earmark-word::before { + content: "\f38d"; +} + +.bi-file-earmark-x-fill::before { + content: "\f38e"; +} + +.bi-file-earmark-x::before { + content: "\f38f"; +} + +.bi-file-earmark-zip-fill::before { + content: "\f390"; +} + +.bi-file-earmark-zip::before { + content: "\f391"; +} + +.bi-file-earmark::before { + content: "\f392"; +} + +.bi-file-easel-fill::before { + content: "\f393"; +} + +.bi-file-easel::before { + content: "\f394"; +} + +.bi-file-excel-fill::before { + content: "\f395"; +} + +.bi-file-excel::before { + content: "\f396"; +} + +.bi-file-fill::before { + content: "\f397"; +} + +.bi-file-font-fill::before { + content: "\f398"; +} + +.bi-file-font::before { + content: "\f399"; +} + +.bi-file-image-fill::before { + content: "\f39a"; +} + +.bi-file-image::before { + content: "\f39b"; +} + +.bi-file-lock-fill::before { + content: "\f39c"; +} + +.bi-file-lock::before { + content: "\f39d"; +} + +.bi-file-lock2-fill::before { + content: "\f39e"; +} + +.bi-file-lock2::before { + content: "\f39f"; +} + +.bi-file-medical-fill::before { + content: "\f3a0"; +} + +.bi-file-medical::before { + content: "\f3a1"; +} + +.bi-file-minus-fill::before { + content: "\f3a2"; +} + +.bi-file-minus::before { + content: "\f3a3"; +} + +.bi-file-music-fill::before { + content: "\f3a4"; +} + +.bi-file-music::before { + content: "\f3a5"; +} + +.bi-file-person-fill::before { + content: "\f3a6"; +} + +.bi-file-person::before { + content: "\f3a7"; +} + +.bi-file-play-fill::before { + content: "\f3a8"; +} + +.bi-file-play::before { + content: "\f3a9"; +} + +.bi-file-plus-fill::before { + content: "\f3aa"; +} + +.bi-file-plus::before { + content: "\f3ab"; +} + +.bi-file-post-fill::before { + content: "\f3ac"; +} + +.bi-file-post::before { + content: "\f3ad"; +} + +.bi-file-ppt-fill::before { + content: "\f3ae"; +} + +.bi-file-ppt::before { + content: "\f3af"; +} + +.bi-file-richtext-fill::before { + content: "\f3b0"; +} + +.bi-file-richtext::before { + content: "\f3b1"; +} + +.bi-file-ruled-fill::before { + content: "\f3b2"; +} + +.bi-file-ruled::before { + content: "\f3b3"; +} + +.bi-file-slides-fill::before { + content: "\f3b4"; +} + +.bi-file-slides::before { + content: "\f3b5"; +} + +.bi-file-spreadsheet-fill::before { + content: "\f3b6"; +} + +.bi-file-spreadsheet::before { + content: "\f3b7"; +} + +.bi-file-text-fill::before { + content: "\f3b8"; +} + +.bi-file-text::before { + content: "\f3b9"; +} + +.bi-file-word-fill::before { + content: "\f3ba"; +} + +.bi-file-word::before { + content: "\f3bb"; +} + +.bi-file-x-fill::before { + content: "\f3bc"; +} + +.bi-file-x::before { + content: "\f3bd"; +} + +.bi-file-zip-fill::before { + content: "\f3be"; +} + +.bi-file-zip::before { + content: "\f3bf"; +} + +.bi-file::before { + content: "\f3c0"; +} + +.bi-files-alt::before { + content: "\f3c1"; +} + +.bi-files::before { + content: "\f3c2"; +} + +.bi-film::before { + content: "\f3c3"; +} + +.bi-filter-circle-fill::before { + content: "\f3c4"; +} + +.bi-filter-circle::before { + content: "\f3c5"; +} + +.bi-filter-left::before { + content: "\f3c6"; +} + +.bi-filter-right::before { + content: "\f3c7"; +} + +.bi-filter-square-fill::before { + content: "\f3c8"; +} + +.bi-filter-square::before { + content: "\f3c9"; +} + +.bi-filter::before { + content: "\f3ca"; +} + +.bi-flag-fill::before { + content: "\f3cb"; +} + +.bi-flag::before { + content: "\f3cc"; +} + +.bi-flower1::before { + content: "\f3cd"; +} + +.bi-flower2::before { + content: "\f3ce"; +} + +.bi-flower3::before { + content: "\f3cf"; +} + +.bi-folder-check::before { + content: "\f3d0"; +} + +.bi-folder-fill::before { + content: "\f3d1"; +} + +.bi-folder-minus::before { + content: "\f3d2"; +} + +.bi-folder-plus::before { + content: "\f3d3"; +} + +.bi-folder-symlink-fill::before { + content: "\f3d4"; +} + +.bi-folder-symlink::before { + content: "\f3d5"; +} + +.bi-folder-x::before { + content: "\f3d6"; +} + +.bi-folder::before { + content: "\f3d7"; +} + +.bi-folder2-open::before { + content: "\f3d8"; +} + +.bi-folder2::before { + content: "\f3d9"; +} + +.bi-fonts::before { + content: "\f3da"; +} + +.bi-forward-fill::before { + content: "\f3db"; +} + +.bi-forward::before { + content: "\f3dc"; +} + +.bi-front::before { + content: "\f3dd"; +} + +.bi-fullscreen-exit::before { + content: "\f3de"; +} + +.bi-fullscreen::before { + content: "\f3df"; +} + +.bi-funnel-fill::before { + content: "\f3e0"; +} + +.bi-funnel::before { + content: "\f3e1"; +} + +.bi-gear-fill::before { + content: "\f3e2"; +} + +.bi-gear-wide-connected::before { + content: "\f3e3"; +} + +.bi-gear-wide::before { + content: "\f3e4"; +} + +.bi-gear::before { + content: "\f3e5"; +} + +.bi-gem::before { + content: "\f3e6"; +} + +.bi-geo-alt-fill::before { + content: "\f3e7"; +} + +.bi-geo-alt::before { + content: "\f3e8"; +} + +.bi-geo-fill::before { + content: "\f3e9"; +} + +.bi-geo::before { + content: "\f3ea"; +} + +.bi-gift-fill::before { + content: "\f3eb"; +} + +.bi-gift::before { + content: "\f3ec"; +} + +.bi-github::before { + content: "\f3ed"; +} + +.bi-globe::before { + content: "\f3ee"; +} + +.bi-globe2::before { + content: "\f3ef"; +} + +.bi-google::before { + content: "\f3f0"; +} + +.bi-graph-down::before { + content: "\f3f1"; +} + +.bi-graph-up::before { + content: "\f3f2"; +} + +.bi-grid-1x2-fill::before { + content: "\f3f3"; +} + +.bi-grid-1x2::before { + content: "\f3f4"; +} + +.bi-grid-3x2-gap-fill::before { + content: "\f3f5"; +} + +.bi-grid-3x2-gap::before { + content: "\f3f6"; +} + +.bi-grid-3x2::before { + content: "\f3f7"; +} + +.bi-grid-3x3-gap-fill::before { + content: "\f3f8"; +} + +.bi-grid-3x3-gap::before { + content: "\f3f9"; +} + +.bi-grid-3x3::before { + content: "\f3fa"; +} + +.bi-grid-fill::before { + content: "\f3fb"; +} + +.bi-grid::before { + content: "\f3fc"; +} + +.bi-grip-horizontal::before { + content: "\f3fd"; +} + +.bi-grip-vertical::before { + content: "\f3fe"; +} + +.bi-hammer::before { + content: "\f3ff"; +} + +.bi-hand-index-fill::before { + content: "\f400"; +} + +.bi-hand-index-thumb-fill::before { + content: "\f401"; +} + +.bi-hand-index-thumb::before { + content: "\f402"; +} + +.bi-hand-index::before { + content: "\f403"; +} + +.bi-hand-thumbs-down-fill::before { + content: "\f404"; +} + +.bi-hand-thumbs-down::before { + content: "\f405"; +} + +.bi-hand-thumbs-up-fill::before { + content: "\f406"; +} + +.bi-hand-thumbs-up::before { + content: "\f407"; +} + +.bi-handbag-fill::before { + content: "\f408"; +} + +.bi-handbag::before { + content: "\f409"; +} + +.bi-hash::before { + content: "\f40a"; +} + +.bi-hdd-fill::before { + content: "\f40b"; +} + +.bi-hdd-network-fill::before { + content: "\f40c"; +} + +.bi-hdd-network::before { + content: "\f40d"; +} + +.bi-hdd-rack-fill::before { + content: "\f40e"; +} + +.bi-hdd-rack::before { + content: "\f40f"; +} + +.bi-hdd-stack-fill::before { + content: "\f410"; +} + +.bi-hdd-stack::before { + content: "\f411"; +} + +.bi-hdd::before { + content: "\f412"; +} + +.bi-headphones::before { + content: "\f413"; +} + +.bi-headset::before { + content: "\f414"; +} + +.bi-heart-fill::before { + content: "\f415"; +} + +.bi-heart-half::before { + content: "\f416"; +} + +.bi-heart::before { + content: "\f417"; +} + +.bi-heptagon-fill::before { + content: "\f418"; +} + +.bi-heptagon-half::before { + content: "\f419"; +} + +.bi-heptagon::before { + content: "\f41a"; +} + +.bi-hexagon-fill::before { + content: "\f41b"; +} + +.bi-hexagon-half::before { + content: "\f41c"; +} + +.bi-hexagon::before { + content: "\f41d"; +} + +.bi-hourglass-bottom::before { + content: "\f41e"; +} + +.bi-hourglass-split::before { + content: "\f41f"; +} + +.bi-hourglass-top::before { + content: "\f420"; +} + +.bi-hourglass::before { + content: "\f421"; +} + +.bi-house-door-fill::before { + content: "\f422"; +} + +.bi-house-door::before { + content: "\f423"; +} + +.bi-house-fill::before { + content: "\f424"; +} + +.bi-house::before { + content: "\f425"; +} + +.bi-hr::before { + content: "\f426"; +} + +.bi-hurricane::before { + content: "\f427"; +} + +.bi-image-alt::before { + content: "\f428"; +} + +.bi-image-fill::before { + content: "\f429"; +} + +.bi-image::before { + content: "\f42a"; +} + +.bi-images::before { + content: "\f42b"; +} + +.bi-inbox-fill::before { + content: "\f42c"; +} + +.bi-inbox::before { + content: "\f42d"; +} + +.bi-inboxes-fill::before { + content: "\f42e"; +} + +.bi-inboxes::before { + content: "\f42f"; +} + +.bi-info-circle-fill::before { + content: "\f430"; +} + +.bi-info-circle::before { + content: "\f431"; +} + +.bi-info-square-fill::before { + content: "\f432"; +} + +.bi-info-square::before { + content: "\f433"; +} + +.bi-info::before { + content: "\f434"; +} + +.bi-input-cursor-text::before { + content: "\f435"; +} + +.bi-input-cursor::before { + content: "\f436"; +} + +.bi-instagram::before { + content: "\f437"; +} + +.bi-intersect::before { + content: "\f438"; +} + +.bi-journal-album::before { + content: "\f439"; +} + +.bi-journal-arrow-down::before { + content: "\f43a"; +} + +.bi-journal-arrow-up::before { + content: "\f43b"; +} + +.bi-journal-bookmark-fill::before { + content: "\f43c"; +} + +.bi-journal-bookmark::before { + content: "\f43d"; +} + +.bi-journal-check::before { + content: "\f43e"; +} + +.bi-journal-code::before { + content: "\f43f"; +} + +.bi-journal-medical::before { + content: "\f440"; +} + +.bi-journal-minus::before { + content: "\f441"; +} + +.bi-journal-plus::before { + content: "\f442"; +} + +.bi-journal-richtext::before { + content: "\f443"; +} + +.bi-journal-text::before { + content: "\f444"; +} + +.bi-journal-x::before { + content: "\f445"; +} + +.bi-journal::before { + content: "\f446"; +} + +.bi-journals::before { + content: "\f447"; +} + +.bi-joystick::before { + content: "\f448"; +} + +.bi-justify-left::before { + content: "\f449"; +} + +.bi-justify-right::before { + content: "\f44a"; +} + +.bi-justify::before { + content: "\f44b"; +} + +.bi-kanban-fill::before { + content: "\f44c"; +} + +.bi-kanban::before { + content: "\f44d"; +} + +.bi-key-fill::before { + content: "\f44e"; +} + +.bi-key::before { + content: "\f44f"; +} + +.bi-keyboard-fill::before { + content: "\f450"; +} + +.bi-keyboard::before { + content: "\f451"; +} + +.bi-ladder::before { + content: "\f452"; +} + +.bi-lamp-fill::before { + content: "\f453"; +} + +.bi-lamp::before { + content: "\f454"; +} + +.bi-laptop-fill::before { + content: "\f455"; +} + +.bi-laptop::before { + content: "\f456"; +} + +.bi-layer-backward::before { + content: "\f457"; +} + +.bi-layer-forward::before { + content: "\f458"; +} + +.bi-layers-fill::before { + content: "\f459"; +} + +.bi-layers-half::before { + content: "\f45a"; +} + +.bi-layers::before { + content: "\f45b"; +} + +.bi-layout-sidebar-inset-reverse::before { + content: "\f45c"; +} + +.bi-layout-sidebar-inset::before { + content: "\f45d"; +} + +.bi-layout-sidebar-reverse::before { + content: "\f45e"; +} + +.bi-layout-sidebar::before { + content: "\f45f"; +} + +.bi-layout-split::before { + content: "\f460"; +} + +.bi-layout-text-sidebar-reverse::before { + content: "\f461"; +} + +.bi-layout-text-sidebar::before { + content: "\f462"; +} + +.bi-layout-text-window-reverse::before { + content: "\f463"; +} + +.bi-layout-text-window::before { + content: "\f464"; +} + +.bi-layout-three-columns::before { + content: "\f465"; +} + +.bi-layout-wtf::before { + content: "\f466"; +} + +.bi-life-preserver::before { + content: "\f467"; +} + +.bi-lightbulb-fill::before { + content: "\f468"; +} + +.bi-lightbulb-off-fill::before { + content: "\f469"; +} + +.bi-lightbulb-off::before { + content: "\f46a"; +} + +.bi-lightbulb::before { + content: "\f46b"; +} + +.bi-lightning-charge-fill::before { + content: "\f46c"; +} + +.bi-lightning-charge::before { + content: "\f46d"; +} + +.bi-lightning-fill::before { + content: "\f46e"; +} + +.bi-lightning::before { + content: "\f46f"; +} + +.bi-link-45deg::before { + content: "\f470"; +} + +.bi-link::before { + content: "\f471"; +} + +.bi-linkedin::before { + content: "\f472"; +} + +.bi-list-check::before { + content: "\f473"; +} + +.bi-list-nested::before { + content: "\f474"; +} + +.bi-list-ol::before { + content: "\f475"; +} + +.bi-list-stars::before { + content: "\f476"; +} + +.bi-list-task::before { + content: "\f477"; +} + +.bi-list-ul::before { + content: "\f478"; +} + +.bi-list::before { + content: "\f479"; +} + +.bi-lock-fill::before { + content: "\f47a"; +} + +.bi-lock::before { + content: "\f47b"; +} + +.bi-mailbox::before { + content: "\f47c"; +} + +.bi-mailbox2::before { + content: "\f47d"; +} + +.bi-map-fill::before { + content: "\f47e"; +} + +.bi-map::before { + content: "\f47f"; +} + +.bi-markdown-fill::before { + content: "\f480"; +} + +.bi-markdown::before { + content: "\f481"; +} + +.bi-mask::before { + content: "\f482"; +} + +.bi-megaphone-fill::before { + content: "\f483"; +} + +.bi-megaphone::before { + content: "\f484"; +} + +.bi-menu-app-fill::before { + content: "\f485"; +} + +.bi-menu-app::before { + content: "\f486"; +} + +.bi-menu-button-fill::before { + content: "\f487"; +} + +.bi-menu-button-wide-fill::before { + content: "\f488"; +} + +.bi-menu-button-wide::before { + content: "\f489"; +} + +.bi-menu-button::before { + content: "\f48a"; +} + +.bi-menu-down::before { + content: "\f48b"; +} + +.bi-menu-up::before { + content: "\f48c"; +} + +.bi-mic-fill::before { + content: "\f48d"; +} + +.bi-mic-mute-fill::before { + content: "\f48e"; +} + +.bi-mic-mute::before { + content: "\f48f"; +} + +.bi-mic::before { + content: "\f490"; +} + +.bi-minecart-loaded::before { + content: "\f491"; +} + +.bi-minecart::before { + content: "\f492"; +} + +.bi-moisture::before { + content: "\f493"; +} + +.bi-moon-fill::before { + content: "\f494"; +} + +.bi-moon-stars-fill::before { + content: "\f495"; +} + +.bi-moon-stars::before { + content: "\f496"; +} + +.bi-moon::before { + content: "\f497"; +} + +.bi-mouse-fill::before { + content: "\f498"; +} + +.bi-mouse::before { + content: "\f499"; +} + +.bi-mouse2-fill::before { + content: "\f49a"; +} + +.bi-mouse2::before { + content: "\f49b"; +} + +.bi-mouse3-fill::before { + content: "\f49c"; +} + +.bi-mouse3::before { + content: "\f49d"; +} + +.bi-music-note-beamed::before { + content: "\f49e"; +} + +.bi-music-note-list::before { + content: "\f49f"; +} + +.bi-music-note::before { + content: "\f4a0"; +} + +.bi-music-player-fill::before { + content: "\f4a1"; +} + +.bi-music-player::before { + content: "\f4a2"; +} + +.bi-newspaper::before { + content: "\f4a3"; +} + +.bi-node-minus-fill::before { + content: "\f4a4"; +} + +.bi-node-minus::before { + content: "\f4a5"; +} + +.bi-node-plus-fill::before { + content: "\f4a6"; +} + +.bi-node-plus::before { + content: "\f4a7"; +} + +.bi-nut-fill::before { + content: "\f4a8"; +} + +.bi-nut::before { + content: "\f4a9"; +} + +.bi-octagon-fill::before { + content: "\f4aa"; +} + +.bi-octagon-half::before { + content: "\f4ab"; +} + +.bi-octagon::before { + content: "\f4ac"; +} + +.bi-option::before { + content: "\f4ad"; +} + +.bi-outlet::before { + content: "\f4ae"; +} + +.bi-paint-bucket::before { + content: "\f4af"; +} + +.bi-palette-fill::before { + content: "\f4b0"; +} + +.bi-palette::before { + content: "\f4b1"; +} + +.bi-palette2::before { + content: "\f4b2"; +} + +.bi-paperclip::before { + content: "\f4b3"; +} + +.bi-paragraph::before { + content: "\f4b4"; +} + +.bi-patch-check-fill::before { + content: "\f4b5"; +} + +.bi-patch-check::before { + content: "\f4b6"; +} + +.bi-patch-exclamation-fill::before { + content: "\f4b7"; +} + +.bi-patch-exclamation::before { + content: "\f4b8"; +} + +.bi-patch-minus-fill::before { + content: "\f4b9"; +} + +.bi-patch-minus::before { + content: "\f4ba"; +} + +.bi-patch-plus-fill::before { + content: "\f4bb"; +} + +.bi-patch-plus::before { + content: "\f4bc"; +} + +.bi-patch-question-fill::before { + content: "\f4bd"; +} + +.bi-patch-question::before { + content: "\f4be"; +} + +.bi-pause-btn-fill::before { + content: "\f4bf"; +} + +.bi-pause-btn::before { + content: "\f4c0"; +} + +.bi-pause-circle-fill::before { + content: "\f4c1"; +} + +.bi-pause-circle::before { + content: "\f4c2"; +} + +.bi-pause-fill::before { + content: "\f4c3"; +} + +.bi-pause::before { + content: "\f4c4"; +} + +.bi-peace-fill::before { + content: "\f4c5"; +} + +.bi-peace::before { + content: "\f4c6"; +} + +.bi-pen-fill::before { + content: "\f4c7"; +} + +.bi-pen::before { + content: "\f4c8"; +} + +.bi-pencil-fill::before { + content: "\f4c9"; +} + +.bi-pencil-square::before { + content: "\f4ca"; +} + +.bi-pencil::before { + content: "\f4cb"; +} + +.bi-pentagon-fill::before { + content: "\f4cc"; +} + +.bi-pentagon-half::before { + content: "\f4cd"; +} + +.bi-pentagon::before { + content: "\f4ce"; +} + +.bi-people-fill::before { + content: "\f4cf"; +} + +.bi-people::before { + content: "\f4d0"; +} + +.bi-percent::before { + content: "\f4d1"; +} + +.bi-person-badge-fill::before { + content: "\f4d2"; +} + +.bi-person-badge::before { + content: "\f4d3"; +} + +.bi-person-bounding-box::before { + content: "\f4d4"; +} + +.bi-person-check-fill::before { + content: "\f4d5"; +} + +.bi-person-check::before { + content: "\f4d6"; +} + +.bi-person-circle::before { + content: "\f4d7"; +} + +.bi-person-dash-fill::before { + content: "\f4d8"; +} + +.bi-person-dash::before { + content: "\f4d9"; +} + +.bi-person-fill::before { + content: "\f4da"; +} + +.bi-person-lines-fill::before { + content: "\f4db"; +} + +.bi-person-plus-fill::before { + content: "\f4dc"; +} + +.bi-person-plus::before { + content: "\f4dd"; +} + +.bi-person-square::before { + content: "\f4de"; +} + +.bi-person-x-fill::before { + content: "\f4df"; +} + +.bi-person-x::before { + content: "\f4e0"; +} + +.bi-person::before { + content: "\f4e1"; +} + +.bi-phone-fill::before { + content: "\f4e2"; +} + +.bi-phone-landscape-fill::before { + content: "\f4e3"; +} + +.bi-phone-landscape::before { + content: "\f4e4"; +} + +.bi-phone-vibrate-fill::before { + content: "\f4e5"; +} + +.bi-phone-vibrate::before { + content: "\f4e6"; +} + +.bi-phone::before { + content: "\f4e7"; +} + +.bi-pie-chart-fill::before { + content: "\f4e8"; +} + +.bi-pie-chart::before { + content: "\f4e9"; +} + +.bi-pin-angle-fill::before { + content: "\f4ea"; +} + +.bi-pin-angle::before { + content: "\f4eb"; +} + +.bi-pin-fill::before { + content: "\f4ec"; +} + +.bi-pin::before { + content: "\f4ed"; +} + +.bi-pip-fill::before { + content: "\f4ee"; +} + +.bi-pip::before { + content: "\f4ef"; +} + +.bi-play-btn-fill::before { + content: "\f4f0"; +} + +.bi-play-btn::before { + content: "\f4f1"; +} + +.bi-play-circle-fill::before { + content: "\f4f2"; +} + +.bi-play-circle::before { + content: "\f4f3"; +} + +.bi-play-fill::before { + content: "\f4f4"; +} + +.bi-play::before { + content: "\f4f5"; +} + +.bi-plug-fill::before { + content: "\f4f6"; +} + +.bi-plug::before { + content: "\f4f7"; +} + +.bi-plus-circle-dotted::before { + content: "\f4f8"; +} + +.bi-plus-circle-fill::before { + content: "\f4f9"; +} + +.bi-plus-circle::before { + content: "\f4fa"; +} + +.bi-plus-square-dotted::before { + content: "\f4fb"; +} + +.bi-plus-square-fill::before { + content: "\f4fc"; +} + +.bi-plus-square::before { + content: "\f4fd"; +} + +.bi-plus::before { + content: "\f4fe"; +} + +.bi-power::before { + content: "\f4ff"; +} + +.bi-printer-fill::before { + content: "\f500"; +} + +.bi-printer::before { + content: "\f501"; +} + +.bi-puzzle-fill::before { + content: "\f502"; +} + +.bi-puzzle::before { + content: "\f503"; +} + +.bi-question-circle-fill::before { + content: "\f504"; +} + +.bi-question-circle::before { + content: "\f505"; +} + +.bi-question-diamond-fill::before { + content: "\f506"; +} + +.bi-question-diamond::before { + content: "\f507"; +} + +.bi-question-octagon-fill::before { + content: "\f508"; +} + +.bi-question-octagon::before { + content: "\f509"; +} + +.bi-question-square-fill::before { + content: "\f50a"; +} + +.bi-question-square::before { + content: "\f50b"; +} + +.bi-question::before { + content: "\f50c"; +} + +.bi-rainbow::before { + content: "\f50d"; +} + +.bi-receipt-cutoff::before { + content: "\f50e"; +} + +.bi-receipt::before { + content: "\f50f"; +} + +.bi-reception-0::before { + content: "\f510"; +} + +.bi-reception-1::before { + content: "\f511"; +} + +.bi-reception-2::before { + content: "\f512"; +} + +.bi-reception-3::before { + content: "\f513"; +} + +.bi-reception-4::before { + content: "\f514"; +} + +.bi-record-btn-fill::before { + content: "\f515"; +} + +.bi-record-btn::before { + content: "\f516"; +} + +.bi-record-circle-fill::before { + content: "\f517"; +} + +.bi-record-circle::before { + content: "\f518"; +} + +.bi-record-fill::before { + content: "\f519"; +} + +.bi-record::before { + content: "\f51a"; +} + +.bi-record2-fill::before { + content: "\f51b"; +} + +.bi-record2::before { + content: "\f51c"; +} + +.bi-reply-all-fill::before { + content: "\f51d"; +} + +.bi-reply-all::before { + content: "\f51e"; +} + +.bi-reply-fill::before { + content: "\f51f"; +} + +.bi-reply::before { + content: "\f520"; +} + +.bi-rss-fill::before { + content: "\f521"; +} + +.bi-rss::before { + content: "\f522"; +} + +.bi-rulers::before { + content: "\f523"; +} + +.bi-save-fill::before { + content: "\f524"; +} + +.bi-save::before { + content: "\f525"; +} + +.bi-save2-fill::before { + content: "\f526"; +} + +.bi-save2::before { + content: "\f527"; +} + +.bi-scissors::before { + content: "\f528"; +} + +.bi-screwdriver::before { + content: "\f529"; +} + +.bi-search::before { + content: "\f52a"; +} + +.bi-segmented-nav::before { + content: "\f52b"; +} + +.bi-server::before { + content: "\f52c"; +} + +.bi-share-fill::before { + content: "\f52d"; +} + +.bi-share::before { + content: "\f52e"; +} + +.bi-shield-check::before { + content: "\f52f"; +} + +.bi-shield-exclamation::before { + content: "\f530"; +} + +.bi-shield-fill-check::before { + content: "\f531"; +} + +.bi-shield-fill-exclamation::before { + content: "\f532"; +} + +.bi-shield-fill-minus::before { + content: "\f533"; +} + +.bi-shield-fill-plus::before { + content: "\f534"; +} + +.bi-shield-fill-x::before { + content: "\f535"; +} + +.bi-shield-fill::before { + content: "\f536"; +} + +.bi-shield-lock-fill::before { + content: "\f537"; +} + +.bi-shield-lock::before { + content: "\f538"; +} + +.bi-shield-minus::before { + content: "\f539"; +} + +.bi-shield-plus::before { + content: "\f53a"; +} + +.bi-shield-shaded::before { + content: "\f53b"; +} + +.bi-shield-slash-fill::before { + content: "\f53c"; +} + +.bi-shield-slash::before { + content: "\f53d"; +} + +.bi-shield-x::before { + content: "\f53e"; +} + +.bi-shield::before { + content: "\f53f"; +} + +.bi-shift-fill::before { + content: "\f540"; +} + +.bi-shift::before { + content: "\f541"; +} + +.bi-shop-window::before { + content: "\f542"; +} + +.bi-shop::before { + content: "\f543"; +} + +.bi-shuffle::before { + content: "\f544"; +} + +.bi-signpost-2-fill::before { + content: "\f545"; +} + +.bi-signpost-2::before { + content: "\f546"; +} + +.bi-signpost-fill::before { + content: "\f547"; +} + +.bi-signpost-split-fill::before { + content: "\f548"; +} + +.bi-signpost-split::before { + content: "\f549"; +} + +.bi-signpost::before { + content: "\f54a"; +} + +.bi-sim-fill::before { + content: "\f54b"; +} + +.bi-sim::before { + content: "\f54c"; +} + +.bi-skip-backward-btn-fill::before { + content: "\f54d"; +} + +.bi-skip-backward-btn::before { + content: "\f54e"; +} + +.bi-skip-backward-circle-fill::before { + content: "\f54f"; +} + +.bi-skip-backward-circle::before { + content: "\f550"; +} + +.bi-skip-backward-fill::before { + content: "\f551"; +} + +.bi-skip-backward::before { + content: "\f552"; +} + +.bi-skip-end-btn-fill::before { + content: "\f553"; +} + +.bi-skip-end-btn::before { + content: "\f554"; +} + +.bi-skip-end-circle-fill::before { + content: "\f555"; +} + +.bi-skip-end-circle::before { + content: "\f556"; +} + +.bi-skip-end-fill::before { + content: "\f557"; +} + +.bi-skip-end::before { + content: "\f558"; +} + +.bi-skip-forward-btn-fill::before { + content: "\f559"; +} + +.bi-skip-forward-btn::before { + content: "\f55a"; +} + +.bi-skip-forward-circle-fill::before { + content: "\f55b"; +} + +.bi-skip-forward-circle::before { + content: "\f55c"; +} + +.bi-skip-forward-fill::before { + content: "\f55d"; +} + +.bi-skip-forward::before { + content: "\f55e"; +} + +.bi-skip-start-btn-fill::before { + content: "\f55f"; +} + +.bi-skip-start-btn::before { + content: "\f560"; +} + +.bi-skip-start-circle-fill::before { + content: "\f561"; +} + +.bi-skip-start-circle::before { + content: "\f562"; +} + +.bi-skip-start-fill::before { + content: "\f563"; +} + +.bi-skip-start::before { + content: "\f564"; +} + +.bi-slack::before { + content: "\f565"; +} + +.bi-slash-circle-fill::before { + content: "\f566"; +} + +.bi-slash-circle::before { + content: "\f567"; +} + +.bi-slash-square-fill::before { + content: "\f568"; +} + +.bi-slash-square::before { + content: "\f569"; +} + +.bi-slash::before { + content: "\f56a"; +} + +.bi-sliders::before { + content: "\f56b"; +} + +.bi-smartwatch::before { + content: "\f56c"; +} + +.bi-snow::before { + content: "\f56d"; +} + +.bi-snow2::before { + content: "\f56e"; +} + +.bi-snow3::before { + content: "\f56f"; +} + +.bi-sort-alpha-down-alt::before { + content: "\f570"; +} + +.bi-sort-alpha-down::before { + content: "\f571"; +} + +.bi-sort-alpha-up-alt::before { + content: "\f572"; +} + +.bi-sort-alpha-up::before { + content: "\f573"; +} + +.bi-sort-down-alt::before { + content: "\f574"; +} + +.bi-sort-down::before { + content: "\f575"; +} + +.bi-sort-numeric-down-alt::before { + content: "\f576"; +} + +.bi-sort-numeric-down::before { + content: "\f577"; +} + +.bi-sort-numeric-up-alt::before { + content: "\f578"; +} + +.bi-sort-numeric-up::before { + content: "\f579"; +} + +.bi-sort-up-alt::before { + content: "\f57a"; +} + +.bi-sort-up::before { + content: "\f57b"; +} + +.bi-soundwave::before { + content: "\f57c"; +} + +.bi-speaker-fill::before { + content: "\f57d"; +} + +.bi-speaker::before { + content: "\f57e"; +} + +.bi-speedometer::before { + content: "\f57f"; +} + +.bi-speedometer2::before { + content: "\f580"; +} + +.bi-spellcheck::before { + content: "\f581"; +} + +.bi-square-fill::before { + content: "\f582"; +} + +.bi-square-half::before { + content: "\f583"; +} + +.bi-square::before { + content: "\f584"; +} + +.bi-stack::before { + content: "\f585"; +} + +.bi-star-fill::before { + content: "\f586"; +} + +.bi-star-half::before { + content: "\f587"; +} + +.bi-star::before { + content: "\f588"; +} + +.bi-stars::before { + content: "\f589"; +} + +.bi-stickies-fill::before { + content: "\f58a"; +} + +.bi-stickies::before { + content: "\f58b"; +} + +.bi-sticky-fill::before { + content: "\f58c"; +} + +.bi-sticky::before { + content: "\f58d"; +} + +.bi-stop-btn-fill::before { + content: "\f58e"; +} + +.bi-stop-btn::before { + content: "\f58f"; +} + +.bi-stop-circle-fill::before { + content: "\f590"; +} + +.bi-stop-circle::before { + content: "\f591"; +} + +.bi-stop-fill::before { + content: "\f592"; +} + +.bi-stop::before { + content: "\f593"; +} + +.bi-stoplights-fill::before { + content: "\f594"; +} + +.bi-stoplights::before { + content: "\f595"; +} + +.bi-stopwatch-fill::before { + content: "\f596"; +} + +.bi-stopwatch::before { + content: "\f597"; +} + +.bi-subtract::before { + content: "\f598"; +} + +.bi-suit-club-fill::before { + content: "\f599"; +} + +.bi-suit-club::before { + content: "\f59a"; +} + +.bi-suit-diamond-fill::before { + content: "\f59b"; +} + +.bi-suit-diamond::before { + content: "\f59c"; +} + +.bi-suit-heart-fill::before { + content: "\f59d"; +} + +.bi-suit-heart::before { + content: "\f59e"; +} + +.bi-suit-spade-fill::before { + content: "\f59f"; +} + +.bi-suit-spade::before { + content: "\f5a0"; +} + +.bi-sun-fill::before { + content: "\f5a1"; +} + +.bi-sun::before { + content: "\f5a2"; +} + +.bi-sunglasses::before { + content: "\f5a3"; +} + +.bi-sunrise-fill::before { + content: "\f5a4"; +} + +.bi-sunrise::before { + content: "\f5a5"; +} + +.bi-sunset-fill::before { + content: "\f5a6"; +} + +.bi-sunset::before { + content: "\f5a7"; +} + +.bi-symmetry-horizontal::before { + content: "\f5a8"; +} + +.bi-symmetry-vertical::before { + content: "\f5a9"; +} + +.bi-table::before { + content: "\f5aa"; +} + +.bi-tablet-fill::before { + content: "\f5ab"; +} + +.bi-tablet-landscape-fill::before { + content: "\f5ac"; +} + +.bi-tablet-landscape::before { + content: "\f5ad"; +} + +.bi-tablet::before { + content: "\f5ae"; +} + +.bi-tag-fill::before { + content: "\f5af"; +} + +.bi-tag::before { + content: "\f5b0"; +} + +.bi-tags-fill::before { + content: "\f5b1"; +} + +.bi-tags::before { + content: "\f5b2"; +} + +.bi-telegram::before { + content: "\f5b3"; +} + +.bi-telephone-fill::before { + content: "\f5b4"; +} + +.bi-telephone-forward-fill::before { + content: "\f5b5"; +} + +.bi-telephone-forward::before { + content: "\f5b6"; +} + +.bi-telephone-inbound-fill::before { + content: "\f5b7"; +} + +.bi-telephone-inbound::before { + content: "\f5b8"; +} + +.bi-telephone-minus-fill::before { + content: "\f5b9"; +} + +.bi-telephone-minus::before { + content: "\f5ba"; +} + +.bi-telephone-outbound-fill::before { + content: "\f5bb"; +} + +.bi-telephone-outbound::before { + content: "\f5bc"; +} + +.bi-telephone-plus-fill::before { + content: "\f5bd"; +} + +.bi-telephone-plus::before { + content: "\f5be"; +} + +.bi-telephone-x-fill::before { + content: "\f5bf"; +} + +.bi-telephone-x::before { + content: "\f5c0"; +} + +.bi-telephone::before { + content: "\f5c1"; +} + +.bi-terminal-fill::before { + content: "\f5c2"; +} + +.bi-terminal::before { + content: "\f5c3"; +} + +.bi-text-center::before { + content: "\f5c4"; +} + +.bi-text-indent-left::before { + content: "\f5c5"; +} + +.bi-text-indent-right::before { + content: "\f5c6"; +} + +.bi-text-left::before { + content: "\f5c7"; +} + +.bi-text-paragraph::before { + content: "\f5c8"; +} + +.bi-text-right::before { + content: "\f5c9"; +} + +.bi-textarea-resize::before { + content: "\f5ca"; +} + +.bi-textarea-t::before { + content: "\f5cb"; +} + +.bi-textarea::before { + content: "\f5cc"; +} + +.bi-thermometer-half::before { + content: "\f5cd"; +} + +.bi-thermometer-high::before { + content: "\f5ce"; +} + +.bi-thermometer-low::before { + content: "\f5cf"; +} + +.bi-thermometer-snow::before { + content: "\f5d0"; +} + +.bi-thermometer-sun::before { + content: "\f5d1"; +} + +.bi-thermometer::before { + content: "\f5d2"; +} + +.bi-three-dots-vertical::before { + content: "\f5d3"; +} + +.bi-three-dots::before { + content: "\f5d4"; +} + +.bi-toggle-off::before { + content: "\f5d5"; +} + +.bi-toggle-on::before { + content: "\f5d6"; +} + +.bi-toggle2-off::before { + content: "\f5d7"; +} + +.bi-toggle2-on::before { + content: "\f5d8"; +} + +.bi-toggles::before { + content: "\f5d9"; +} + +.bi-toggles2::before { + content: "\f5da"; +} + +.bi-tools::before { + content: "\f5db"; +} + +.bi-tornado::before { + content: "\f5dc"; +} + +.bi-trash-fill::before { + content: "\f5dd"; +} + +.bi-trash::before { + content: "\f5de"; +} + +.bi-trash2-fill::before { + content: "\f5df"; +} + +.bi-trash2::before { + content: "\f5e0"; +} + +.bi-tree-fill::before { + content: "\f5e1"; +} + +.bi-tree::before { + content: "\f5e2"; +} + +.bi-triangle-fill::before { + content: "\f5e3"; +} + +.bi-triangle-half::before { + content: "\f5e4"; +} + +.bi-triangle::before { + content: "\f5e5"; +} + +.bi-trophy-fill::before { + content: "\f5e6"; +} + +.bi-trophy::before { + content: "\f5e7"; +} + +.bi-tropical-storm::before { + content: "\f5e8"; +} + +.bi-truck-flatbed::before { + content: "\f5e9"; +} + +.bi-truck::before { + content: "\f5ea"; +} + +.bi-tsunami::before { + content: "\f5eb"; +} + +.bi-tv-fill::before { + content: "\f5ec"; +} + +.bi-tv::before { + content: "\f5ed"; +} + +.bi-twitch::before { + content: "\f5ee"; +} + +.bi-twitter::before { + content: "\f5ef"; +} + +.bi-type-bold::before { + content: "\f5f0"; +} + +.bi-type-h1::before { + content: "\f5f1"; +} + +.bi-type-h2::before { + content: "\f5f2"; +} + +.bi-type-h3::before { + content: "\f5f3"; +} + +.bi-type-italic::before { + content: "\f5f4"; +} + +.bi-type-strikethrough::before { + content: "\f5f5"; +} + +.bi-type-underline::before { + content: "\f5f6"; +} + +.bi-type::before { + content: "\f5f7"; +} + +.bi-ui-checks-grid::before { + content: "\f5f8"; +} + +.bi-ui-checks::before { + content: "\f5f9"; +} + +.bi-ui-radios-grid::before { + content: "\f5fa"; +} + +.bi-ui-radios::before { + content: "\f5fb"; +} + +.bi-umbrella-fill::before { + content: "\f5fc"; +} + +.bi-umbrella::before { + content: "\f5fd"; +} + +.bi-union::before { + content: "\f5fe"; +} + +.bi-unlock-fill::before { + content: "\f5ff"; +} + +.bi-unlock::before { + content: "\f600"; +} + +.bi-upc-scan::before { + content: "\f601"; +} + +.bi-upc::before { + content: "\f602"; +} + +.bi-upload::before { + content: "\f603"; +} + +.bi-vector-pen::before { + content: "\f604"; +} + +.bi-view-list::before { + content: "\f605"; +} + +.bi-view-stacked::before { + content: "\f606"; +} + +.bi-vinyl-fill::before { + content: "\f607"; +} + +.bi-vinyl::before { + content: "\f608"; +} + +.bi-voicemail::before { + content: "\f609"; +} + +.bi-volume-down-fill::before { + content: "\f60a"; +} + +.bi-volume-down::before { + content: "\f60b"; +} + +.bi-volume-mute-fill::before { + content: "\f60c"; +} + +.bi-volume-mute::before { + content: "\f60d"; +} + +.bi-volume-off-fill::before { + content: "\f60e"; +} + +.bi-volume-off::before { + content: "\f60f"; +} + +.bi-volume-up-fill::before { + content: "\f610"; +} + +.bi-volume-up::before { + content: "\f611"; +} + +.bi-vr::before { + content: "\f612"; +} + +.bi-wallet-fill::before { + content: "\f613"; +} + +.bi-wallet::before { + content: "\f614"; +} + +.bi-wallet2::before { + content: "\f615"; +} + +.bi-watch::before { + content: "\f616"; +} + +.bi-water::before { + content: "\f617"; +} + +.bi-whatsapp::before { + content: "\f618"; +} + +.bi-wifi-1::before { + content: "\f619"; +} + +.bi-wifi-2::before { + content: "\f61a"; +} + +.bi-wifi-off::before { + content: "\f61b"; +} + +.bi-wifi::before { + content: "\f61c"; +} + +.bi-wind::before { + content: "\f61d"; +} + +.bi-window-dock::before { + content: "\f61e"; +} + +.bi-window-sidebar::before { + content: "\f61f"; +} + +.bi-window::before { + content: "\f620"; +} + +.bi-wrench::before { + content: "\f621"; +} + +.bi-x-circle-fill::before { + content: "\f622"; +} + +.bi-x-circle::before { + content: "\f623"; +} + +.bi-x-diamond-fill::before { + content: "\f624"; +} + +.bi-x-diamond::before { + content: "\f625"; +} + +.bi-x-octagon-fill::before { + content: "\f626"; +} + +.bi-x-octagon::before { + content: "\f627"; +} + +.bi-x-square-fill::before { + content: "\f628"; +} + +.bi-x-square::before { + content: "\f629"; +} + +.bi-x::before { + content: "\f62a"; +} + +.bi-youtube::before { + content: "\f62b"; +} + +.bi-zoom-in::before { + content: "\f62c"; +} + +.bi-zoom-out::before { + content: "\f62d"; +} + +.bi-bank::before { + content: "\f62e"; +} + +.bi-bank2::before { + content: "\f62f"; +} + +.bi-bell-slash-fill::before { + content: "\f630"; +} + +.bi-bell-slash::before { + content: "\f631"; +} + +.bi-cash-coin::before { + content: "\f632"; +} + +.bi-check-lg::before { + content: "\f633"; +} + +.bi-coin::before { + content: "\f634"; +} + +.bi-currency-bitcoin::before { + content: "\f635"; +} + +.bi-currency-dollar::before { + content: "\f636"; +} + +.bi-currency-euro::before { + content: "\f637"; +} + +.bi-currency-exchange::before { + content: "\f638"; +} + +.bi-currency-pound::before { + content: "\f639"; +} + +.bi-currency-yen::before { + content: "\f63a"; +} + +.bi-dash-lg::before { + content: "\f63b"; +} + +.bi-exclamation-lg::before { + content: "\f63c"; +} + +.bi-file-earmark-pdf-fill::before { + content: "\f63d"; +} + +.bi-file-earmark-pdf::before { + content: "\f63e"; +} + +.bi-file-pdf-fill::before { + content: "\f63f"; +} + +.bi-file-pdf::before { + content: "\f640"; +} + +.bi-gender-ambiguous::before { + content: "\f641"; +} + +.bi-gender-female::before { + content: "\f642"; +} + +.bi-gender-male::before { + content: "\f643"; +} + +.bi-gender-trans::before { + content: "\f644"; +} + +.bi-headset-vr::before { + content: "\f645"; +} + +.bi-info-lg::before { + content: "\f646"; +} + +.bi-mastodon::before { + content: "\f647"; +} + +.bi-messenger::before { + content: "\f648"; +} + +.bi-piggy-bank-fill::before { + content: "\f649"; +} + +.bi-piggy-bank::before { + content: "\f64a"; +} + +.bi-pin-map-fill::before { + content: "\f64b"; +} + +.bi-pin-map::before { + content: "\f64c"; +} + +.bi-plus-lg::before { + content: "\f64d"; +} + +.bi-question-lg::before { + content: "\f64e"; +} + +.bi-recycle::before { + content: "\f64f"; +} + +.bi-reddit::before { + content: "\f650"; +} + +.bi-safe-fill::before { + content: "\f651"; +} + +.bi-safe2-fill::before { + content: "\f652"; +} + +.bi-safe2::before { + content: "\f653"; +} + +.bi-sd-card-fill::before { + content: "\f654"; +} + +.bi-sd-card::before { + content: "\f655"; +} + +.bi-skype::before { + content: "\f656"; +} + +.bi-slash-lg::before { + content: "\f657"; +} + +.bi-translate::before { + content: "\f658"; +} + +.bi-x-lg::before { + content: "\f659"; +} + +.bi-safe::before { + content: "\f65a"; +} + +.bi-apple::before { + content: "\f65b"; +} + +.bi-microsoft::before { + content: "\f65d"; +} + +.bi-windows::before { + content: "\f65e"; +} + +.bi-behance::before { + content: "\f65c"; +} + +.bi-dribbble::before { + content: "\f65f"; +} + +.bi-line::before { + content: "\f660"; +} + +.bi-medium::before { + content: "\f661"; +} + +.bi-paypal::before { + content: "\f662"; +} + +.bi-pinterest::before { + content: "\f663"; +} + +.bi-signal::before { + content: "\f664"; +} + +.bi-snapchat::before { + content: "\f665"; +} + +.bi-spotify::before { + content: "\f666"; +} + +.bi-stack-overflow::before { + content: "\f667"; +} + +.bi-strava::before { + content: "\f668"; +} + +.bi-wordpress::before { + content: "\f669"; +} + +.bi-vimeo::before { + content: "\f66a"; +} + +.bi-activity::before { + content: "\f66b"; +} + +.bi-easel2-fill::before { + content: "\f66c"; +} + +.bi-easel2::before { + content: "\f66d"; +} + +.bi-easel3-fill::before { + content: "\f66e"; +} + +.bi-easel3::before { + content: "\f66f"; +} + +.bi-fan::before { + content: "\f670"; +} + +.bi-fingerprint::before { + content: "\f671"; +} + +.bi-graph-down-arrow::before { + content: "\f672"; +} + +.bi-graph-up-arrow::before { + content: "\f673"; +} + +.bi-hypnotize::before { + content: "\f674"; +} + +.bi-magic::before { + content: "\f675"; +} + +.bi-person-rolodex::before { + content: "\f676"; +} + +.bi-person-video::before { + content: "\f677"; +} + +.bi-person-video2::before { + content: "\f678"; +} + +.bi-person-video3::before { + content: "\f679"; +} + +.bi-person-workspace::before { + content: "\f67a"; +} + +.bi-radioactive::before { + content: "\f67b"; +} + +.bi-webcam-fill::before { + content: "\f67c"; +} + +.bi-webcam::before { + content: "\f67d"; +} + +.bi-yin-yang::before { + content: "\f67e"; +} + +.bi-bandaid-fill::before { + content: "\f680"; +} + +.bi-bandaid::before { + content: "\f681"; +} + +.bi-bluetooth::before { + content: "\f682"; +} + +.bi-body-text::before { + content: "\f683"; +} + +.bi-boombox::before { + content: "\f684"; +} + +.bi-boxes::before { + content: "\f685"; +} + +.bi-dpad-fill::before { + content: "\f686"; +} + +.bi-dpad::before { + content: "\f687"; +} + +.bi-ear-fill::before { + content: "\f688"; +} + +.bi-ear::before { + content: "\f689"; +} + +.bi-envelope-check-fill::before { + content: "\f68b"; +} + +.bi-envelope-check::before { + content: "\f68c"; +} + +.bi-envelope-dash-fill::before { + content: "\f68e"; +} + +.bi-envelope-dash::before { + content: "\f68f"; +} + +.bi-envelope-exclamation-fill::before { + content: "\f691"; +} + +.bi-envelope-exclamation::before { + content: "\f692"; +} + +.bi-envelope-plus-fill::before { + content: "\f693"; +} + +.bi-envelope-plus::before { + content: "\f694"; +} + +.bi-envelope-slash-fill::before { + content: "\f696"; +} + +.bi-envelope-slash::before { + content: "\f697"; +} + +.bi-envelope-x-fill::before { + content: "\f699"; +} + +.bi-envelope-x::before { + content: "\f69a"; +} + +.bi-explicit-fill::before { + content: "\f69b"; +} + +.bi-explicit::before { + content: "\f69c"; +} + +.bi-git::before { + content: "\f69d"; +} + +.bi-infinity::before { + content: "\f69e"; +} + +.bi-list-columns-reverse::before { + content: "\f69f"; +} + +.bi-list-columns::before { + content: "\f6a0"; +} + +.bi-meta::before { + content: "\f6a1"; +} + +.bi-nintendo-switch::before { + content: "\f6a4"; +} + +.bi-pc-display-horizontal::before { + content: "\f6a5"; +} + +.bi-pc-display::before { + content: "\f6a6"; +} + +.bi-pc-horizontal::before { + content: "\f6a7"; +} + +.bi-pc::before { + content: "\f6a8"; +} + +.bi-playstation::before { + content: "\f6a9"; +} + +.bi-plus-slash-minus::before { + content: "\f6aa"; +} + +.bi-projector-fill::before { + content: "\f6ab"; +} + +.bi-projector::before { + content: "\f6ac"; +} + +.bi-qr-code-scan::before { + content: "\f6ad"; +} + +.bi-qr-code::before { + content: "\f6ae"; +} + +.bi-quora::before { + content: "\f6af"; +} + +.bi-quote::before { + content: "\f6b0"; +} + +.bi-robot::before { + content: "\f6b1"; +} + +.bi-send-check-fill::before { + content: "\f6b2"; +} + +.bi-send-check::before { + content: "\f6b3"; +} + +.bi-send-dash-fill::before { + content: "\f6b4"; +} + +.bi-send-dash::before { + content: "\f6b5"; +} + +.bi-send-exclamation-fill::before { + content: "\f6b7"; +} + +.bi-send-exclamation::before { + content: "\f6b8"; +} + +.bi-send-fill::before { + content: "\f6b9"; +} + +.bi-send-plus-fill::before { + content: "\f6ba"; +} + +.bi-send-plus::before { + content: "\f6bb"; +} + +.bi-send-slash-fill::before { + content: "\f6bc"; +} + +.bi-send-slash::before { + content: "\f6bd"; +} + +.bi-send-x-fill::before { + content: "\f6be"; +} + +.bi-send-x::before { + content: "\f6bf"; +} + +.bi-send::before { + content: "\f6c0"; +} + +.bi-steam::before { + content: "\f6c1"; +} + +.bi-terminal-dash::before { + content: "\f6c3"; +} + +.bi-terminal-plus::before { + content: "\f6c4"; +} + +.bi-terminal-split::before { + content: "\f6c5"; +} + +.bi-ticket-detailed-fill::before { + content: "\f6c6"; +} + +.bi-ticket-detailed::before { + content: "\f6c7"; +} + +.bi-ticket-fill::before { + content: "\f6c8"; +} + +.bi-ticket-perforated-fill::before { + content: "\f6c9"; +} + +.bi-ticket-perforated::before { + content: "\f6ca"; +} + +.bi-ticket::before { + content: "\f6cb"; +} + +.bi-tiktok::before { + content: "\f6cc"; +} + +.bi-window-dash::before { + content: "\f6cd"; +} + +.bi-window-desktop::before { + content: "\f6ce"; +} + +.bi-window-fullscreen::before { + content: "\f6cf"; +} + +.bi-window-plus::before { + content: "\f6d0"; +} + +.bi-window-split::before { + content: "\f6d1"; +} + +.bi-window-stack::before { + content: "\f6d2"; +} + +.bi-window-x::before { + content: "\f6d3"; +} + +.bi-xbox::before { + content: "\f6d4"; +} + +.bi-ethernet::before { + content: "\f6d5"; +} + +.bi-hdmi-fill::before { + content: "\f6d6"; +} + +.bi-hdmi::before { + content: "\f6d7"; +} + +.bi-usb-c-fill::before { + content: "\f6d8"; +} + +.bi-usb-c::before { + content: "\f6d9"; +} + +.bi-usb-fill::before { + content: "\f6da"; +} + +.bi-usb-plug-fill::before { + content: "\f6db"; +} + +.bi-usb-plug::before { + content: "\f6dc"; +} + +.bi-usb-symbol::before { + content: "\f6dd"; +} + +.bi-usb::before { + content: "\f6de"; +} + +.bi-boombox-fill::before { + content: "\f6df"; +} + +.bi-displayport::before { + content: "\f6e1"; +} + +.bi-gpu-card::before { + content: "\f6e2"; +} + +.bi-memory::before { + content: "\f6e3"; +} + +.bi-modem-fill::before { + content: "\f6e4"; +} + +.bi-modem::before { + content: "\f6e5"; +} + +.bi-motherboard-fill::before { + content: "\f6e6"; +} + +.bi-motherboard::before { + content: "\f6e7"; +} + +.bi-optical-audio-fill::before { + content: "\f6e8"; +} + +.bi-optical-audio::before { + content: "\f6e9"; +} + +.bi-pci-card::before { + content: "\f6ea"; +} + +.bi-router-fill::before { + content: "\f6eb"; +} + +.bi-router::before { + content: "\f6ec"; +} + +.bi-thunderbolt-fill::before { + content: "\f6ef"; +} + +.bi-thunderbolt::before { + content: "\f6f0"; +} + +.bi-usb-drive-fill::before { + content: "\f6f1"; +} + +.bi-usb-drive::before { + content: "\f6f2"; +} + +.bi-usb-micro-fill::before { + content: "\f6f3"; +} + +.bi-usb-micro::before { + content: "\f6f4"; +} + +.bi-usb-mini-fill::before { + content: "\f6f5"; +} + +.bi-usb-mini::before { + content: "\f6f6"; +} + +.bi-cloud-haze2::before { + content: "\f6f7"; +} + +.bi-device-hdd-fill::before { + content: "\f6f8"; +} + +.bi-device-hdd::before { + content: "\f6f9"; +} + +.bi-device-ssd-fill::before { + content: "\f6fa"; +} + +.bi-device-ssd::before { + content: "\f6fb"; +} + +.bi-displayport-fill::before { + content: "\f6fc"; +} + +.bi-mortarboard-fill::before { + content: "\f6fd"; +} + +.bi-mortarboard::before { + content: "\f6fe"; +} + +.bi-terminal-x::before { + content: "\f6ff"; +} + +.bi-arrow-through-heart-fill::before { + content: "\f700"; +} + +.bi-arrow-through-heart::before { + content: "\f701"; +} + +.bi-badge-sd-fill::before { + content: "\f702"; +} + +.bi-badge-sd::before { + content: "\f703"; +} + +.bi-bag-heart-fill::before { + content: "\f704"; +} + +.bi-bag-heart::before { + content: "\f705"; +} + +.bi-balloon-fill::before { + content: "\f706"; +} + +.bi-balloon-heart-fill::before { + content: "\f707"; +} + +.bi-balloon-heart::before { + content: "\f708"; +} + +.bi-balloon::before { + content: "\f709"; +} + +.bi-box2-fill::before { + content: "\f70a"; +} + +.bi-box2-heart-fill::before { + content: "\f70b"; +} + +.bi-box2-heart::before { + content: "\f70c"; +} + +.bi-box2::before { + content: "\f70d"; +} + +.bi-braces-asterisk::before { + content: "\f70e"; +} + +.bi-calendar-heart-fill::before { + content: "\f70f"; +} + +.bi-calendar-heart::before { + content: "\f710"; +} + +.bi-calendar2-heart-fill::before { + content: "\f711"; +} + +.bi-calendar2-heart::before { + content: "\f712"; +} + +.bi-chat-heart-fill::before { + content: "\f713"; +} + +.bi-chat-heart::before { + content: "\f714"; +} + +.bi-chat-left-heart-fill::before { + content: "\f715"; +} + +.bi-chat-left-heart::before { + content: "\f716"; +} + +.bi-chat-right-heart-fill::before { + content: "\f717"; +} + +.bi-chat-right-heart::before { + content: "\f718"; +} + +.bi-chat-square-heart-fill::before { + content: "\f719"; +} + +.bi-chat-square-heart::before { + content: "\f71a"; +} + +.bi-clipboard-check-fill::before { + content: "\f71b"; +} + +.bi-clipboard-data-fill::before { + content: "\f71c"; +} + +.bi-clipboard-fill::before { + content: "\f71d"; +} + +.bi-clipboard-heart-fill::before { + content: "\f71e"; +} + +.bi-clipboard-heart::before { + content: "\f71f"; +} + +.bi-clipboard-minus-fill::before { + content: "\f720"; +} + +.bi-clipboard-plus-fill::before { + content: "\f721"; +} + +.bi-clipboard-pulse::before { + content: "\f722"; +} + +.bi-clipboard-x-fill::before { + content: "\f723"; +} + +.bi-clipboard2-check-fill::before { + content: "\f724"; +} + +.bi-clipboard2-check::before { + content: "\f725"; +} + +.bi-clipboard2-data-fill::before { + content: "\f726"; +} + +.bi-clipboard2-data::before { + content: "\f727"; +} + +.bi-clipboard2-fill::before { + content: "\f728"; +} + +.bi-clipboard2-heart-fill::before { + content: "\f729"; +} + +.bi-clipboard2-heart::before { + content: "\f72a"; +} + +.bi-clipboard2-minus-fill::before { + content: "\f72b"; +} + +.bi-clipboard2-minus::before { + content: "\f72c"; +} + +.bi-clipboard2-plus-fill::before { + content: "\f72d"; +} + +.bi-clipboard2-plus::before { + content: "\f72e"; +} + +.bi-clipboard2-pulse-fill::before { + content: "\f72f"; +} + +.bi-clipboard2-pulse::before { + content: "\f730"; +} + +.bi-clipboard2-x-fill::before { + content: "\f731"; +} + +.bi-clipboard2-x::before { + content: "\f732"; +} + +.bi-clipboard2::before { + content: "\f733"; +} + +.bi-emoji-kiss-fill::before { + content: "\f734"; +} + +.bi-emoji-kiss::before { + content: "\f735"; +} + +.bi-envelope-heart-fill::before { + content: "\f736"; +} + +.bi-envelope-heart::before { + content: "\f737"; +} + +.bi-envelope-open-heart-fill::before { + content: "\f738"; +} + +.bi-envelope-open-heart::before { + content: "\f739"; +} + +.bi-envelope-paper-fill::before { + content: "\f73a"; +} + +.bi-envelope-paper-heart-fill::before { + content: "\f73b"; +} + +.bi-envelope-paper-heart::before { + content: "\f73c"; +} + +.bi-envelope-paper::before { + content: "\f73d"; +} + +.bi-filetype-aac::before { + content: "\f73e"; +} + +.bi-filetype-ai::before { + content: "\f73f"; +} + +.bi-filetype-bmp::before { + content: "\f740"; +} + +.bi-filetype-cs::before { + content: "\f741"; +} + +.bi-filetype-css::before { + content: "\f742"; +} + +.bi-filetype-csv::before { + content: "\f743"; +} + +.bi-filetype-doc::before { + content: "\f744"; +} + +.bi-filetype-docx::before { + content: "\f745"; +} + +.bi-filetype-exe::before { + content: "\f746"; +} + +.bi-filetype-gif::before { + content: "\f747"; +} + +.bi-filetype-heic::before { + content: "\f748"; +} + +.bi-filetype-html::before { + content: "\f749"; +} + +.bi-filetype-java::before { + content: "\f74a"; +} + +.bi-filetype-jpg::before { + content: "\f74b"; +} + +.bi-filetype-js::before { + content: "\f74c"; +} + +.bi-filetype-jsx::before { + content: "\f74d"; +} + +.bi-filetype-key::before { + content: "\f74e"; +} + +.bi-filetype-m4p::before { + content: "\f74f"; +} + +.bi-filetype-md::before { + content: "\f750"; +} + +.bi-filetype-mdx::before { + content: "\f751"; +} + +.bi-filetype-mov::before { + content: "\f752"; +} + +.bi-filetype-mp3::before { + content: "\f753"; +} + +.bi-filetype-mp4::before { + content: "\f754"; +} + +.bi-filetype-otf::before { + content: "\f755"; +} + +.bi-filetype-pdf::before { + content: "\f756"; +} + +.bi-filetype-php::before { + content: "\f757"; +} + +.bi-filetype-png::before { + content: "\f758"; +} + +.bi-filetype-ppt::before { + content: "\f75a"; +} + +.bi-filetype-psd::before { + content: "\f75b"; +} + +.bi-filetype-py::before { + content: "\f75c"; +} + +.bi-filetype-raw::before { + content: "\f75d"; +} + +.bi-filetype-rb::before { + content: "\f75e"; +} + +.bi-filetype-sass::before { + content: "\f75f"; +} + +.bi-filetype-scss::before { + content: "\f760"; +} + +.bi-filetype-sh::before { + content: "\f761"; +} + +.bi-filetype-svg::before { + content: "\f762"; +} + +.bi-filetype-tiff::before { + content: "\f763"; +} + +.bi-filetype-tsx::before { + content: "\f764"; +} + +.bi-filetype-ttf::before { + content: "\f765"; +} + +.bi-filetype-txt::before { + content: "\f766"; +} + +.bi-filetype-wav::before { + content: "\f767"; +} + +.bi-filetype-woff::before { + content: "\f768"; +} + +.bi-filetype-xls::before { + content: "\f76a"; +} + +.bi-filetype-xml::before { + content: "\f76b"; +} + +.bi-filetype-yml::before { + content: "\f76c"; +} + +.bi-heart-arrow::before { + content: "\f76d"; +} + +.bi-heart-pulse-fill::before { + content: "\f76e"; +} + +.bi-heart-pulse::before { + content: "\f76f"; +} + +.bi-heartbreak-fill::before { + content: "\f770"; +} + +.bi-heartbreak::before { + content: "\f771"; +} + +.bi-hearts::before { + content: "\f772"; +} + +.bi-hospital-fill::before { + content: "\f773"; +} + +.bi-hospital::before { + content: "\f774"; +} + +.bi-house-heart-fill::before { + content: "\f775"; +} + +.bi-house-heart::before { + content: "\f776"; +} + +.bi-incognito::before { + content: "\f777"; +} + +.bi-magnet-fill::before { + content: "\f778"; +} + +.bi-magnet::before { + content: "\f779"; +} + +.bi-person-heart::before { + content: "\f77a"; +} + +.bi-person-hearts::before { + content: "\f77b"; +} + +.bi-phone-flip::before { + content: "\f77c"; +} + +.bi-plugin::before { + content: "\f77d"; +} + +.bi-postage-fill::before { + content: "\f77e"; +} + +.bi-postage-heart-fill::before { + content: "\f77f"; +} + +.bi-postage-heart::before { + content: "\f780"; +} + +.bi-postage::before { + content: "\f781"; +} + +.bi-postcard-fill::before { + content: "\f782"; +} + +.bi-postcard-heart-fill::before { + content: "\f783"; +} + +.bi-postcard-heart::before { + content: "\f784"; +} + +.bi-postcard::before { + content: "\f785"; +} + +.bi-search-heart-fill::before { + content: "\f786"; +} + +.bi-search-heart::before { + content: "\f787"; +} + +.bi-sliders2-vertical::before { + content: "\f788"; +} + +.bi-sliders2::before { + content: "\f789"; +} + +.bi-trash3-fill::before { + content: "\f78a"; +} + +.bi-trash3::before { + content: "\f78b"; +} + +.bi-valentine::before { + content: "\f78c"; +} + +.bi-valentine2::before { + content: "\f78d"; +} + +.bi-wrench-adjustable-circle-fill::before { + content: "\f78e"; +} + +.bi-wrench-adjustable-circle::before { + content: "\f78f"; +} + +.bi-wrench-adjustable::before { + content: "\f790"; +} + +.bi-filetype-json::before { + content: "\f791"; +} + +.bi-filetype-pptx::before { + content: "\f792"; +} + +.bi-filetype-xlsx::before { + content: "\f793"; +} + +.bi-1-circle-fill::before { + content: "\f796"; +} + +.bi-1-circle::before { + content: "\f797"; +} + +.bi-1-square-fill::before { + content: "\f798"; +} + +.bi-1-square::before { + content: "\f799"; +} + +.bi-2-circle-fill::before { + content: "\f79c"; +} + +.bi-2-circle::before { + content: "\f79d"; +} + +.bi-2-square-fill::before { + content: "\f79e"; +} + +.bi-2-square::before { + content: "\f79f"; +} + +.bi-3-circle-fill::before { + content: "\f7a2"; +} + +.bi-3-circle::before { + content: "\f7a3"; +} + +.bi-3-square-fill::before { + content: "\f7a4"; +} + +.bi-3-square::before { + content: "\f7a5"; +} + +.bi-4-circle-fill::before { + content: "\f7a8"; +} + +.bi-4-circle::before { + content: "\f7a9"; +} + +.bi-4-square-fill::before { + content: "\f7aa"; +} + +.bi-4-square::before { + content: "\f7ab"; +} + +.bi-5-circle-fill::before { + content: "\f7ae"; +} + +.bi-5-circle::before { + content: "\f7af"; +} + +.bi-5-square-fill::before { + content: "\f7b0"; +} + +.bi-5-square::before { + content: "\f7b1"; +} + +.bi-6-circle-fill::before { + content: "\f7b4"; +} + +.bi-6-circle::before { + content: "\f7b5"; +} + +.bi-6-square-fill::before { + content: "\f7b6"; +} + +.bi-6-square::before { + content: "\f7b7"; +} + +.bi-7-circle-fill::before { + content: "\f7ba"; +} + +.bi-7-circle::before { + content: "\f7bb"; +} + +.bi-7-square-fill::before { + content: "\f7bc"; +} + +.bi-7-square::before { + content: "\f7bd"; +} + +.bi-8-circle-fill::before { + content: "\f7c0"; +} + +.bi-8-circle::before { + content: "\f7c1"; +} + +.bi-8-square-fill::before { + content: "\f7c2"; +} + +.bi-8-square::before { + content: "\f7c3"; +} + +.bi-9-circle-fill::before { + content: "\f7c6"; +} + +.bi-9-circle::before { + content: "\f7c7"; +} + +.bi-9-square-fill::before { + content: "\f7c8"; +} + +.bi-9-square::before { + content: "\f7c9"; +} + +.bi-airplane-engines-fill::before { + content: "\f7ca"; +} + +.bi-airplane-engines::before { + content: "\f7cb"; +} + +.bi-airplane-fill::before { + content: "\f7cc"; +} + +.bi-airplane::before { + content: "\f7cd"; +} + +.bi-alexa::before { + content: "\f7ce"; +} + +.bi-alipay::before { + content: "\f7cf"; +} + +.bi-android::before { + content: "\f7d0"; +} + +.bi-android2::before { + content: "\f7d1"; +} + +.bi-box-fill::before { + content: "\f7d2"; +} + +.bi-box-seam-fill::before { + content: "\f7d3"; +} + +.bi-browser-chrome::before { + content: "\f7d4"; +} + +.bi-browser-edge::before { + content: "\f7d5"; +} + +.bi-browser-firefox::before { + content: "\f7d6"; +} + +.bi-browser-safari::before { + content: "\f7d7"; +} + +.bi-c-circle-fill::before { + content: "\f7da"; +} + +.bi-c-circle::before { + content: "\f7db"; +} + +.bi-c-square-fill::before { + content: "\f7dc"; +} + +.bi-c-square::before { + content: "\f7dd"; +} + +.bi-capsule-pill::before { + content: "\f7de"; +} + +.bi-capsule::before { + content: "\f7df"; +} + +.bi-car-front-fill::before { + content: "\f7e0"; +} + +.bi-car-front::before { + content: "\f7e1"; +} + +.bi-cassette-fill::before { + content: "\f7e2"; +} + +.bi-cassette::before { + content: "\f7e3"; +} + +.bi-cc-circle-fill::before { + content: "\f7e6"; +} + +.bi-cc-circle::before { + content: "\f7e7"; +} + +.bi-cc-square-fill::before { + content: "\f7e8"; +} + +.bi-cc-square::before { + content: "\f7e9"; +} + +.bi-cup-hot-fill::before { + content: "\f7ea"; +} + +.bi-cup-hot::before { + content: "\f7eb"; +} + +.bi-currency-rupee::before { + content: "\f7ec"; +} + +.bi-dropbox::before { + content: "\f7ed"; +} + +.bi-escape::before { + content: "\f7ee"; +} + +.bi-fast-forward-btn-fill::before { + content: "\f7ef"; +} + +.bi-fast-forward-btn::before { + content: "\f7f0"; +} + +.bi-fast-forward-circle-fill::before { + content: "\f7f1"; +} + +.bi-fast-forward-circle::before { + content: "\f7f2"; +} + +.bi-fast-forward-fill::before { + content: "\f7f3"; +} + +.bi-fast-forward::before { + content: "\f7f4"; +} + +.bi-filetype-sql::before { + content: "\f7f5"; +} + +.bi-fire::before { + content: "\f7f6"; +} + +.bi-google-play::before { + content: "\f7f7"; +} + +.bi-h-circle-fill::before { + content: "\f7fa"; +} + +.bi-h-circle::before { + content: "\f7fb"; +} + +.bi-h-square-fill::before { + content: "\f7fc"; +} + +.bi-h-square::before { + content: "\f7fd"; +} + +.bi-indent::before { + content: "\f7fe"; +} + +.bi-lungs-fill::before { + content: "\f7ff"; +} + +.bi-lungs::before { + content: "\f800"; +} + +.bi-microsoft-teams::before { + content: "\f801"; +} + +.bi-p-circle-fill::before { + content: "\f804"; +} + +.bi-p-circle::before { + content: "\f805"; +} + +.bi-p-square-fill::before { + content: "\f806"; +} + +.bi-p-square::before { + content: "\f807"; +} + +.bi-pass-fill::before { + content: "\f808"; +} + +.bi-pass::before { + content: "\f809"; +} + +.bi-prescription::before { + content: "\f80a"; +} + +.bi-prescription2::before { + content: "\f80b"; +} + +.bi-r-circle-fill::before { + content: "\f80e"; +} + +.bi-r-circle::before { + content: "\f80f"; +} + +.bi-r-square-fill::before { + content: "\f810"; +} + +.bi-r-square::before { + content: "\f811"; +} + +.bi-repeat-1::before { + content: "\f812"; +} + +.bi-repeat::before { + content: "\f813"; +} + +.bi-rewind-btn-fill::before { + content: "\f814"; +} + +.bi-rewind-btn::before { + content: "\f815"; +} + +.bi-rewind-circle-fill::before { + content: "\f816"; +} + +.bi-rewind-circle::before { + content: "\f817"; +} + +.bi-rewind-fill::before { + content: "\f818"; +} + +.bi-rewind::before { + content: "\f819"; +} + +.bi-train-freight-front-fill::before { + content: "\f81a"; +} + +.bi-train-freight-front::before { + content: "\f81b"; +} + +.bi-train-front-fill::before { + content: "\f81c"; +} + +.bi-train-front::before { + content: "\f81d"; +} + +.bi-train-lightrail-front-fill::before { + content: "\f81e"; +} + +.bi-train-lightrail-front::before { + content: "\f81f"; +} + +.bi-truck-front-fill::before { + content: "\f820"; +} + +.bi-truck-front::before { + content: "\f821"; +} + +.bi-ubuntu::before { + content: "\f822"; +} + +.bi-unindent::before { + content: "\f823"; +} + +.bi-unity::before { + content: "\f824"; +} + +.bi-universal-access-circle::before { + content: "\f825"; +} + +.bi-universal-access::before { + content: "\f826"; +} + +.bi-virus::before { + content: "\f827"; +} + +.bi-virus2::before { + content: "\f828"; +} + +.bi-wechat::before { + content: "\f829"; +} + +.bi-yelp::before { + content: "\f82a"; +} + +.bi-sign-stop-fill::before { + content: "\f82b"; +} + +.bi-sign-stop-lights-fill::before { + content: "\f82c"; +} + +.bi-sign-stop-lights::before { + content: "\f82d"; +} + +.bi-sign-stop::before { + content: "\f82e"; +} + +.bi-sign-turn-left-fill::before { + content: "\f82f"; +} + +.bi-sign-turn-left::before { + content: "\f830"; +} + +.bi-sign-turn-right-fill::before { + content: "\f831"; +} + +.bi-sign-turn-right::before { + content: "\f832"; +} + +.bi-sign-turn-slight-left-fill::before { + content: "\f833"; +} + +.bi-sign-turn-slight-left::before { + content: "\f834"; +} + +.bi-sign-turn-slight-right-fill::before { + content: "\f835"; +} + +.bi-sign-turn-slight-right::before { + content: "\f836"; +} + +.bi-sign-yield-fill::before { + content: "\f837"; +} + +.bi-sign-yield::before { + content: "\f838"; +} + +.bi-ev-station-fill::before { + content: "\f839"; +} + +.bi-ev-station::before { + content: "\f83a"; +} + +.bi-fuel-pump-diesel-fill::before { + content: "\f83b"; +} + +.bi-fuel-pump-diesel::before { + content: "\f83c"; +} + +.bi-fuel-pump-fill::before { + content: "\f83d"; +} + +.bi-fuel-pump::before { + content: "\f83e"; +} + +.bi-0-circle-fill::before { + content: "\f83f"; +} + +.bi-0-circle::before { + content: "\f840"; +} + +.bi-0-square-fill::before { + content: "\f841"; +} + +.bi-0-square::before { + content: "\f842"; +} + +.bi-rocket-fill::before { + content: "\f843"; +} + +.bi-rocket-takeoff-fill::before { + content: "\f844"; +} + +.bi-rocket-takeoff::before { + content: "\f845"; +} + +.bi-rocket::before { + content: "\f846"; +} + +.bi-stripe::before { + content: "\f847"; +} + +.bi-subscript::before { + content: "\f848"; +} + +.bi-superscript::before { + content: "\f849"; +} + +.bi-trello::before { + content: "\f84a"; +} + +.bi-envelope-at-fill::before { + content: "\f84b"; +} + +.bi-envelope-at::before { + content: "\f84c"; +} + +.bi-regex::before { + content: "\f84d"; +} + +.bi-text-wrap::before { + content: "\f84e"; +} + +.bi-sign-dead-end-fill::before { + content: "\f84f"; +} + +.bi-sign-dead-end::before { + content: "\f850"; +} + +.bi-sign-do-not-enter-fill::before { + content: "\f851"; +} + +.bi-sign-do-not-enter::before { + content: "\f852"; +} + +.bi-sign-intersection-fill::before { + content: "\f853"; +} + +.bi-sign-intersection-side-fill::before { + content: "\f854"; +} + +.bi-sign-intersection-side::before { + content: "\f855"; +} + +.bi-sign-intersection-t-fill::before { + content: "\f856"; +} + +.bi-sign-intersection-t::before { + content: "\f857"; +} + +.bi-sign-intersection-y-fill::before { + content: "\f858"; +} + +.bi-sign-intersection-y::before { + content: "\f859"; +} + +.bi-sign-intersection::before { + content: "\f85a"; +} + +.bi-sign-merge-left-fill::before { + content: "\f85b"; +} + +.bi-sign-merge-left::before { + content: "\f85c"; +} + +.bi-sign-merge-right-fill::before { + content: "\f85d"; +} + +.bi-sign-merge-right::before { + content: "\f85e"; +} + +.bi-sign-no-left-turn-fill::before { + content: "\f85f"; +} + +.bi-sign-no-left-turn::before { + content: "\f860"; +} + +.bi-sign-no-parking-fill::before { + content: "\f861"; +} + +.bi-sign-no-parking::before { + content: "\f862"; +} + +.bi-sign-no-right-turn-fill::before { + content: "\f863"; +} + +.bi-sign-no-right-turn::before { + content: "\f864"; +} + +.bi-sign-railroad-fill::before { + content: "\f865"; +} + +.bi-sign-railroad::before { + content: "\f866"; +} + +.bi-building-add::before { + content: "\f867"; +} + +.bi-building-check::before { + content: "\f868"; +} + +.bi-building-dash::before { + content: "\f869"; +} + +.bi-building-down::before { + content: "\f86a"; +} + +.bi-building-exclamation::before { + content: "\f86b"; +} + +.bi-building-fill-add::before { + content: "\f86c"; +} + +.bi-building-fill-check::before { + content: "\f86d"; +} + +.bi-building-fill-dash::before { + content: "\f86e"; +} + +.bi-building-fill-down::before { + content: "\f86f"; +} + +.bi-building-fill-exclamation::before { + content: "\f870"; +} + +.bi-building-fill-gear::before { + content: "\f871"; +} + +.bi-building-fill-lock::before { + content: "\f872"; +} + +.bi-building-fill-slash::before { + content: "\f873"; +} + +.bi-building-fill-up::before { + content: "\f874"; +} + +.bi-building-fill-x::before { + content: "\f875"; +} + +.bi-building-fill::before { + content: "\f876"; +} + +.bi-building-gear::before { + content: "\f877"; +} + +.bi-building-lock::before { + content: "\f878"; +} + +.bi-building-slash::before { + content: "\f879"; +} + +.bi-building-up::before { + content: "\f87a"; +} + +.bi-building-x::before { + content: "\f87b"; +} + +.bi-buildings-fill::before { + content: "\f87c"; +} + +.bi-buildings::before { + content: "\f87d"; +} + +.bi-bus-front-fill::before { + content: "\f87e"; +} + +.bi-bus-front::before { + content: "\f87f"; +} + +.bi-ev-front-fill::before { + content: "\f880"; +} + +.bi-ev-front::before { + content: "\f881"; +} + +.bi-globe-americas::before { + content: "\f882"; +} + +.bi-globe-asia-australia::before { + content: "\f883"; +} + +.bi-globe-central-south-asia::before { + content: "\f884"; +} + +.bi-globe-europe-africa::before { + content: "\f885"; +} + +.bi-house-add-fill::before { + content: "\f886"; +} + +.bi-house-add::before { + content: "\f887"; +} + +.bi-house-check-fill::before { + content: "\f888"; +} + +.bi-house-check::before { + content: "\f889"; +} + +.bi-house-dash-fill::before { + content: "\f88a"; +} + +.bi-house-dash::before { + content: "\f88b"; +} + +.bi-house-down-fill::before { + content: "\f88c"; +} + +.bi-house-down::before { + content: "\f88d"; +} + +.bi-house-exclamation-fill::before { + content: "\f88e"; +} + +.bi-house-exclamation::before { + content: "\f88f"; +} + +.bi-house-gear-fill::before { + content: "\f890"; +} + +.bi-house-gear::before { + content: "\f891"; +} + +.bi-house-lock-fill::before { + content: "\f892"; +} + +.bi-house-lock::before { + content: "\f893"; +} + +.bi-house-slash-fill::before { + content: "\f894"; +} + +.bi-house-slash::before { + content: "\f895"; +} + +.bi-house-up-fill::before { + content: "\f896"; +} + +.bi-house-up::before { + content: "\f897"; +} + +.bi-house-x-fill::before { + content: "\f898"; +} + +.bi-house-x::before { + content: "\f899"; +} + +.bi-person-add::before { + content: "\f89a"; +} + +.bi-person-down::before { + content: "\f89b"; +} + +.bi-person-exclamation::before { + content: "\f89c"; +} + +.bi-person-fill-add::before { + content: "\f89d"; +} + +.bi-person-fill-check::before { + content: "\f89e"; +} + +.bi-person-fill-dash::before { + content: "\f89f"; +} + +.bi-person-fill-down::before { + content: "\f8a0"; +} + +.bi-person-fill-exclamation::before { + content: "\f8a1"; +} + +.bi-person-fill-gear::before { + content: "\f8a2"; +} + +.bi-person-fill-lock::before { + content: "\f8a3"; +} + +.bi-person-fill-slash::before { + content: "\f8a4"; +} + +.bi-person-fill-up::before { + content: "\f8a5"; +} + +.bi-person-fill-x::before { + content: "\f8a6"; +} + +.bi-person-gear::before { + content: "\f8a7"; +} + +.bi-person-lock::before { + content: "\f8a8"; +} + +.bi-person-slash::before { + content: "\f8a9"; +} + +.bi-person-up::before { + content: "\f8aa"; +} + +.bi-scooter::before { + content: "\f8ab"; +} + +.bi-taxi-front-fill::before { + content: "\f8ac"; +} + +.bi-taxi-front::before { + content: "\f8ad"; +} + +.bi-amd::before { + content: "\f8ae"; +} + +.bi-database-add::before { + content: "\f8af"; +} + +.bi-database-check::before { + content: "\f8b0"; +} + +.bi-database-dash::before { + content: "\f8b1"; +} + +.bi-database-down::before { + content: "\f8b2"; +} + +.bi-database-exclamation::before { + content: "\f8b3"; +} + +.bi-database-fill-add::before { + content: "\f8b4"; +} + +.bi-database-fill-check::before { + content: "\f8b5"; +} + +.bi-database-fill-dash::before { + content: "\f8b6"; +} + +.bi-database-fill-down::before { + content: "\f8b7"; +} + +.bi-database-fill-exclamation::before { + content: "\f8b8"; +} + +.bi-database-fill-gear::before { + content: "\f8b9"; +} + +.bi-database-fill-lock::before { + content: "\f8ba"; +} + +.bi-database-fill-slash::before { + content: "\f8bb"; +} + +.bi-database-fill-up::before { + content: "\f8bc"; +} + +.bi-database-fill-x::before { + content: "\f8bd"; +} + +.bi-database-fill::before { + content: "\f8be"; +} + +.bi-database-gear::before { + content: "\f8bf"; +} + +.bi-database-lock::before { + content: "\f8c0"; +} + +.bi-database-slash::before { + content: "\f8c1"; +} + +.bi-database-up::before { + content: "\f8c2"; +} + +.bi-database-x::before { + content: "\f8c3"; +} + +.bi-database::before { + content: "\f8c4"; +} + +.bi-houses-fill::before { + content: "\f8c5"; +} + +.bi-houses::before { + content: "\f8c6"; +} + +.bi-nvidia::before { + content: "\f8c7"; +} + +.bi-person-vcard-fill::before { + content: "\f8c8"; +} + +.bi-person-vcard::before { + content: "\f8c9"; +} + +.bi-sina-weibo::before { + content: "\f8ca"; +} + +.bi-tencent-qq::before { + content: "\f8cb"; +} + +.bi-wikipedia::before { + content: "\f8cc"; +} + +.bi-alphabet-uppercase::before { + content: "\f2a5"; +} + +.bi-alphabet::before { + content: "\f68a"; +} + +.bi-amazon::before { + content: "\f68d"; +} + +.bi-arrows-collapse-vertical::before { + content: "\f690"; +} + +.bi-arrows-expand-vertical::before { + content: "\f695"; +} + +.bi-arrows-vertical::before { + content: "\f698"; +} + +.bi-arrows::before { + content: "\f6a2"; +} + +.bi-ban-fill::before { + content: "\f6a3"; +} + +.bi-ban::before { + content: "\f6b6"; +} + +.bi-bing::before { + content: "\f6c2"; +} + +.bi-cake::before { + content: "\f6e0"; +} + +.bi-cake2::before { + content: "\f6ed"; +} + +.bi-cookie::before { + content: "\f6ee"; +} + +.bi-copy::before { + content: "\f759"; +} + +.bi-crosshair::before { + content: "\f769"; +} + +.bi-crosshair2::before { + content: "\f794"; +} + +.bi-emoji-astonished-fill::before { + content: "\f795"; +} + +.bi-emoji-astonished::before { + content: "\f79a"; +} + +.bi-emoji-grimace-fill::before { + content: "\f79b"; +} + +.bi-emoji-grimace::before { + content: "\f7a0"; +} + +.bi-emoji-grin-fill::before { + content: "\f7a1"; +} + +.bi-emoji-grin::before { + content: "\f7a6"; +} + +.bi-emoji-surprise-fill::before { + content: "\f7a7"; +} + +.bi-emoji-surprise::before { + content: "\f7ac"; +} + +.bi-emoji-tear-fill::before { + content: "\f7ad"; +} + +.bi-emoji-tear::before { + content: "\f7b2"; +} + +.bi-envelope-arrow-down-fill::before { + content: "\f7b3"; +} + +.bi-envelope-arrow-down::before { + content: "\f7b8"; +} + +.bi-envelope-arrow-up-fill::before { + content: "\f7b9"; +} + +.bi-envelope-arrow-up::before { + content: "\f7be"; +} + +.bi-feather::before { + content: "\f7bf"; +} + +.bi-feather2::before { + content: "\f7c4"; +} + +.bi-floppy-fill::before { + content: "\f7c5"; +} + +.bi-floppy::before { + content: "\f7d8"; +} + +.bi-floppy2-fill::before { + content: "\f7d9"; +} + +.bi-floppy2::before { + content: "\f7e4"; +} + +.bi-gitlab::before { + content: "\f7e5"; +} + +.bi-highlighter::before { + content: "\f7f8"; +} + +.bi-marker-tip::before { + content: "\f802"; +} + +.bi-nvme-fill::before { + content: "\f803"; +} + +.bi-nvme::before { + content: "\f80c"; +} + +.bi-opencollective::before { + content: "\f80d"; +} + +.bi-pci-card-network::before { + content: "\f8cd"; +} + +.bi-pci-card-sound::before { + content: "\f8ce"; +} + +.bi-radar::before { + content: "\f8cf"; +} + +.bi-send-arrow-down-fill::before { + content: "\f8d0"; +} + +.bi-send-arrow-down::before { + content: "\f8d1"; +} + +.bi-send-arrow-up-fill::before { + content: "\f8d2"; +} + +.bi-send-arrow-up::before { + content: "\f8d3"; +} + +.bi-sim-slash-fill::before { + content: "\f8d4"; +} + +.bi-sim-slash::before { + content: "\f8d5"; +} + +.bi-sourceforge::before { + content: "\f8d6"; +} + +.bi-substack::before { + content: "\f8d7"; +} + +.bi-threads-fill::before { + content: "\f8d8"; +} + +.bi-threads::before { + content: "\f8d9"; +} + +.bi-transparency::before { + content: "\f8da"; +} + +.bi-twitter-x::before { + content: "\f8db"; +} + +.bi-type-h4::before { + content: "\f8dc"; +} + +.bi-type-h5::before { + content: "\f8dd"; +} + +.bi-type-h6::before { + content: "\f8de"; +} + +.bi-backpack-fill::before { + content: "\f8df"; +} + +.bi-backpack::before { + content: "\f8e0"; +} + +.bi-backpack2-fill::before { + content: "\f8e1"; +} + +.bi-backpack2::before { + content: "\f8e2"; +} + +.bi-backpack3-fill::before { + content: "\f8e3"; +} + +.bi-backpack3::before { + content: "\f8e4"; +} + +.bi-backpack4-fill::before { + content: "\f8e5"; +} + +.bi-backpack4::before { + content: "\f8e6"; +} + +.bi-brilliance::before { + content: "\f8e7"; +} + +.bi-cake-fill::before { + content: "\f8e8"; +} + +.bi-cake2-fill::before { + content: "\f8e9"; +} + +.bi-duffle-fill::before { + content: "\f8ea"; +} + +.bi-duffle::before { + content: "\f8eb"; +} + +.bi-exposure::before { + content: "\f8ec"; +} + +.bi-gender-neuter::before { + content: "\f8ed"; +} + +.bi-highlights::before { + content: "\f8ee"; +} + +.bi-luggage-fill::before { + content: "\f8ef"; +} + +.bi-luggage::before { + content: "\f8f0"; +} + +.bi-mailbox-flag::before { + content: "\f8f1"; +} + +.bi-mailbox2-flag::before { + content: "\f8f2"; +} + +.bi-noise-reduction::before { + content: "\f8f3"; +} + +.bi-passport-fill::before { + content: "\f8f4"; +} + +.bi-passport::before { + content: "\f8f5"; +} + +.bi-person-arms-up::before { + content: "\f8f6"; +} + +.bi-person-raised-hand::before { + content: "\f8f7"; +} + +.bi-person-standing-dress::before { + content: "\f8f8"; +} + +.bi-person-standing::before { + content: "\f8f9"; +} + +.bi-person-walking::before { + content: "\f8fa"; +} + +.bi-person-wheelchair::before { + content: "\f8fb"; +} + +.bi-shadows::before { + content: "\f8fc"; +} + +.bi-suitcase-fill::before { + content: "\f8fd"; +} + +.bi-suitcase-lg-fill::before { + content: "\f8fe"; +} + +.bi-suitcase-lg::before { + content: "\f8ff"; +} + +.bi-suitcase::before { + content: "豈"; +} + +.bi-suitcase2-fill::before { + content: "更"; +} + +.bi-suitcase2::before { + content: "車"; +} + +.bi-vignette::before { + content: "賈"; +} + +/*# sourceMappingURL=base.css.map */ diff --git a/BaCa2/assets/css/base.css.map b/BaCa2/assets/css/base.css.map new file mode 100644 index 00000000..de70c24f --- /dev/null +++ b/BaCa2/assets/css/base.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":["../scss/base.scss","../scss/_baca2_variables.scss","../scss/_version_footer.scss","../scss/_forms.scss","../scss/_baca2_logo.scss","../scss/_navbar.scss","../scss/_cards.scss","../scss/_icons.scss","../scss/_theme_button.scss","../scss/_separator.scss","../scss/_side_nav.scss","../scss/_table.scss","../scss/_buttons.scss","../scss/_popups.scss","../scss/_pdf_displayer.scss","../scss/_datetime_picker.scss","../scss/_scrollbar.scss","../scss/_code_block.scss","../../../node_modules/bootstrap-icons/font/bootstrap-icons.scss"],"names":[],"mappings":";AAAA;EACI;;;AC2CJ;EACI;;;AC3CJ;EACI;EACA;EACA;EACA,ODWU;ECVV;EACA;;;ADoCJ;EACI;;;AE1CA;EACI;EACA;;;AAIR;EACI;;;AAIA;EACI;;AAGJ;EACI;;;AAIR;EACI;;;AAIA;EACI;EACA;;AAIA;EACI;;AAGJ;EACI;;AAGJ;EACI;;;AAKZ;EACI;;AAEA;EACI;EACA;;;AAKJ;EACI;;;AFfR;EACI;;;AGzCA;EACI,MHqEmB;;AGnEvB;EACI,MHUK;;AGRT;EACI,MHMM;;AGJV;EACI,MHEK;;;AGET;EACI,MHwDkB;;;AGlDtB;EACI;;AAIA;EACI;;AADJ;EACI;;AADJ;EACI;;AAIR;EACI;;AAOY;EACI;;AADJ;EACI;;AAKZ;EACI;;AAPI;EACI;;AADJ;EACI;;AAKZ;EACI;;AAPI;EACI;;AADJ;EACI;;AAKZ;EACI;;AAQA;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;;AHhBpB;EACI;;;AI1CJ;EACI;;;AAGJ;EACI;;;AAGJ;EACI,aJwHa;;;AIpHb;EACI;;AAGI;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;;AAEA;EACI;;AAMA;EACI;;AAIA;EACI;;AAIR;EACI;;;AAnEhB;EACI;;AAGI;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;;AAIR;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAEA;EACI;;AAIR;EACI;;AAEA;EACI;;AAMA;EACI;;AAIA;EACI;;AAIR;EACI;;;AJvCpB;EACI;;;AKzCI;EACI;;AADJ;EACI;;AADJ;EACI;;;ALuCZ;EACI;;;AM3CJ;EA0BI;EACA;;AAxBQ;EACI;EACA;;AAFJ;EACI;EACA;;AAFJ;EACI;EACA;;AAOJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AAOJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;AADJ;EACI;;;ACtBZ;EACI;;AAEJ;EACI;;;AAKJ;EACI;;AAEJ;EACI;;;ACdR;EACI;EACA;EACA;;;AAGJ;AAAA;EAEI;EACA;EACA;EACA;;;AAGJ;EACI;;;AAGJ;EACI;;;ARyBJ;EACI;;;ASvCA;EACI;IACI;IACA,KT2HK;;;ASvHb;EACI;EACA;EACA;;AAGJ;EACI;EACA;EACA;;AAEA;EACI;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;EACA;;;AAMhB;EACI;;AAEA;EACI;;;AAMR;EACI;;AAEA;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAEA;EACI;;;AAQxB;EACI;;;AAOI;EACI;EACA;EACA;;AAIA;EACI;;AAMR;EACI;;;AAOZ;EACI,eT+DqB;ES9DrB;;AAGI;EACI;;AAGJ;EACI;;AAEA;EACI;;;AASZ;EAEI;EAEA;;AAGA;EACI;;AAIJ;EACI;;AAMA;EACI;;AASR;EACI;;AAMA;EACI;;AASR;EACI;;;AA/CR;EAEI;EAEA;;AAGA;EACI;;AAIJ;EACI;;AAMA;EACI;;AASR;EACI;;AAMA;EACI;;AASR;EACI;;;AAWJ;EACI;EACA;;AAIJ;EACI;;AAKA;EACI;;AAIJ;EACI;;;AAlBR;EACI;EACA;;AAIJ;EACI;;AAKA;EACI;;AAIJ;EACI;;;AASZ;EAEI;EACA;;AAGA;EACI;;AAIJ;EACI;;AAIJ;EACI;;AAIJ;EACI;;AAQJ;EACI;;AASA;EACI;;AASR;EACI;EACA;;AAGA;EACI;;AAQJ;EACI;;;AAjEZ;EAEI;EACA;;AAGA;EACI;;AAIJ;EACI;;AAIJ;EACI;;AAIJ;EACI;;AAQJ;EACI;;AASA;EACI;;AASR;EACI;EACA;;AAGA;EACI;;AAQJ;EACI;;;ATlPhB;EACI;;;AU1CJ;EACI;;;AAIA;EACI;;AAGJ;EACI;;;AAKJ;EACI;;AAGJ;EACI;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;;AAIA;EACI;;AAIR;EACI;;AAEA;EACI;;AAIR;EACI;;AAGJ;EACI;EACA;EACA;EACA;EACA;;;AAmBA;EACI,kBAfe;;AAmBf;EACI,OAnBQ;;AAsBZ;EACI,OAtBc;;AA0BtB;EACI,kBA1BgB;;AA6BpB;EACI,kBA7Ba;;AAgCjB;EACI,kBAhCc;;AAkCd;EACI,kBAlCgB;;;AAQxB;EACI,kBAfe;;AAmBf;EACI,OAnBQ;;AAsBZ;EACI,OAtBc;;AA0BtB;EACI,kBA1BgB;;AA6BpB;EACI,kBA7Ba;;AAgCjB;EACI,kBAhCc;;AAkCd;EACI,kBAlCgB;;;AVvChC;EACI;;;AW3CJ;EACI;EACA;;;AAGJ;EACI;EACA;;;AAGJ;EACI;EACA;;;AAGJ;EACI;EACA;;;AXyBJ;EACI;;;AYxCA;EACI;;AAGJ;EACI;;;AAMR;EACC,QZNS;;;AYSV;EACC,QZXQ;;;AYgBP;EACC;;AAGD;EACC;EACA;;AAEA;EACC;EACA;;AAGD;EACC;EACA;EACA;;AAGD;EACC;EACA;EACA;;;AAQJ;EACC;IACC;;EAED;IACC;;;AAGF;EACC;IACC;;EAED;IACC;;;AAGF;EACC;IACC;;EAED;IACC;;;AAGF;EACC;IACC;;EAED;IACC;;;AAGF;EACC;IACC;;EAED;IACC;;;AAGF;EACC;IACC;;EAED;IACC;;;ACpGE;EACI;EACA;;AAGJ;EACI;;AAGJ;EACI;EACA;;;AbgCR;EACI;;;Ac1CJ;EACI;;AAEA;EACI;;AAEA;EACI;;AAKJ;EACI;EACA;EACA;;AAGJ;EACI;;AAIR;EACI;;AAEA;EACI;;AAGJ;EACI;;;AA0ER;EACI;;AAEA;EACI;;AAGJ;EACI;;AAKJ;EACI;;AAIA;EACI;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAKJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAMR;EACI;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;;AA9EZ;EACI;;AAEA;EACI;;AAGJ;EACI;;AAKJ;EACI;;AAIA;EACI;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;AAKJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAGJ;EACI;EACA;;AAEA;EACI;EACA;;AAMR;EACI;EACA;;AAEA;EACI;EACA;;AAIR;EACI;EACA;;;Ad9IhB;EACI;;;Ae1CJ;EACI,Of8Fc;;;Ae3FlB;EACI,ef0Fc;EezFd;EACA;;;AAKI;EACI;;AAEA;EACI;;;AAJR;EACI;;AAEA;EACI;;;AfyBhB;EACI;;;AgBzCA;EACI;;AAEA;EACI;;;AASJ;EACI;;AAEA;EACI;;AAIR;EACI,kBAZG;;;AAGP;EACI;;AAEA;EACI;;AAIR;EACI,kBAZG;;;AAkBX;EACI;EACA;;AAGJ;EACI;;;ACtCR;AAAA;AAAA;AAAA;AAAA;AAaA;EACE;EACA,aATqB;EAUrB,KANyB;;AAS3B;AAAA;AAAA;EAGE;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;;;AAygEA;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH;;;AAsgEpB;EAAuB,SAtgEH","file":"base.css"} diff --git a/BaCa2/assets/css/fonts/bootstrap-icons.woff b/BaCa2/assets/css/fonts/bootstrap-icons.woff new file mode 100644 index 00000000..ae24c650 Binary files /dev/null and b/BaCa2/assets/css/fonts/bootstrap-icons.woff differ diff --git a/BaCa2/assets/css/fonts/bootstrap-icons.woff2 b/BaCa2/assets/css/fonts/bootstrap-icons.woff2 new file mode 100644 index 00000000..fff9d0cf Binary files /dev/null and b/BaCa2/assets/css/fonts/bootstrap-icons.woff2 differ diff --git a/BaCa2/assets/css/jquery.datetimepicker.min.css b/BaCa2/assets/css/jquery.datetimepicker.min.css new file mode 100644 index 00000000..e3e02e2b --- /dev/null +++ b/BaCa2/assets/css/jquery.datetimepicker.min.css @@ -0,0 +1 @@ +.xdsoft_datetimepicker{box-shadow:0 5px 15px -5px rgba(0,0,0,0.506);background:#fff;border-bottom:1px solid #bbb;border-left:1px solid #ccc;border-right:1px solid #ccc;border-top:1px solid #ccc;color:#333;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif;padding:8px;padding-left:0;padding-top:2px;position:absolute;z-index:9999;-moz-box-sizing:border-box;box-sizing:border-box;display:none}.xdsoft_datetimepicker.xdsoft_rtl{padding:8px 0 8px 8px}.xdsoft_datetimepicker iframe{position:absolute;left:0;top:0;width:75px;height:210px;background:transparent;border:0}.xdsoft_datetimepicker button{border:none !important}.xdsoft_noselect{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;-o-user-select:none;user-select:none}.xdsoft_noselect::selection{background:transparent}.xdsoft_noselect::-moz-selection{background:transparent}.xdsoft_datetimepicker.xdsoft_inline{display:inline-block;position:static;box-shadow:none}.xdsoft_datetimepicker *{-moz-box-sizing:border-box;box-sizing:border-box;padding:0;margin:0}.xdsoft_datetimepicker .xdsoft_datepicker,.xdsoft_datetimepicker .xdsoft_timepicker{display:none}.xdsoft_datetimepicker .xdsoft_datepicker.active,.xdsoft_datetimepicker .xdsoft_timepicker.active{display:block}.xdsoft_datetimepicker .xdsoft_datepicker{width:224px;float:left;margin-left:8px}.xdsoft_datetimepicker.xdsoft_rtl .xdsoft_datepicker{float:right;margin-right:8px;margin-left:0}.xdsoft_datetimepicker.xdsoft_showweeks .xdsoft_datepicker{width:256px}.xdsoft_datetimepicker .xdsoft_timepicker{width:58px;float:left;text-align:center;margin-left:8px;margin-top:0}.xdsoft_datetimepicker.xdsoft_rtl .xdsoft_timepicker{float:right;margin-right:8px;margin-left:0}.xdsoft_datetimepicker .xdsoft_datepicker.active+.xdsoft_timepicker{margin-top:8px;margin-bottom:3px}.xdsoft_datetimepicker .xdsoft_monthpicker{position:relative;text-align:center}.xdsoft_datetimepicker .xdsoft_label i,.xdsoft_datetimepicker .xdsoft_prev,.xdsoft_datetimepicker .xdsoft_next,.xdsoft_datetimepicker .xdsoft_today_button{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAeCAYAAADaW7vzAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6Q0NBRjI1NjM0M0UwMTFFNDk4NkFGMzJFQkQzQjEwRUIiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6Q0NBRjI1NjQ0M0UwMTFFNDk4NkFGMzJFQkQzQjEwRUIiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpDQ0FGMjU2MTQzRTAxMUU0OTg2QUYzMkVCRDNCMTBFQiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpDQ0FGMjU2MjQzRTAxMUU0OTg2QUYzMkVCRDNCMTBFQiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/PoNEP54AAAIOSURBVHja7Jq9TsMwEMcxrZD4WpBYeKUCe+kTMCACHZh4BFfHO/AAIHZGFhYkBBsSEqxsLCAgXKhbXYOTxh9pfJVP+qutnZ5s/5Lz2Y5I03QhWji2GIcgAokWgfCxNvcOCCGKqiSqhUp0laHOne05vdEyGMfkdxJDVjgwDlEQgYQBgx+ULJaWSXXS6r/ER5FBVR8VfGftTKcITNs+a1XpcFoExREIDF14AVIFxgQUS+h520cdud6wNkC0UBw6BCO/HoCYwBhD8QCkQ/x1mwDyD4plh4D6DDV0TAGyo4HcawLIBBSLDkHeH0Mg2yVP3l4TQMZQDDsEOl/MgHQqhMNuE0D+oBh0CIr8MAKyazBH9WyBuKxDWgbXfjNf32TZ1KWm/Ap1oSk/R53UtQ5xTh3LUlMmT8gt6g51Q9p+SobxgJQ/qmsfZhWywGFSl0yBjCLJCMgXail3b7+rumdVJ2YRss4cN+r6qAHDkPWjPjdJCF4n9RmAD/V9A/Wp4NQassDjwlB6XBiCxcJQWmZZb8THFilfy/lfrTvLghq2TqTHrRMTKNJ0sIhdo15RT+RpyWwFdY96UZ/LdQKBGjcXpcc1AlSFEfLmouD+1knuxBDUVrvOBmoOC/rEcN7OQxKVeJTCiAdUzUJhA2Oez9QTkp72OTVcxDcXY8iKNkxGAJXmJCOQwOa6dhyXsOa6XwEGAKdeb5ET3rQdAAAAAElFTkSuQmCC)}.xdsoft_datetimepicker .xdsoft_label i{opacity:.5;background-position:-92px -19px;display:inline-block;width:9px;height:20px;vertical-align:middle}.xdsoft_datetimepicker .xdsoft_prev{float:left;background-position:-20px 0}.xdsoft_datetimepicker .xdsoft_today_button{float:left;background-position:-70px 0;margin-left:5px}.xdsoft_datetimepicker .xdsoft_next{float:right;background-position:0 0}.xdsoft_datetimepicker .xdsoft_next,.xdsoft_datetimepicker .xdsoft_prev,.xdsoft_datetimepicker .xdsoft_today_button{background-color:transparent;background-repeat:no-repeat;border:0 none;cursor:pointer;display:block;height:30px;opacity:.5;-ms-filter:"alpha(opacity=50)";outline:medium none;overflow:hidden;padding:0;position:relative;text-indent:100%;white-space:nowrap;width:20px;min-width:0}.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_prev,.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_next{float:none;background-position:-40px -15px;height:15px;width:30px;display:block;margin-left:14px;margin-top:7px}.xdsoft_datetimepicker.xdsoft_rtl .xdsoft_timepicker .xdsoft_prev,.xdsoft_datetimepicker.xdsoft_rtl .xdsoft_timepicker .xdsoft_next{float:none;margin-left:0;margin-right:14px}.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_prev{background-position:-40px 0;margin-bottom:7px;margin-top:0}.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box{height:151px;overflow:hidden;border-bottom:1px solid #ddd}.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div{background:#f5f5f5;border-top:1px solid #ddd;color:#666;font-size:12px;text-align:center;border-collapse:collapse;cursor:pointer;border-bottom-width:0;height:25px;line-height:25px}.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div:first-child{border-top-width:0}.xdsoft_datetimepicker .xdsoft_today_button:hover,.xdsoft_datetimepicker .xdsoft_next:hover,.xdsoft_datetimepicker .xdsoft_prev:hover{opacity:1;-ms-filter:"alpha(opacity=100)"}.xdsoft_datetimepicker .xdsoft_label{display:inline;position:relative;z-index:9999;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:bold;background-color:#fff;float:left;width:182px;text-align:center;cursor:pointer}.xdsoft_datetimepicker .xdsoft_label:hover>span{text-decoration:underline}.xdsoft_datetimepicker .xdsoft_label:hover i{opacity:1.0}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select{border:1px solid #ccc;position:absolute;right:0;top:30px;z-index:101;display:none;background:#fff;max-height:160px;overflow-y:hidden}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select.xdsoft_monthselect{right:-7px}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select.xdsoft_yearselect{right:2px}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select>div>.xdsoft_option:hover{color:#fff;background:#ff8000}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select>div>.xdsoft_option{padding:2px 10px 2px 5px;text-decoration:none !important}.xdsoft_datetimepicker .xdsoft_label>.xdsoft_select>div>.xdsoft_option.xdsoft_current{background:#3af;box-shadow:#178fe5 0 1px 3px 0 inset;color:#fff;font-weight:700}.xdsoft_datetimepicker .xdsoft_month{width:100px;text-align:right}.xdsoft_datetimepicker .xdsoft_calendar{clear:both}.xdsoft_datetimepicker .xdsoft_year{width:48px;margin-left:5px}.xdsoft_datetimepicker .xdsoft_calendar table{border-collapse:collapse;width:100%}.xdsoft_datetimepicker .xdsoft_calendar td>div{padding-right:5px}.xdsoft_datetimepicker .xdsoft_calendar th{height:25px}.xdsoft_datetimepicker .xdsoft_calendar td,.xdsoft_datetimepicker .xdsoft_calendar th{width:14.2857142%;background:#f5f5f5;border:1px solid #ddd;color:#666;font-size:12px;text-align:right;vertical-align:middle;padding:0;border-collapse:collapse;cursor:pointer;height:25px}.xdsoft_datetimepicker.xdsoft_showweeks .xdsoft_calendar td,.xdsoft_datetimepicker.xdsoft_showweeks .xdsoft_calendar th{width:12.5%}.xdsoft_datetimepicker .xdsoft_calendar th{background:#f1f1f1}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_today{color:#3af}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_highlighted_default{background:#ffe9d2;box-shadow:#ffb871 0 1px 4px 0 inset;color:#000}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_highlighted_mint{background:#c1ffc9;box-shadow:#00dd1c 0 1px 4px 0 inset;color:#000}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_default,.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_current,.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div.xdsoft_current{background:#3af;box-shadow:#178fe5 0 1px 3px 0 inset;color:#fff;font-weight:700}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_other_month,.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_disabled,.xdsoft_datetimepicker .xdsoft_time_box>div>div.xdsoft_disabled{opacity:.5;-ms-filter:"alpha(opacity=50)";cursor:default}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_other_month.xdsoft_disabled{opacity:.2;-ms-filter:"alpha(opacity=20)"}.xdsoft_datetimepicker .xdsoft_calendar td:hover,.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div:hover{color:#fff !important;background:#ff8000 !important;box-shadow:none !important}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_current.xdsoft_disabled:hover,.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div.xdsoft_current.xdsoft_disabled:hover{background:#3af !important;box-shadow:#178fe5 0 1px 3px 0 inset !important;color:#fff !important}.xdsoft_datetimepicker .xdsoft_calendar td.xdsoft_disabled:hover,.xdsoft_datetimepicker .xdsoft_timepicker .xdsoft_time_box>div>div.xdsoft_disabled:hover{color:inherit !important;background:inherit !important;box-shadow:inherit !important}.xdsoft_datetimepicker .xdsoft_calendar th{font-weight:700;text-align:center;color:#999;cursor:default}.xdsoft_datetimepicker .xdsoft_copyright{color:#ccc !important;font-size:10px;clear:both;float:none;margin-left:8px}.xdsoft_datetimepicker .xdsoft_copyright a{color:#eee !important}.xdsoft_datetimepicker .xdsoft_copyright a:hover{color:#aaa !important}.xdsoft_time_box{position:relative;border:1px solid #ccc}.xdsoft_scrollbar>.xdsoft_scroller{background:#ccc !important;height:20px;border-radius:3px}.xdsoft_scrollbar{position:absolute;width:7px;right:0;top:0;bottom:0;cursor:pointer}.xdsoft_datetimepicker.xdsoft_rtl .xdsoft_scrollbar{left:0;right:auto}.xdsoft_scroller_box{position:relative}.xdsoft_datetimepicker.xdsoft_dark{box-shadow:0 5px 15px -5px rgba(255,255,255,0.506);background:#000;border-bottom:1px solid #444;border-left:1px solid #333;border-right:1px solid #333;border-top:1px solid #333;color:#ccc}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_timepicker .xdsoft_time_box{border-bottom:1px solid #222}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_timepicker .xdsoft_time_box>div>div{background:#0a0a0a;border-top:1px solid #222;color:#999}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_label{background-color:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_label>.xdsoft_select{border:1px solid #333;background:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_label>.xdsoft_select>div>.xdsoft_option:hover{color:#000;background:#007fff}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_label>.xdsoft_select>div>.xdsoft_option.xdsoft_current{background:#c50;box-shadow:#b03e00 0 1px 3px 0 inset;color:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_label i,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_prev,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_next,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_today_button{background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAGQAAAAeCAYAAADaW7vzAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAyJpVFh0WE1MOmNvbS5hZG9iZS54bXAAAAAAADw/eHBhY2tldCBiZWdpbj0i77u/IiBpZD0iVzVNME1wQ2VoaUh6cmVTek5UY3prYzlkIj8+IDx4OnhtcG1ldGEgeG1sbnM6eD0iYWRvYmU6bnM6bWV0YS8iIHg6eG1wdGs9IkFkb2JlIFhNUCBDb3JlIDUuMy1jMDExIDY2LjE0NTY2MSwgMjAxMi8wMi8wNi0xNDo1NjoyNyAgICAgICAgIj4gPHJkZjpSREYgeG1sbnM6cmRmPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5LzAyLzIyLXJkZi1zeW50YXgtbnMjIj4gPHJkZjpEZXNjcmlwdGlvbiByZGY6YWJvdXQ9IiIgeG1sbnM6eG1wPSJodHRwOi8vbnMuYWRvYmUuY29tL3hhcC8xLjAvIiB4bWxuczp4bXBNTT0iaHR0cDovL25zLmFkb2JlLmNvbS94YXAvMS4wL21tLyIgeG1sbnM6c3RSZWY9Imh0dHA6Ly9ucy5hZG9iZS5jb20veGFwLzEuMC9zVHlwZS9SZXNvdXJjZVJlZiMiIHhtcDpDcmVhdG9yVG9vbD0iQWRvYmUgUGhvdG9zaG9wIENTNiAoV2luZG93cykiIHhtcE1NOkluc3RhbmNlSUQ9InhtcC5paWQ6QUExQUUzOTA0M0UyMTFFNDlBM0FFQTJENTExRDVBODYiIHhtcE1NOkRvY3VtZW50SUQ9InhtcC5kaWQ6QUExQUUzOTE0M0UyMTFFNDlBM0FFQTJENTExRDVBODYiPiA8eG1wTU06RGVyaXZlZEZyb20gc3RSZWY6aW5zdGFuY2VJRD0ieG1wLmlpZDpBQTFBRTM4RTQzRTIxMUU0OUEzQUVBMkQ1MTFENUE4NiIgc3RSZWY6ZG9jdW1lbnRJRD0ieG1wLmRpZDpBQTFBRTM4RjQzRTIxMUU0OUEzQUVBMkQ1MTFENUE4NiIvPiA8L3JkZjpEZXNjcmlwdGlvbj4gPC9yZGY6UkRGPiA8L3g6eG1wbWV0YT4gPD94cGFja2V0IGVuZD0iciI/Pp0VxGEAAAIASURBVHja7JrNSgMxEMebtgh+3MSLr1T1Xn2CHoSKB08+QmR8Bx9A8e7RixdB9CKCoNdexIugxFlJa7rNZneTbLIpM/CnNLsdMvNjM8l0mRCiQ9Ye61IKCAgZAUnH+mU3MMZaHYChBnJUDzWOFZdVfc5+ZFLbrWDeXPwbxIqrLLfaeS0hEBVGIRQCEiZoHQwtlGSByCCdYBl8g8egTTAWoKQMRBRBcZxYlhzhKegqMOageErsCHVkk3hXIFooDgHB1KkHIHVgzKB4ADJQ/A1jAFmAYhkQqA5TOBtocrKrgXwQA8gcFIuAIO8sQSA7hidvPwaQGZSaAYHOUWJABhWWw2EMIH9QagQERU4SArJXo0ZZL18uvaxejXt/Em8xjVBXmvFr1KVm/AJ10tRe2XnraNqaJvKE3KHuUbfK1E+VHB0q40/y3sdQSxY4FHWeKJCunP8UyDdqJZenT3ntVV5jIYCAh20vT7ioP8tpf6E2lfEMwERe+whV1MHjwZB7PBiCxcGQWwKZKD62lfGNnP/1poFAA60T7rF1UgcKd2id3KDeUS+oLWV8DfWAepOfq00CgQabi9zjcgJVYVD7PVzQUAUGAQkbNJTBICDhgwYTjDYD6XeW08ZKh+A4pYkzenOxXUbvZcWz7E8ykRMnIHGX1XPl+1m2vPYpL+2qdb8CDAARlKFEz/ZVkAAAAABJRU5ErkJggg==)}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar th{background:#0a0a0a;border:1px solid #222;color:#999}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar th{background:#0e0e0e}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td.xdsoft_today{color:#c50}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td.xdsoft_highlighted_default{background:#ffe9d2;box-shadow:#ffb871 0 1px 4px 0 inset;color:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td.xdsoft_highlighted_mint{background:#c1ffc9;box-shadow:#00dd1c 0 1px 4px 0 inset;color:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td.xdsoft_default,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td.xdsoft_current,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_timepicker .xdsoft_time_box>div>div.xdsoft_current{background:#c50;box-shadow:#b03e00 0 1px 3px 0 inset;color:#000}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar td:hover,.xdsoft_datetimepicker.xdsoft_dark .xdsoft_timepicker .xdsoft_time_box>div>div:hover{color:#000 !important;background:#007fff !important}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_calendar th{color:#666}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_copyright{color:#333 !important}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_copyright a{color:#111 !important}.xdsoft_datetimepicker.xdsoft_dark .xdsoft_copyright a:hover{color:#555 !important}.xdsoft_dark .xdsoft_time_box{border:1px solid #333}.xdsoft_dark .xdsoft_scrollbar>.xdsoft_scroller{background:#333 !important}.xdsoft_datetimepicker .xdsoft_save_selected{display:block;border:1px solid #ddd !important;margin-top:5px;width:100%;color:#454551;font-size:13px}.xdsoft_datetimepicker .blue-gradient-button{font-family:"museo-sans","Book Antiqua",sans-serif;font-size:12px;font-weight:300;color:#82878c;height:28px;position:relative;padding:4px 17px 4px 33px;border:1px solid #d7d8da;background:-moz-linear-gradient(top,#fff 0,#f4f8fa 73%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#fff),color-stop(73%,#f4f8fa));background:-webkit-linear-gradient(top,#fff 0,#f4f8fa 73%);background:-o-linear-gradient(top,#fff 0,#f4f8fa 73%);background:-ms-linear-gradient(top,#fff 0,#f4f8fa 73%);background:linear-gradient(to bottom,#fff 0,#f4f8fa 73%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff',endColorstr='#f4f8fa',GradientType=0)}.xdsoft_datetimepicker .blue-gradient-button:hover,.xdsoft_datetimepicker .blue-gradient-button:focus,.xdsoft_datetimepicker .blue-gradient-button:hover span,.xdsoft_datetimepicker .blue-gradient-button:focus span{color:#454551;background:-moz-linear-gradient(top,#f4f8fa 0,#FFF 73%);background:-webkit-gradient(linear,left top,left bottom,color-stop(0,#f4f8fa),color-stop(73%,#FFF));background:-webkit-linear-gradient(top,#f4f8fa 0,#FFF 73%);background:-o-linear-gradient(top,#f4f8fa 0,#FFF 73%);background:-ms-linear-gradient(top,#f4f8fa 0,#FFF 73%);background:linear-gradient(to bottom,#f4f8fa 0,#FFF 73%);filter:progid:DXImageTransform.Microsoft.gradient(startColorstr='#f4f8fa',endColorstr='#FFF',GradientType=0)} diff --git a/BaCa2/assets/css/pdf_displayer.css b/BaCa2/assets/css/pdf_displayer.css new file mode 100644 index 00000000..c6e5339e --- /dev/null +++ b/BaCa2/assets/css/pdf_displayer.css @@ -0,0 +1,3 @@ + + +/*# sourceMappingURL=pdf_displayer.css.map */ diff --git a/BaCa2/assets/css/pdf_displayer.css.map b/BaCa2/assets/css/pdf_displayer.css.map new file mode 100644 index 00000000..e8120608 --- /dev/null +++ b/BaCa2/assets/css/pdf_displayer.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"pdf_displayer.css"} \ No newline at end of file diff --git a/BaCa2/assets/css/table.css b/BaCa2/assets/css/table.css new file mode 100644 index 00000000..f0b01be1 --- /dev/null +++ b/BaCa2/assets/css/table.css @@ -0,0 +1,3 @@ + + +/*# sourceMappingURL=table.css.map */ diff --git a/BaCa2/assets/css/table.css.map b/BaCa2/assets/css/table.css.map new file mode 100644 index 00000000..44925ec0 --- /dev/null +++ b/BaCa2/assets/css/table.css.map @@ -0,0 +1 @@ +{"version":3,"sourceRoot":"","sources":[],"names":[],"mappings":"","file":"table.css"} diff --git a/BaCa2/assets/img/datetime_picker_icons_black.png b/BaCa2/assets/img/datetime_picker_icons_black.png new file mode 100644 index 00000000..b54bc08f Binary files /dev/null and b/BaCa2/assets/img/datetime_picker_icons_black.png differ diff --git a/BaCa2/assets/img/datetime_picker_icons_white.png b/BaCa2/assets/img/datetime_picker_icons_white.png new file mode 100644 index 00000000..44036abf Binary files /dev/null and b/BaCa2/assets/img/datetime_picker_icons_white.png differ diff --git a/BaCa2/assets/img/favicon.png b/BaCa2/assets/img/favicon.png new file mode 100644 index 00000000..a8594629 Binary files /dev/null and b/BaCa2/assets/img/favicon.png differ diff --git a/BaCa2/assets/js/base.js b/BaCa2/assets/js/base.js new file mode 100644 index 00000000..e2fa0b34 --- /dev/null +++ b/BaCa2/assets/js/base.js @@ -0,0 +1,27 @@ +function preInitCommon() { + formsPreSetup(); + tablesPreSetup(); + themePreSetup(); +} + +function initCommon() { + if (!window.location.href.includes("login")) { + sessionStorage.removeItem("loginFormRefresh"); + sessionStorage.removeItem("loginFormAlert"); + } + buttonsSetup(); + tablesSetup(); + formsSetup(); + sideNavSetup(); + themeSetup(); +} + +function showPage() { + $(".main-container").show(); +} + +function generateFormattedString(data, formatString) { + return formatString.replace(/\[\[(\w+)]]/g, function (match, key) { + return data[key].toString() || match; + }); +} diff --git a/BaCa2/assets/js/buttons.js b/BaCa2/assets/js/buttons.js new file mode 100644 index 00000000..3448e833 --- /dev/null +++ b/BaCa2/assets/js/buttons.js @@ -0,0 +1,30 @@ +function setUpTextSwitchBtn(btn) { + const textOn = btn.data('text-on'); + const textOff = btn.data('text-off'); + const longerText = textOn.length > textOff.length ? textOn : textOff; + const temp = $('').css({visibility: 'hidden', whiteSpace: 'nowrap'}).text(longerText); + + $('body').append(temp); + btn.width(temp.width()); + temp.remove(); + + if (btn.hasClass('switch-off')) + btn.text(textOff); + else + btn.text(textOn); +} + +function toggleTextSwitchBtn(btn) { + if (btn.hasClass('switch-on')) + btn.removeClass('switch-on').addClass('switch-off'); + else + btn.removeClass('switch-off').addClass('switch-on'); + + btn.text(btn.hasClass('switch-on') ? btn.data('text-on') : btn.data('text-off')) +} + +function buttonsSetup() { + $('.text-switch-btn').each(function () { + setUpTextSwitchBtn($(this)) + }) +} diff --git a/BaCa2/BaCa2/__init__.py b/BaCa2/assets/js/codeblock.js similarity index 100% rename from BaCa2/BaCa2/__init__.py rename to BaCa2/assets/js/codeblock.js diff --git a/BaCa2/assets/js/forms.js b/BaCa2/assets/js/forms.js new file mode 100644 index 00000000..20161288 --- /dev/null +++ b/BaCa2/assets/js/forms.js @@ -0,0 +1,1181 @@ +class FormInput { + constructor(input) { + this.input = input; + this.inputId = input.attr('id'); + this.required = input.prop('required'); + this.defaultVal = this.getDefaultVal(); + this.liveValidation = input.data('live-validation'); + this.toggleBtn = input.closest('.input-group').find('.field-toggle-btn'); + this.toggleable = this.toggleBtn.length > 0; + + const label = input.closest('form').find(`label[for="${this.inputId}"`) + this.label = label.length > 0 ? label : null; + } + + formInputInit() { + this.toggleInit(); + } + + toggleInit() { + if (!this.toggleable) return; + const formInput = this; + formInput.resetToggleable(); + + this.toggleBtn.on('click', function (e) { + e.preventDefault(); + console.log('toggle clicked'); + toggleTextSwitchBtn($(this)); + formInput.toggleInput($(this).hasClass('switch-on'), true); + }); + } + + getLabel() { + if (!this.label) return this.inputId; + let label = this.label + + if (this.label.find('.required-symbol').length > 0) { + label = label.clone(); + label.find('.required-symbol').remove(); + } + + return label.text().trim(); + } + + getDefaultVal() { + const defaultVal = this.input.prop('defaultValue'); + return defaultVal !== undefined ? defaultVal : ''; + } + + getVal() { + return this.input.val(); + } + + hasValue() { + const val = this.getVal(); + return val !== undefined && val !== null && val.length > 0; + } + + setValid() { + if (!this.liveValidation) return; + this.input.removeClass('is-invalid').addClass('is-valid'); + } + + setInvalid() { + if (!this.liveValidation) return; + this.input.removeClass('is-valid').addClass('is-invalid'); + } + + clearValidation() { + this.input.removeClass('is-valid').removeClass('is-invalid'); + this.input.closest('.input-block').find('.invalid-feedback').remove(); + } + + isValid() { + if (this.input.hasClass('is-invalid')) return false; + return !(this.required && !this.hasValue()); + } + + resetValue() { + if (this.defaultVal.length > 0) { + this.input.val(this.defaultVal); + this.setValid(); + } else + this.clearValue(); + } + + clearValue() { + this.input.val(''); + this.clearValidation(); + } + + resetInput() { + this.resetValue(); + this.resetToggleable(); + } + + resetToggleable() { + if (!this.toggleable) return; + let on = this.toggleBtn.data('initial-state') !== 'off'; + + if (this.toggleBtn.hasClass('switch-on') && !on) + toggleTextSwitchBtn(this.toggleBtn); + + this.toggleInput(on); + } + + toggleInput(on, focus = false) { + if (on) { + this.input.attr('disabled', false); + this.resetValue(); + if (focus) this.input.focus(); + } else { + this.clearValue() + this.input.attr('disabled', true); + } + } +} + + +class SelectInput extends FormInput { + getDefaultVal() { + const selectedOption = this.input.find('option:selected'); + return selectedOption.length > 0 ? selectedOption.val() : ''; + } +} + + +class TableSelectField extends FormInput { + constructor(input) { + super(input); + this.wrapper = input.closest('.table-select-field'); + this.tableId = input.data('table-id'); + this.tableWidget = window.tableWidgets[`#${this.tableId}`]; + } + + setValid() { + if (!this.liveValidation) + return; + this.input.removeClass('is-invalid').addClass('is-valid'); + this.wrapper.removeClass('is-invalid').addClass('is-valid'); + } + + setInvalid() { + if (!this.liveValidation) + return; + this.input.removeClass('is-valid').addClass('is-invalid'); + this.wrapper.removeClass('is-valid').addClass('is-invalid'); + } + + clearValidation() { + this.input.removeClass('is-valid').removeClass('is-invalid'); + this.wrapper.removeClass('is-valid').removeClass('is-invalid'); + } +} + + +class FormPopup { + constructor(popup, formWidget) { + this.popup = popup; + this.formWidget = formWidget; + } + + render(data = null) { + this.popup.modal('show'); + } +} + + +class ConfirmationPopup extends FormPopup { + constructor(popup, formWidget) { + super(popup, formWidget); + const inputSummary = {}; + + this.popup.find('.input-summary').each(function () { + const inputId = $(this).data('input-target'); + const inputLabel = $(this).find('.input-summary-label'); + const inputVal = $(this).find('.input-summary-value'); + inputSummary[inputId] = {label: inputLabel, value: inputVal}; + }); + + this.inputSummary = inputSummary; + this.submitBtn = this.popup.find('.submit-btn'); + } + + render(data = null) { + const inputSummary = this.inputSummary; + + this.formWidget.getInputs(Object.keys(this.inputSummary)).each(function () { + const summary = inputSummary[this.inputId]; + const val = this.getVal(); + summary.label.text(this.getLabel() + ':'); + summary.value.text(val.length > 0 ? val : '-'); + }); + + super.render(); + } +} + + +class ResponsePopup extends FormPopup { + constructor(popup, formWidget) { + super(popup, formWidget); + this.message = this.popup.find('.popup-message'); + this.messageBlock = this.popup.find('.popup-message-wrapper'); + this.renderMessage = this.message.data('render-message') === true; + } + + render(data) { + if (this.renderMessage) + this.message.text(data.message); + if (data['status'] === 'invalid') + this.renderValidationErrors(data); + else if (data['status'] === 'error') + this.renderErrorMessages(data); + super.render(data); + } + + renderValidationErrors(data) { + this.popup.find('.popup-errors-wrapper').remove(); + const errorsBlock = $(''); + + Object.entries(data.errors).forEach(([key, value]) => { + const errorDiv = $(''); + const nestedList = $(''); + const fieldLabel = this.formWidget.inputs[key].getLabel(); + + + value.forEach((nestedValue) => { + nestedList.append(`
  • ${nestedValue}
  • `); + }); + + errorDiv.append(`${fieldLabel}:`); + errorDiv.append(nestedList); + errorsBlock.append(errorDiv); + }); + + this.messageBlock.after(errorsBlock); + } + + renderErrorMessages(data) { + this.popup.find('.popup-errors-wrapper').remove(); + const errorsBlock = $(''); + + data.errors.forEach((error) => { + errorsBlock.append(``); + }); + + this.messageBlock.after(errorsBlock); + } +} + + +class FormWidget { + constructor(form) { + this.form = form; + this.wrapper = form.closest('.form-wrapper'); + this.submitBtn = form.find('.submit-btn'); + this.ajaxSubmit = form.attr('action') !== undefined; + this.postUrl = this.ajaxSubmit ? form.attr('action') : null; + this.inputs = this.getInputs().get().reduce((dict, input) => { + dict[input.inputId] = input; + return dict; + }, {}); + + const confirmationPopup = this.wrapper.find('.form-confirmation-popup'); + this.confirmationPopup = confirmationPopup.length > 0 ? + new ConfirmationPopup(confirmationPopup, this) : null; + + this.showResponsePopups = this.form.data('show-response-popups'); + this.successPopup = this.showResponsePopups ? + new ResponsePopup(this.wrapper.find('.form-success-popup'), this) : + null; + this.failurePopup = this.showResponsePopups ? + new ResponsePopup(this.wrapper.find('.form-failure-popup'), this) : + null; + } + + formWidgetInit() { + this.submitHandlingInit(); + this.submitBtnInit(); + this.refreshBtnInit(); + this.responsePopupsInit(); + this.toggleableGroupInit(); + + for (const input of Object.values(this.inputs)) + input.formInputInit(); + } + + submitHandlingInit() { + if (!this.ajaxSubmit) return; + const formWidget = this; + + this.form.on('submit', function (e) { + e.preventDefault(); + formWidget.handleAjaxSubmit(); + }); + } + + submitBtnInit() { + if (this.confirmationPopup) { + const popup = this.confirmationPopup + const formWidget = this; + + this.submitBtn.on('click', function (e) { + e.preventDefault(); + popup.render(); + }); + + this.confirmationPopup.submitBtn.on('click', function () { + popup.popup.modal('hide'); + formWidget.submit(); + }); + } + } + + refreshBtnInit() { + const formWidget = this; + this.form.find('.form-refresh-button').on('click', function () { + formWidget.resetForm(); + }); + } + + responsePopupsInit() { + if (!this.showResponsePopups) return; + const successPopup = this.successPopup; + const failurePopup = this.failurePopup; + + this.form.on('submit-success', function (e, data) { + successPopup.render(data); + }); + + this.form.on('submit-failure', function (e, data) { + failurePopup.render(data); + }); + } + + toggleableGroupInit() { + const formWidget = this; + + this.form.find('.group-toggle-btn').each(function () { + const btn = $(this); + FormWidget.resetToggleableGroup(btn); + + + btn.on('click', function (e) { + e.preventDefault(); + toggleTextSwitchBtn(btn); + FormWidget.toggleElementGroup(btn.closest('.form-element-group'), + btn.hasClass('switch-on')); + formWidget.refreshSubmitBtn(); + }); + }); + } + + resetToggleableGroups() { + this.form.find('.group-toggle-btn').each(function () { + FormWidget.resetToggleableGroup($(this)); + }); + } + + static resetToggleableGroup(toggleBtn) { + const on = toggleBtn.data('initial-state') !== 'off'; + const elementGroup = toggleBtn.closest('.form-element-group'); + if (toggleBtn.hasClass('switch-on') && !on) toggleTextSwitchBtn(toggleBtn); + FormWidget.toggleElementGroup(elementGroup, on); + } + + static toggleElementGroup(elementGroup, on) { + FormWidget.getElementGroupInputs(elementGroup).each(function () { + this.toggleInput(on); + }); + } + + submit() { + this.form.submit(); + } + + handleAjaxSubmit() { + const formData = new FormData(this.form[0]); + const formWidget = this; + + $.ajax({ + type: 'POST', + url: formWidget.postUrl, + data: formData, + contentType: false, + processData: false, + success: function (data) { + formWidget.resetForm(); + formWidget.form.trigger('submit-complete', [data]); + + if (data.status === 'success') { + formWidget.form.trigger('submit-success', [data]); + return; + } + + formWidget.form.trigger('submit-failure', [data]); + + switch (data.status) { + case 'invalid': + formWidget.form.trigger('submit-invalid', [data]); + break; + case 'impermissible': + formWidget.form.trigger('submit-impermissible', [data]); + break; + case 'error': + formWidget.form.trigger('submit-error', [data]); + break; + } + } + }) + } + + getInputs(ids = null) { + if (ids) { + if (Array.isArray(ids)) + ids = ids.map(id => `#${id}`).join(', '); + return this.form.find(ids).map(function () { + return FormWidget.getInputObj($(this)); + }); + } + + return this.form.find('input, select, textarea').map(function () { + return FormWidget.getInputObj($(this)); + }); + } + + static getElementGroupInputs(elementGroup) { + return elementGroup.find('input, select, textarea').map(function () { + return FormWidget.getInputObj($(this)); + }); + } + + static getInputObj(inputElement) { + if (inputElement.hasClass('table-select-input')) + return new TableSelectField(inputElement); + else if (inputElement.is('select')) + return new SelectInput(inputElement); + else + return new FormInput(inputElement); + } + + refreshSubmitBtn() { + for (const input of Object.values(this.inputs)) + if (!input.isValid()) { + this.submitBtn.attr('disabled', true); + return; + } + + this.enableSubmitBtn(); + } + + enableSubmitBtn() { + const submitBtn = this.submitBtn; + + if (submitBtn.is(':disabled')) { + submitBtn.attr('disabled', false).addClass('submit-enabled'); + + setTimeout(function () { + submitBtn.removeClass('submit-enabled'); + }, 300); + } + } + + resetForm() { + for (const input of Object.values(this.inputs)) + input.resetInput(); + this.resetToggleableGroups(); + this.refreshSubmitBtn(); + } +} + + +// ---------------------------------------- forms setup --------------------------------------- // + +function formsPreSetup() { + tableSelectFieldSetup(); + + $(document).on('tab-activated', function (e) { + const tab = $(e.target); + tab.find('.model-choice-field').each(function () { + loadModelChoiceFieldOptions($(this)); + }); + }); +} + +function formsSetup() { + $('form').each(function () { + new FormWidget($(this)).formWidgetInit(); + }); + + //ajaxPostSetup(); + //toggleableGroupSetup(); + //toggleableFieldSetup(); + //confirmationPopupSetup(); + //responsePopupsSetup(); + //refreshButtonSetup(); + selectFieldSetup(); + choiceFieldSetup(); + modelChoiceFieldSetup(); + textAreaFieldSetup(); + liveValidationSetup(); + tableSelectFieldValidationSetup(); + formObserverSetup(); +} + +function ajaxPostSetup() { + $('form').filter(function () { + return $(this).attr("action") !== undefined; + }).on('submit', function (e) { + e.preventDefault(); + handleAjaxSubmit($(this)); + }); +} + +function refreshButtonSetup() { + $('form').each(function () { + const form = $(this); + form.find('.form-refresh-button').on('click', function () { + formRefresh(form); + }); + }); +} + +function liveValidationSetup() { + $('form').each(function () { + $(this).find('.live-validation').each(function () { + $(this).find('input').filter(function () { + return $(this).val() !== undefined && $(this).val().length > 0; + }).addClass('is-valid'); + + $(this).find('textarea').filter(function () { + return $(this).val() !== undefined && $(this).val().length > 0; + }).addClass('is-valid'); + + $(this).find('select').filter(function () { + return $(this).val() !== null && $(this).val().length > 0; + }).addClass('is-valid'); + }) + submitButtonRefresh($(this)); + }); +} + +function textAreaFieldSetup() { + $('.form-floating textarea').each(function () { + const rows = $(this).attr('rows'); + const height = `${rows * 2.1}rem`; + $(this).css('height', height); + }); +} + +// --------------------------------------- toggle setup --------------------------------------- // + +function toggleableFieldSetup() { + const buttons = $('.field-toggle-btn'); + + buttons.each(function () { + toggleableFieldButtonInit($(this)); + }); + + buttons.on('click', function (e) { + toggleFieldButtonClickHandler(e, $(this)); + }); +} + +function toggleableFieldButtonInit(button) { + let on = button.data('initial-state') !== 'off'; + if (button.hasClass('switch-on') && !on) + toggleTextSwitchBtn(button); + toggleField(button.closest('.input-group').find('input'), on); +} + +function toggleableGroupSetup() { + const buttons = $('.group-toggle-btn'); + + buttons.each(function () { + toggleableGroupButtonInit($(this)); + }); + + buttons.on('click', function (e) { + toggleGroupButtonClickHandler(e, $(this)); + }); +} + +function toggleableGroupButtonInit(button) { + let on = button.data('initial-state') !== 'off'; + if (button.hasClass('switch-on') && !on) + toggleTextSwitchBtn(button); + toggleFieldGroup(button.closest('.form-element-group'), on); +} + +// ---------------------------------------- popup setup --------------------------------------- // + +function confirmationPopupSetup() { + const formWrappers = $('.form-wrapper').filter(function () { + return $(this).find('.form-confirmation-popup').length > 0; + }); + + formWrappers.each(function () { + const form = $(this).find('form'); + const popup = $(this).find('.form-confirmation-popup'); + const submitBtn = form.find('.submit-btn'); + submitBtn.on('click', function (e) { + e.preventDefault(); + renderConfirmationPopup(popup, form); + }); + }); + + $('.form-confirmation-popup .submit-btn').on('click', function () { + $(this).closest('.form-confirmation-popup').modal('hide'); + $('#' + $(this).data('form-target')).submit(); + }); +} + +function responsePopupsSetup() { + const forms = $('form').filter(function () { + return $(this).data('show-response-popups'); + }); + + forms.on('submit-success', function (e, data) { + const popup = $(`#${$(this).data('submit-success-popup')}`); + renderResponsePopup(popup, data); + popup.modal('show'); + }); + + forms.on('submit-failure', function (e, data) { + const popup = $(`#${$(this).data('submit-failure-popup')}`); + renderResponsePopup(popup, data); + popup.modal('show'); + }); +} + +// ---------------------------------------- ajax submit --------------------------------------- // + +function handleAjaxSubmit(form) { + const formData = new FormData(form[0]); + $.ajax({ + type: 'POST', + url: form.attr('action'), + data: formData, + contentType: false, + processData: false, + success: function (data) { + formRefresh(form); + + form.trigger('submit-complete', [data]); + + if (data.status === 'success') + form.trigger('submit-success', [data]); + else { + form.trigger('submit-failure', [data]); + + if (data.status === 'invalid') + form.trigger('submit-invalid', [data]); + else if (data.status === 'impermissible') + form.trigger('submit-impermissible', [data]); + else if (data.status === 'error') + form.trigger('submit-error', [data]); + } + } + }); +} + +// ----------------------------------- field & group toggle ----------------------------------- // + +function toggleFieldButtonClickHandler(e, btn) { + e.preventDefault(); + toggleTextSwitchBtn(btn); + let on = false; + const input = btn.closest('.input-group').find('input'); + + if (btn.hasClass('switch-on')) + on = true; + + toggleField(input, on); + + if (on) + input.focus(); + + submitButtonRefresh(btn.closest('form')); +} + +function toggleField(field, on) { + if (on) + $(field).attr('disabled', false); + else { + field.val(''); + if (field.hasClass('is-invalid')) { + field.closest('.input-block').find('.invalid-feedback').remove(); + field.removeClass('is-invalid'); + } + field.removeClass('is-valid'); + $(field).attr('disabled', true); + } +} + +function toggleGroupButtonClickHandler(e, btn) { + e.preventDefault(); + toggleTextSwitchBtn(btn); + let on = false; + const group = btn.closest('.form-element-group'); + + if (btn.hasClass('switch-on')) + on = true; + + toggleFieldGroup(group, on); + + if (on) + group.find('input:first').focus(); + + submitButtonRefresh(btn.closest('form')); +} + +function toggleFieldGroup(formElementGroup, on) { + formElementGroup.find('input').each(function () { + toggleField($(this), on); + }); +} + +// --------------------------------------- form refresh --------------------------------------- // + +function formRefresh(form) { + // form[0].reset(); + // clearValidation(form); + // resetToggleables(form); + // submitButtonRefresh(form); + // resetHiddenFields(form); + new FormWidget(form).resetForm(); +} + +function clearValidation(form) { + form.find('input').removeClass('is-valid').removeClass('is-invalid'); + form.find('select').removeClass('is-valid').removeClass('is-invalid'); + form.find('textarea').removeClass('is-valid').removeClass('is-invalid'); + form.find('.table-select-field').removeClass('is-valid').removeClass('is-invalid'); + form.find('.invalid-feedback').remove(); +} + +function resetToggleables(form) { + form.find('.field-toggle-btn').each(function () { + toggleableFieldButtonInit($(this)); + }); + + form.find('.group-toggle-btn').each(function () { + toggleableGroupButtonInit($(this)); + }); +} + +function resetHiddenFields(form) { + form.find('input[type="hidden"]').each(function () { + if ($(this).data('reset-on-refresh') === true) + $(this).val(''); + }); + +} + +// -------------------------------------- live validation ------------------------------------- // + +function updateValidationStatus(field, formCls, formInstanceId, minLength, url) { + const value = $(field).val(); + $.ajax({ + url: url, + data: { + 'formCls': formCls, + 'form_instance_id': formInstanceId, + 'fieldName': $(field).attr('name'), + 'value': value, + 'minLength': minLength, + }, + dataType: 'json', + success: function (data) { + if (data.status === 'ok') { + $(field).removeClass('is-invalid'); + + if (value.length > 0) + $(field).addClass('is-valid'); + else + $(field).removeClass('is-valid'); + + const input_block = $(field).closest('.input-block'); + $(input_block).find('.invalid-feedback').remove(); + + submitButtonRefresh($(field).closest('form')); + } else { + $(field).removeClass('is-valid'); + $(field).addClass('is-invalid'); + + const input_block = $(field).closest('.input-block'); + $(input_block).find('.invalid-feedback').remove(); + + for (let i = 0; i < data.messages.length; i++) { + $(input_block).append( + "
    " + data.messages[i] + "
    " + ); + } + + $(field).closest('form').find('.submit-btn').attr('disabled', true); + } + + $(field).trigger('validation-complete'); + } + }); +} + +function updateSelectFieldValidationStatus(field) { + if ($(field).val().length === 0) { + $(field).removeClass('is-valid'); + $(field).addClass('is-invalid'); + $(field).closest('form').find('.submit-btn').attr('disabled', true); + } else { + $(field).removeClass('is-invalid'); + $(field).addClass('is-valid'); + submitButtonRefresh($(field).closest('form')); + } + + $(field).trigger('validation-complete'); +} + +function submitButtonRefresh(form) { + if (form.find('.live-validation').filter(function () { + return ($(this)).find('input:not(:disabled):not(.is-valid):required').length > 0 || + $(this).find('select:not(:disabled):not(.is-valid)').length > 0; + }).length > 0) + form.find('.submit-btn').attr('disabled', true); + else + enableSubmitButton(form.find('.submit-btn')); +} + +function enableSubmitButton(submitButton) { + if (submitButton.is(':disabled')) { + submitButton.attr('disabled', false); + submitButton.addClass('submit-enabled'); + + setTimeout(function () { + submitButton.removeClass('submit-enabled'); + }, 300); + } +} + +// --------------------------------------- form observer -------------------------------------- // + +function formObserverSetup() { + formObserverElementGroupSetup(); + formObserverListenerSetup(); +} + +function formObserverListenerSetup() { + $('.form-observer').each(function () { + const observer = $(this); + const formId = observer.data('form-id'); + const form = $(`#${formId}`); + + form.find('input, select, textarea').change(function () { + formObserverFieldChangeHandler(observer, form, $(this)); + }); + }); +} + +function formObserverElementGroupSetup() { + $('.form-observer[data-element-group-titles="true"]').each(function () { + const observer = $(this); + const formId = observer.data('form-id'); + const form = $(`#${formId}`); + const summary = observer.find('.observer-summary'); + + form.find('.form-element-group[data-title!=""]').each(function () { + const groupId = $(this).attr('id'); + const groupTitle = $(this).data('title'); + + summary.each(function () { + const groupSummary = $('
    '); + + groupSummary.attr('data-group-id', groupId); + groupSummary.append(`
    ${groupTitle}
    `); + groupSummary.append('
    '); + + $(this).append(groupSummary); + }); + }); + }); +} + +function formObserverFieldChangeHandler(observer, form, field) { + updateFormObserverSummary(observer, + observer.find('.observer-general-summary:first'), + form, + field); + + observer.find('.form-observer-tab-content').each(function () { + const acceptedFields = $(this).data('fields').split(' '); + + if (acceptedFields.includes(field.attr('name'))) + updateFormObserverSummary(observer, $(this), form, field); + }); +} + +function updateFormObserverSummary(observer, summary, form, field) { + let targetDiv = summary; + + if (observer.data('element-group-titles')) { + const group = getClosestTitledElementGroup(field); + + if (group !== null) { + targetDiv = summary.find(`.group-summary[data-group-id="${group.attr('id')}"]`); + targetDiv = targetDiv.find('.group-fields'); + } + } + + let fieldSummary = targetDiv.find(`.field-summary[data-field-id="${field.attr('id')}"]`); + let fieldVal = field.val(); + const fieldDefaultVal = field.prop("defaultValue"); + + if (fieldSummary.length > 0) + return updateFormObserverFieldSummary(targetDiv, fieldSummary, fieldVal, fieldDefaultVal); + + + const fieldLabel = getFieldLabel(field.attr('id'), form); + const valueSummary = $(`
    `); + + fieldVal = fieldVal === '' ? '' : fieldVal; + + fieldSummary = $('
    '); + fieldSummary.attr('data-field-id', field.attr('id')); + fieldSummary.append(`
    ${fieldLabel}:
    `); + + valueSummary.append(`
    ${fieldDefaultVal}
    `); + valueSummary.append(`
    `); + valueSummary.append(`
    ${fieldVal}
    `); + + targetDiv.prepend(fieldSummary.append(valueSummary)); + targetDiv.closest('.group-summary').addClass('has-fields'); +} + +function updateFormObserverFieldSummary(summaryDiv, summary, fieldVal, fieldDefaultVal) { + if (fieldVal === fieldDefaultVal) { + summary.remove(); + + if (summaryDiv.find('.field-summary').length === 0) + summaryDiv.closest('.group-summary').removeClass('has-fields'); + } else + summary.find('.field-value') + .text(fieldVal === '' ? '' : fieldVal); +} + +function getClosestTitledElementGroup(field) { + return field.closest('.form-element-group[data-title]:not([data-title=""])'); +} + +// ------------------------------------------ popups ------------------------------------------ // + +function renderConfirmationPopup(popup, form) { + popup.find('.input-summary-label').text(function () { + const inputId = $(this).data('input-target'); + return getFieldLabel(inputId, form) + ':'; + }); + + popup.find('.input-summary-value').text(function () { + const value = $('#' + $(this).data('input-target')).val(); + return value.length > 0 ? value : '-'; + }); +} + +function renderResponsePopup(popup, data) { + const message = popup.find('.popup-message'); + if (message.data('render-message') === true) + message.text(data.message); + if (data['status'] === 'invalid') + renderValidationErrors(popup, data['errors']); + if (data['status'] === 'error') + renderErrorMessages(popup, data['errors']); +} + +function renderErrorMessages(popup, errors) { + const messageBlock = popup.find('.popup-message-wrapper'); + const errorsBlock = $(''); + + errors.forEach((error) => { + errorsBlock.append(``); + }); + + messageBlock.after(errorsBlock); +} + +function renderValidationErrors(popup, errors) { + const form = popup.closest('.form-wrapper').find('form'); + const messageBlock = popup.find('.popup-message-wrapper'); + const errorsBlock = $(''); + + Object.entries(errors).forEach(([key, value]) => { + const errorDiv = $(''); + const fieldLabel = getFieldLabel(key, form); + + errorDiv.append(`${fieldLabel}:`); + + const nestedList = $(''); + value.forEach((nestedValue) => { + nestedList.append(`
  • ${nestedValue}
  • `); + }); + + errorDiv.append(nestedList); + errorsBlock.append(errorDiv); + }); + + messageBlock.after(errorsBlock); +} + +// ------------------------------------ table select field ------------------------------------ // + +function tableSelectFieldSetup() { + $(document).on('init.dt table-reload', function (e) { + const table = $(e.target); + + table.closest('.table-select-field').each(function () { + const tableSelectField = $(this); + const tableId = table.attr('id'); + const input = tableSelectField.find('.input-group input'); + const inputVal = input.val(); + const form = tableSelectField.closest('form'); + const tableWidget = window.tableWidgets[`#${tableId}`]; + + table.find('.select').on('change', function () { + tableSelectFieldCheckboxClickHandler(tableSelectField, input); + }); + + form.on('submit-complete', function () { + tableWidget.table.one('draw.dt', function () { + tableWidget.updateSelectHeader(); + }); + + tableWidget.DTObj.ajax.reload(function () { + $(`#${tableId}`).trigger('table-reload'); + }); + }) + + if (inputVal.length > 0) { + const recordIds = inputVal.split(','); + + for (const id of recordIds) { + const row = table.find(`tr[data-record-id="${id}"]`); + const checkbox = row.find('.select .select-checkbox'); + row.addClass('row-selected'); + checkbox.prop('checked', true); + } + + tableWidget.updateSelectHeader(); + } + }); + }); +} + +function tableSelectFieldValidationSetup() { + $('.table-select-field').each(function () { + const tableSelectField = $(this); + const input = tableSelectField.find('.input-group input'); + + input.on('validation-complete', function () { + if ($(this).hasClass('is-valid')) + tableSelectField.removeClass('is-invalid').addClass('is-valid'); + else if ($(this).hasClass('is-invalid')) + tableSelectField.removeClass('is-valid').addClass('is-invalid'); + else + tableSelectField.removeClass('is-valid').removeClass('is-invalid'); + }); + }); +} + +function tableSelectFieldCheckboxClickHandler(tableSelectField, input) { + const tableId = input.data('table-id'); + const tableWidget = window.tableWidgets[`#${tableId}`]; + const ids = []; + + for (const row of tableWidget.getAllSelectedRows()) + ids.push($(row).data('record-id')); + + input.val(ids.join(',')).trigger('input'); +} + +// --------------------------------------- select field --------------------------------------- // + +function selectFieldSetup() { + $('select.auto-width').each(function () { + const select = $(this); + const tempDiv = $('
    ').css({'position': 'absolute', 'visibility': 'hidden'}); + const tempOption = $(''); + tempDiv.appendTo('body'); + tempOption.appendTo(tempDiv); + tempOption.text(select.data('placeholder-option')); + + select.find('option').each(function () { + tempOption.text($(this).text()); + + if (tempOption.width() > select.width()) + select.width(tempOption.width()); + }); + + tempDiv.remove(); + }); +} + +// ------------------------------------ model choice field ------------------------------------ // + +function choiceFieldSetup() { + $('.choice-field.placeholder-option').each(function () { + const placeholder = $(this).data('placeholder-option'); + $(this).prepend(``); + }); + + $('.choice-field:not(.placeholder-option)').each(function () { + const inputBlock = $(this).closest('.input-block'); + if (inputBlock.find('.live-validation').length > 0) + updateSelectFieldValidationStatus($(this)); + }); +} + +function modelChoiceFieldSetup() { + $('.model-choice-field').each(function () { + loadModelChoiceFieldOptions($(this)); + }); + + $('.model-choice-field-reload-btn').each(function () { + const field = $(this).closest('.input-group').find('.model-choice-field'); + $(this).on('click', function () { + loadModelChoiceFieldOptions(field); + }); + }); +} + +function loadModelChoiceFieldOptions(field) { + if (field.hasClass('loading')) return; + field.addClass('loading'); + + const sourceURL = field.data('source-url'); + const labelFormatString = field.data('label-format-string'); + const valueFormatString = field.data('value-format-string'); + const placeholderOption = field.find('option.placeholder'); + const placeholderText = placeholderOption.text(); + + field.removeClass('is-valid').removeClass('is-invalid'); + field.attr('disabled', true); + placeholderOption.text(field.data('loading-option')); + + $.ajax({ + url: sourceURL, + dataType: 'json', + success: function (response) { + field.find('option:not(.placeholder)').remove(); + + for (const record of response.data) { + addModelChoiceFieldOption(field, + record, + labelFormatString, + valueFormatString) + } + + placeholderOption.text(placeholderText); + field.attr('disabled', false); + field.removeClass('loading'); + } + }) +} + +function addModelChoiceFieldOption(field, data, labelFormatString, valueFormatString) { + const value = generateFormattedString(data, valueFormatString); + const label = generateFormattedString(data, labelFormatString); + field.append(``); +} + +// ------------------------------------------ helpers ----------------------------------------- // + +function getFieldLabel(fieldId, form) { + let label = form.find(`label[for="${fieldId}"]`); + + if (label.length === 0) + return fieldId; + + if (label.find('.required-symbol').length > 0) { + label = label.clone(); + label.find('.required-symbol').remove(); + } + + return label.text().trim(); +} diff --git a/BaCa2/assets/js/jquery.datetimepicker.full.min.js b/BaCa2/assets/js/jquery.datetimepicker.full.min.js new file mode 100644 index 00000000..a2a09c63 --- /dev/null +++ b/BaCa2/assets/js/jquery.datetimepicker.full.min.js @@ -0,0 +1 @@ +var DateFormatter;!function(){"use strict";var e,t,a,r,n,o,i;o=864e5,i=3600,e=function(e,t){return"string"==typeof e&&"string"==typeof t&&e.toLowerCase()===t.toLowerCase()},t=function(e,a,r){var n=r||"0",o=e.toString();return o.lengths?"20":"19")+i):s,h=!0;break;case"m":case"n":case"M":case"F":if(isNaN(s)){if(!((u=m.getMonth(i))>0))return null;D.month=u}else{if(!(s>=1&&12>=s))return null;D.month=s}h=!0;break;case"d":case"j":if(!(s>=1&&31>=s))return null;D.day=s,h=!0;break;case"g":case"h":if(d=r.indexOf("a")>-1?r.indexOf("a"):r.indexOf("A")>-1?r.indexOf("A"):-1,c=n[d],d>-1)l=e(c,p.meridiem[0])?0:e(c,p.meridiem[1])?12:-1,s>=1&&12>=s&&l>-1?D.hour=s+l-1:s>=0&&23>=s&&(D.hour=s);else{if(!(s>=0&&23>=s))return null;D.hour=s}g=!0;break;case"G":case"H":if(!(s>=0&&23>=s))return null;D.hour=s,g=!0;break;case"i":if(!(s>=0&&59>=s))return null;D.min=s,g=!0;break;case"s":if(!(s>=0&&59>=s))return null;D.sec=s,g=!0}if(!0===h&&D.year&&D.month&&D.day)D.date=new Date(D.year,D.month-1,D.day,D.hour,D.min,D.sec,0);else{if(!0!==g)return null;D.date=new Date(0,0,0,D.hour,D.min,D.sec,0)}return D.date},guessDate:function(e,t){if("string"!=typeof e)return e;var a,r,n,o,i,s,u=this,d=e.replace(u.separators,"\0").split("\0"),l=/^[djmn]/g,f=t.match(u.validParts),c=new Date,m=0;if(!l.test(f[0]))return e;for(n=0;na?a:4,!(r=parseInt(4>a?r.toString().substr(0,4-a)+i:i.substr(0,4))))return null;c.setFullYear(r);break;case 3:c.setHours(s);break;case 4:c.setMinutes(s);break;case 5:c.setSeconds(s)}(o=i.substr(m)).length>0&&d.splice(n+1,0,o)}return c},parseFormat:function(e,a){var r,n=this,s=n.dateSettings,u=/\\?(.?)/gi,d=function(e,t){return r[e]?r[e]():t};return r={d:function(){return t(r.j(),2)},D:function(){return s.daysShort[r.w()]},j:function(){return a.getDate()},l:function(){return s.days[r.w()]},N:function(){return r.w()||7},w:function(){return a.getDay()},z:function(){var e=new Date(r.Y(),r.n()-1,r.j()),t=new Date(r.Y(),0,1);return Math.round((e-t)/o)},W:function(){var e=new Date(r.Y(),r.n()-1,r.j()-r.N()+3),a=new Date(e.getFullYear(),0,4);return t(1+Math.round((e-a)/o/7),2)},F:function(){return s.months[a.getMonth()]},m:function(){return t(r.n(),2)},M:function(){return s.monthsShort[a.getMonth()]},n:function(){return a.getMonth()+1},t:function(){return new Date(r.Y(),r.n(),0).getDate()},L:function(){var e=r.Y();return e%4==0&&e%100!=0||e%400==0?1:0},o:function(){var e=r.n(),t=r.W();return r.Y()+(12===e&&9>t?1:1===e&&t>9?-1:0)},Y:function(){return a.getFullYear()},y:function(){return r.Y().toString().slice(-2)},a:function(){return r.A().toLowerCase()},A:function(){var e=r.G()<12?0:1;return s.meridiem[e]},B:function(){var e=a.getUTCHours()*i,r=60*a.getUTCMinutes(),n=a.getUTCSeconds();return t(Math.floor((e+r+n+i)/86.4)%1e3,3)},g:function(){return r.G()%12||12},G:function(){return a.getHours()},h:function(){return t(r.g(),2)},H:function(){return t(r.G(),2)},i:function(){return t(a.getMinutes(),2)},s:function(){return t(a.getSeconds(),2)},u:function(){return t(1e3*a.getMilliseconds(),6)},e:function(){return/\((.*)\)/.exec(String(a))[1]||"Coordinated Universal Time"},I:function(){return new Date(r.Y(),0)-Date.UTC(r.Y(),0)!=new Date(r.Y(),6)-Date.UTC(r.Y(),6)?1:0},O:function(){var e=a.getTimezoneOffset(),r=Math.abs(e);return(e>0?"-":"+")+t(100*Math.floor(r/60)+r%60,4)},P:function(){var e=r.O();return e.substr(0,3)+":"+e.substr(3,2)},T:function(){return(String(a).match(n.tzParts)||[""]).pop().replace(n.tzClip,"")||"UTC"},Z:function(){return 60*-a.getTimezoneOffset()},c:function(){return"Y-m-d\\TH:i:sP".replace(u,d)},r:function(){return"D, d M Y H:i:s O".replace(u,d)},U:function(){return a.getTime()/1e3||0}},d(e,e)},formatDate:function(e,t){var a,r,n,o,i,s=this,u="";if("string"==typeof e&&!(e=s.parseDate(e,t)))return null;if(e instanceof Date){for(n=t.length,a=0;n>a;a++)"S"!==(i=t.charAt(a))&&"\\"!==i&&(a>0&&"\\"===t.charAt(a-1)?u+=i:(o=s.parseFormat(i,e),a!==n-1&&s.intParts.test(i)&&"S"===t.charAt(a+1)&&(r=parseInt(o)||0,o+=s.dateSettings.ordinal(r)),u+=o));return u}return""}}}();var datetimepickerFactory=function(e){"use strict";function t(e,t,a){this.date=e,this.desc=t,this.style=a}var a={i18n:{ar:{months:["كانون الثاني","شباط","آذار","نيسان","مايو","حزيران","تموز","آب","أيلول","تشرين الأول","تشرين الثاني","كانون الأول"],dayOfWeekShort:["ن","ث","ع","خ","ج","س","ح"],dayOfWeek:["الأحد","الاثنين","الثلاثاء","الأربعاء","الخميس","الجمعة","السبت","الأحد"]},ro:{months:["Ianuarie","Februarie","Martie","Aprilie","Mai","Iunie","Iulie","August","Septembrie","Octombrie","Noiembrie","Decembrie"],dayOfWeekShort:["Du","Lu","Ma","Mi","Jo","Vi","Sâ"],dayOfWeek:["Duminică","Luni","Marţi","Miercuri","Joi","Vineri","Sâmbătă"]},id:{months:["Januari","Februari","Maret","April","Mei","Juni","Juli","Agustus","September","Oktober","November","Desember"],dayOfWeekShort:["Min","Sen","Sel","Rab","Kam","Jum","Sab"],dayOfWeek:["Minggu","Senin","Selasa","Rabu","Kamis","Jumat","Sabtu"]},is:{months:["Janúar","Febrúar","Mars","Apríl","Maí","Júní","Júlí","Ágúst","September","Október","Nóvember","Desember"],dayOfWeekShort:["Sun","Mán","Þrið","Mið","Fim","Fös","Lau"],dayOfWeek:["Sunnudagur","Mánudagur","Þriðjudagur","Miðvikudagur","Fimmtudagur","Föstudagur","Laugardagur"]},bg:{months:["Януари","Февруари","Март","Април","Май","Юни","Юли","Август","Септември","Октомври","Ноември","Декември"],dayOfWeekShort:["Нд","Пн","Вт","Ср","Чт","Пт","Сб"],dayOfWeek:["Неделя","Понеделник","Вторник","Сряда","Четвъртък","Петък","Събота"]},fa:{months:["فروردین","اردیبهشت","خرداد","تیر","مرداد","شهریور","مهر","آبان","آذر","دی","بهمن","اسفند"],dayOfWeekShort:["یکشنبه","دوشنبه","سه شنبه","چهارشنبه","پنجشنبه","جمعه","شنبه"],dayOfWeek:["یک‌شنبه","دوشنبه","سه‌شنبه","چهارشنبه","پنج‌شنبه","جمعه","شنبه","یک‌شنبه"]},ru:{months:["Январь","Февраль","Март","Апрель","Май","Июнь","Июль","Август","Сентябрь","Октябрь","Ноябрь","Декабрь"],dayOfWeekShort:["Вс","Пн","Вт","Ср","Чт","Пт","Сб"],dayOfWeek:["Воскресенье","Понедельник","Вторник","Среда","Четверг","Пятница","Суббота"]},uk:{months:["Січень","Лютий","Березень","Квітень","Травень","Червень","Липень","Серпень","Вересень","Жовтень","Листопад","Грудень"],dayOfWeekShort:["Ндл","Пнд","Втр","Срд","Чтв","Птн","Сбт"],dayOfWeek:["Неділя","Понеділок","Вівторок","Середа","Четвер","П'ятниця","Субота"]},en:{months:["January","February","March","April","May","June","July","August","September","October","November","December"],dayOfWeekShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},el:{months:["Ιανουάριος","Φεβρουάριος","Μάρτιος","Απρίλιος","Μάιος","Ιούνιος","Ιούλιος","Αύγουστος","Σεπτέμβριος","Οκτώβριος","Νοέμβριος","Δεκέμβριος"],dayOfWeekShort:["Κυρ","Δευ","Τρι","Τετ","Πεμ","Παρ","Σαβ"],dayOfWeek:["Κυριακή","Δευτέρα","Τρίτη","Τετάρτη","Πέμπτη","Παρασκευή","Σάββατο"]},de:{months:["Januar","Februar","März","April","Mai","Juni","Juli","August","September","Oktober","November","Dezember"],dayOfWeekShort:["So","Mo","Di","Mi","Do","Fr","Sa"],dayOfWeek:["Sonntag","Montag","Dienstag","Mittwoch","Donnerstag","Freitag","Samstag"]},nl:{months:["januari","februari","maart","april","mei","juni","juli","augustus","september","oktober","november","december"],dayOfWeekShort:["zo","ma","di","wo","do","vr","za"],dayOfWeek:["zondag","maandag","dinsdag","woensdag","donderdag","vrijdag","zaterdag"]},tr:{months:["Ocak","Şubat","Mart","Nisan","Mayıs","Haziran","Temmuz","Ağustos","Eylül","Ekim","Kasım","Aralık"],dayOfWeekShort:["Paz","Pts","Sal","Çar","Per","Cum","Cts"],dayOfWeek:["Pazar","Pazartesi","Salı","Çarşamba","Perşembe","Cuma","Cumartesi"]},fr:{months:["Janvier","Février","Mars","Avril","Mai","Juin","Juillet","Août","Septembre","Octobre","Novembre","Décembre"],dayOfWeekShort:["Dim","Lun","Mar","Mer","Jeu","Ven","Sam"],dayOfWeek:["dimanche","lundi","mardi","mercredi","jeudi","vendredi","samedi"]},es:{months:["Enero","Febrero","Marzo","Abril","Mayo","Junio","Julio","Agosto","Septiembre","Octubre","Noviembre","Diciembre"],dayOfWeekShort:["Dom","Lun","Mar","Mié","Jue","Vie","Sáb"],dayOfWeek:["Domingo","Lunes","Martes","Miércoles","Jueves","Viernes","Sábado"]},th:{months:["มกราคม","กุมภาพันธ์","มีนาคม","เมษายน","พฤษภาคม","มิถุนายน","กรกฎาคม","สิงหาคม","กันยายน","ตุลาคม","พฤศจิกายน","ธันวาคม"],dayOfWeekShort:["อา.","จ.","อ.","พ.","พฤ.","ศ.","ส."],dayOfWeek:["อาทิตย์","จันทร์","อังคาร","พุธ","พฤหัส","ศุกร์","เสาร์","อาทิตย์"]},pl:{months:["styczeń","luty","marzec","kwiecień","maj","czerwiec","lipiec","sierpień","wrzesień","październik","listopad","grudzień"],dayOfWeekShort:["nd","pn","wt","śr","cz","pt","sb"],dayOfWeek:["niedziela","poniedziałek","wtorek","środa","czwartek","piątek","sobota"]},pt:{months:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],dayOfWeekShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sab"],dayOfWeek:["Domingo","Segunda","Terça","Quarta","Quinta","Sexta","Sábado"]},ch:{months:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],dayOfWeekShort:["日","一","二","三","四","五","六"]},se:{months:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],dayOfWeekShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör"]},km:{months:["មករា​","កុម្ភៈ","មិនា​","មេសា​","ឧសភា​","មិថុនា​","កក្កដា​","សីហា​","កញ្ញា​","តុលា​","វិច្ឆិកា","ធ្នូ​"],dayOfWeekShort:["អាទិ​","ច័ន្ទ​","អង្គារ​","ពុធ​","ព្រហ​​","សុក្រ​","សៅរ៍"],dayOfWeek:["អាទិត្យ​","ច័ន្ទ​","អង្គារ​","ពុធ​","ព្រហស្បតិ៍​","សុក្រ​","សៅរ៍"]},kr:{months:["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],dayOfWeekShort:["일","월","화","수","목","금","토"],dayOfWeek:["일요일","월요일","화요일","수요일","목요일","금요일","토요일"]},it:{months:["Gennaio","Febbraio","Marzo","Aprile","Maggio","Giugno","Luglio","Agosto","Settembre","Ottobre","Novembre","Dicembre"],dayOfWeekShort:["Dom","Lun","Mar","Mer","Gio","Ven","Sab"],dayOfWeek:["Domenica","Lunedì","Martedì","Mercoledì","Giovedì","Venerdì","Sabato"]},da:{months:["Januar","Februar","Marts","April","Maj","Juni","Juli","August","September","Oktober","November","December"],dayOfWeekShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør"],dayOfWeek:["søndag","mandag","tirsdag","onsdag","torsdag","fredag","lørdag"]},no:{months:["Januar","Februar","Mars","April","Mai","Juni","Juli","August","September","Oktober","November","Desember"],dayOfWeekShort:["Søn","Man","Tir","Ons","Tor","Fre","Lør"],dayOfWeek:["Søndag","Mandag","Tirsdag","Onsdag","Torsdag","Fredag","Lørdag"]},ja:{months:["1月","2月","3月","4月","5月","6月","7月","8月","9月","10月","11月","12月"],dayOfWeekShort:["日","月","火","水","木","金","土"],dayOfWeek:["日曜","月曜","火曜","水曜","木曜","金曜","土曜"]},vi:{months:["Tháng 1","Tháng 2","Tháng 3","Tháng 4","Tháng 5","Tháng 6","Tháng 7","Tháng 8","Tháng 9","Tháng 10","Tháng 11","Tháng 12"],dayOfWeekShort:["CN","T2","T3","T4","T5","T6","T7"],dayOfWeek:["Chủ nhật","Thứ hai","Thứ ba","Thứ tư","Thứ năm","Thứ sáu","Thứ bảy"]},sl:{months:["Januar","Februar","Marec","April","Maj","Junij","Julij","Avgust","September","Oktober","November","December"],dayOfWeekShort:["Ned","Pon","Tor","Sre","Čet","Pet","Sob"],dayOfWeek:["Nedelja","Ponedeljek","Torek","Sreda","Četrtek","Petek","Sobota"]},cs:{months:["Leden","Únor","Březen","Duben","Květen","Červen","Červenec","Srpen","Září","Říjen","Listopad","Prosinec"],dayOfWeekShort:["Ne","Po","Út","St","Čt","Pá","So"]},hu:{months:["Január","Február","Március","Április","Május","Június","Július","Augusztus","Szeptember","Október","November","December"],dayOfWeekShort:["Va","Hé","Ke","Sze","Cs","Pé","Szo"],dayOfWeek:["vasárnap","hétfő","kedd","szerda","csütörtök","péntek","szombat"]},az:{months:["Yanvar","Fevral","Mart","Aprel","May","Iyun","Iyul","Avqust","Sentyabr","Oktyabr","Noyabr","Dekabr"],dayOfWeekShort:["B","Be","Ça","Ç","Ca","C","Ş"],dayOfWeek:["Bazar","Bazar ertəsi","Çərşənbə axşamı","Çərşənbə","Cümə axşamı","Cümə","Şənbə"]},bs:{months:["Januar","Februar","Mart","April","Maj","Jun","Jul","Avgust","Septembar","Oktobar","Novembar","Decembar"],dayOfWeekShort:["Ned","Pon","Uto","Sri","Čet","Pet","Sub"],dayOfWeek:["Nedjelja","Ponedjeljak","Utorak","Srijeda","Četvrtak","Petak","Subota"]},ca:{months:["Gener","Febrer","Març","Abril","Maig","Juny","Juliol","Agost","Setembre","Octubre","Novembre","Desembre"],dayOfWeekShort:["Dg","Dl","Dt","Dc","Dj","Dv","Ds"],dayOfWeek:["Diumenge","Dilluns","Dimarts","Dimecres","Dijous","Divendres","Dissabte"]},"en-GB":{months:["January","February","March","April","May","June","July","August","September","October","November","December"],dayOfWeekShort:["Sun","Mon","Tue","Wed","Thu","Fri","Sat"],dayOfWeek:["Sunday","Monday","Tuesday","Wednesday","Thursday","Friday","Saturday"]},et:{months:["Jaanuar","Veebruar","Märts","Aprill","Mai","Juuni","Juuli","August","September","Oktoober","November","Detsember"],dayOfWeekShort:["P","E","T","K","N","R","L"],dayOfWeek:["Pühapäev","Esmaspäev","Teisipäev","Kolmapäev","Neljapäev","Reede","Laupäev"]},eu:{months:["Urtarrila","Otsaila","Martxoa","Apirila","Maiatza","Ekaina","Uztaila","Abuztua","Iraila","Urria","Azaroa","Abendua"],dayOfWeekShort:["Ig.","Al.","Ar.","Az.","Og.","Or.","La."],dayOfWeek:["Igandea","Astelehena","Asteartea","Asteazkena","Osteguna","Ostirala","Larunbata"]},fi:{months:["Tammikuu","Helmikuu","Maaliskuu","Huhtikuu","Toukokuu","Kesäkuu","Heinäkuu","Elokuu","Syyskuu","Lokakuu","Marraskuu","Joulukuu"],dayOfWeekShort:["Su","Ma","Ti","Ke","To","Pe","La"],dayOfWeek:["sunnuntai","maanantai","tiistai","keskiviikko","torstai","perjantai","lauantai"]},gl:{months:["Xan","Feb","Maz","Abr","Mai","Xun","Xul","Ago","Set","Out","Nov","Dec"],dayOfWeekShort:["Dom","Lun","Mar","Mer","Xov","Ven","Sab"],dayOfWeek:["Domingo","Luns","Martes","Mércores","Xoves","Venres","Sábado"]},hr:{months:["Siječanj","Veljača","Ožujak","Travanj","Svibanj","Lipanj","Srpanj","Kolovoz","Rujan","Listopad","Studeni","Prosinac"],dayOfWeekShort:["Ned","Pon","Uto","Sri","Čet","Pet","Sub"],dayOfWeek:["Nedjelja","Ponedjeljak","Utorak","Srijeda","Četvrtak","Petak","Subota"]},ko:{months:["1월","2월","3월","4월","5월","6월","7월","8월","9월","10월","11월","12월"],dayOfWeekShort:["일","월","화","수","목","금","토"],dayOfWeek:["일요일","월요일","화요일","수요일","목요일","금요일","토요일"]},lt:{months:["Sausio","Vasario","Kovo","Balandžio","Gegužės","Birželio","Liepos","Rugpjūčio","Rugsėjo","Spalio","Lapkričio","Gruodžio"],dayOfWeekShort:["Sek","Pir","Ant","Tre","Ket","Pen","Šeš"],dayOfWeek:["Sekmadienis","Pirmadienis","Antradienis","Trečiadienis","Ketvirtadienis","Penktadienis","Šeštadienis"]},lv:{months:["Janvāris","Februāris","Marts","Aprīlis ","Maijs","Jūnijs","Jūlijs","Augusts","Septembris","Oktobris","Novembris","Decembris"],dayOfWeekShort:["Sv","Pr","Ot","Tr","Ct","Pk","St"],dayOfWeek:["Svētdiena","Pirmdiena","Otrdiena","Trešdiena","Ceturtdiena","Piektdiena","Sestdiena"]},mk:{months:["јануари","февруари","март","април","мај","јуни","јули","август","септември","октомври","ноември","декември"],dayOfWeekShort:["нед","пон","вто","сре","чет","пет","саб"],dayOfWeek:["Недела","Понеделник","Вторник","Среда","Четврток","Петок","Сабота"]},mn:{months:["1-р сар","2-р сар","3-р сар","4-р сар","5-р сар","6-р сар","7-р сар","8-р сар","9-р сар","10-р сар","11-р сар","12-р сар"],dayOfWeekShort:["Дав","Мяг","Лха","Пүр","Бсн","Бям","Ням"],dayOfWeek:["Даваа","Мягмар","Лхагва","Пүрэв","Баасан","Бямба","Ням"]},"pt-BR":{months:["Janeiro","Fevereiro","Março","Abril","Maio","Junho","Julho","Agosto","Setembro","Outubro","Novembro","Dezembro"],dayOfWeekShort:["Dom","Seg","Ter","Qua","Qui","Sex","Sáb"],dayOfWeek:["Domingo","Segunda","Terça","Quarta","Quinta","Sexta","Sábado"]},sk:{months:["Január","Február","Marec","Apríl","Máj","Jún","Júl","August","September","Október","November","December"],dayOfWeekShort:["Ne","Po","Ut","St","Št","Pi","So"],dayOfWeek:["Nedeľa","Pondelok","Utorok","Streda","Štvrtok","Piatok","Sobota"]},sq:{months:["Janar","Shkurt","Mars","Prill","Maj","Qershor","Korrik","Gusht","Shtator","Tetor","Nëntor","Dhjetor"],dayOfWeekShort:["Die","Hën","Mar","Mër","Enj","Pre","Shtu"],dayOfWeek:["E Diel","E Hënë","E Martē","E Mërkurë","E Enjte","E Premte","E Shtunë"]},"sr-YU":{months:["Januar","Februar","Mart","April","Maj","Jun","Jul","Avgust","Septembar","Oktobar","Novembar","Decembar"],dayOfWeekShort:["Ned","Pon","Uto","Sre","čet","Pet","Sub"],dayOfWeek:["Nedelja","Ponedeljak","Utorak","Sreda","Četvrtak","Petak","Subota"]},sr:{months:["јануар","фебруар","март","април","мај","јун","јул","август","септембар","октобар","новембар","децембар"],dayOfWeekShort:["нед","пон","уто","сре","чет","пет","суб"],dayOfWeek:["Недеља","Понедељак","Уторак","Среда","Четвртак","Петак","Субота"]},sv:{months:["Januari","Februari","Mars","April","Maj","Juni","Juli","Augusti","September","Oktober","November","December"],dayOfWeekShort:["Sön","Mån","Tis","Ons","Tor","Fre","Lör"],dayOfWeek:["Söndag","Måndag","Tisdag","Onsdag","Torsdag","Fredag","Lördag"]},"zh-TW":{months:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],dayOfWeekShort:["日","一","二","三","四","五","六"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"]},zh:{months:["一月","二月","三月","四月","五月","六月","七月","八月","九月","十月","十一月","十二月"],dayOfWeekShort:["日","一","二","三","四","五","六"],dayOfWeek:["星期日","星期一","星期二","星期三","星期四","星期五","星期六"]},ug:{months:["1-ئاي","2-ئاي","3-ئاي","4-ئاي","5-ئاي","6-ئاي","7-ئاي","8-ئاي","9-ئاي","10-ئاي","11-ئاي","12-ئاي"],dayOfWeek:["يەكشەنبە","دۈشەنبە","سەيشەنبە","چارشەنبە","پەيشەنبە","جۈمە","شەنبە"]},he:{months:["ינואר","פברואר","מרץ","אפריל","מאי","יוני","יולי","אוגוסט","ספטמבר","אוקטובר","נובמבר","דצמבר"],dayOfWeekShort:["א'","ב'","ג'","ד'","ה'","ו'","שבת"],dayOfWeek:["ראשון","שני","שלישי","רביעי","חמישי","שישי","שבת","ראשון"]},hy:{months:["Հունվար","Փետրվար","Մարտ","Ապրիլ","Մայիս","Հունիս","Հուլիս","Օգոստոս","Սեպտեմբեր","Հոկտեմբեր","Նոյեմբեր","Դեկտեմբեր"],dayOfWeekShort:["Կի","Երկ","Երք","Չոր","Հնգ","Ուրբ","Շբթ"],dayOfWeek:["Կիրակի","Երկուշաբթի","Երեքշաբթի","Չորեքշաբթի","Հինգշաբթի","Ուրբաթ","Շաբաթ"]},kg:{months:["Үчтүн айы","Бирдин айы","Жалган Куран","Чын Куран","Бугу","Кулжа","Теке","Баш Оона","Аяк Оона","Тогуздун айы","Жетинин айы","Бештин айы"],dayOfWeekShort:["Жек","Дүй","Шей","Шар","Бей","Жум","Ише"],dayOfWeek:["Жекшемб","Дүйшөмб","Шейшемб","Шаршемб","Бейшемби","Жума","Ишенб"]},rm:{months:["Schaner","Favrer","Mars","Avrigl","Matg","Zercladur","Fanadur","Avust","Settember","October","November","December"],dayOfWeekShort:["Du","Gli","Ma","Me","Gie","Ve","So"],dayOfWeek:["Dumengia","Glindesdi","Mardi","Mesemna","Gievgia","Venderdi","Sonda"]},ka:{months:["იანვარი","თებერვალი","მარტი","აპრილი","მაისი","ივნისი","ივლისი","აგვისტო","სექტემბერი","ოქტომბერი","ნოემბერი","დეკემბერი"],dayOfWeekShort:["კვ","ორშ","სამშ","ოთხ","ხუთ","პარ","შაბ"],dayOfWeek:["კვირა","ორშაბათი","სამშაბათი","ოთხშაბათი","ხუთშაბათი","პარასკევი","შაბათი"]}},ownerDocument:document,contentWindow:window,value:"",rtl:!1,format:"Y/m/d H:i",formatTime:"H:i",formatDate:"Y/m/d",startDate:!1,step:60,monthChangeSpinner:!0,closeOnDateSelect:!1,closeOnTimeSelect:!0,closeOnWithoutClick:!0,closeOnInputClick:!0,openOnFocus:!0,timepicker:!0,datepicker:!0,weeks:!1,defaultTime:!1,defaultDate:!1,minDate:!1,maxDate:!1,minTime:!1,maxTime:!1,minDateTime:!1,maxDateTime:!1,allowTimes:[],opened:!1,initTime:!0,inline:!1,theme:"",touchMovedThreshold:5,onSelectDate:function(){},onSelectTime:function(){},onChangeMonth:function(){},onGetWeekOfYear:function(){},onChangeYear:function(){},onChangeDateTime:function(){},onShow:function(){},onClose:function(){},onGenerate:function(){},withoutCopyright:!0,inverseButton:!1,hours12:!1,next:"xdsoft_next",prev:"xdsoft_prev",dayOfWeekStart:0,parentID:"body",timeHeightInTimePicker:25,timepickerScrollbar:!0,todayButton:!0,prevButton:!0,nextButton:!0,defaultSelect:!0,scrollMonth:!0,scrollTime:!0,scrollInput:!0,lazyInit:!1,mask:!1,validateOnBlur:!0,allowBlank:!0,yearStart:1950,yearEnd:2050,monthStart:0,monthEnd:11,style:"",id:"",fixed:!1,roundTime:"round",className:"",weekends:[],highlightedDates:[],highlightedPeriods:[],allowDates:[],allowDateRe:null,disabledDates:[],disabledWeekDays:[],yearOffset:0,beforeShowDay:null,enterLikeTab:!0,showApplyButton:!1},r=null,n=null,o="en",i={meridiem:["AM","PM"]},s=function(){var t=a.i18n[o],s={days:t.dayOfWeek,daysShort:t.dayOfWeekShort,months:t.months,monthsShort:e.map(t.months,function(e){return e.substring(0,3)})};"function"==typeof DateFormatter&&(r=n=new DateFormatter({dateSettings:e.extend({},i,s)}))},u={moment:{default_options:{format:"YYYY/MM/DD HH:mm",formatDate:"YYYY/MM/DD",formatTime:"HH:mm"},formatter:{parseDate:function(e,t){if(l(t))return n.parseDate(e,t);var a=moment(e,t);return!!a.isValid()&&a.toDate()},formatDate:function(e,t){return l(t)?n.formatDate(e,t):moment(e).format(t)},formatMask:function(e){return e.replace(/Y{4}/g,"9999").replace(/Y{2}/g,"99").replace(/M{2}/g,"19").replace(/D{2}/g,"39").replace(/H{2}/g,"29").replace(/m{2}/g,"59").replace(/s{2}/g,"59")}}}};e.datetimepicker={setLocale:function(e){var t=a.i18n[e]?e:"en";o!==t&&(o=t,s())},setDateFormatter:function(t){if("string"==typeof t&&u.hasOwnProperty(t)){var n=u[t];e.extend(a,n.default_options),r=n.formatter}else r=t}};var d={RFC_2822:"D, d M Y H:i:s O",ATOM:"Y-m-dTH:i:sP",ISO_8601:"Y-m-dTH:i:sO",RFC_822:"D, d M y H:i:s O",RFC_850:"l, d-M-y H:i:s T",RFC_1036:"D, d M y H:i:s O",RFC_1123:"D, d M Y H:i:s O",RSS:"D, d M Y H:i:s O",W3C:"Y-m-dTH:i:sP"},l=function(e){return-1!==Object.values(d).indexOf(e)};e.extend(e.datetimepicker,d),s(),window.getComputedStyle||(window.getComputedStyle=function(e){return this.el=e,this.getPropertyValue=function(t){var a=/(-([a-z]))/g;return"float"===t&&(t="styleFloat"),a.test(t)&&(t=t.replace(a,function(e,t,a){return a.toUpperCase()})),e.currentStyle[t]||null},this}),Array.prototype.indexOf||(Array.prototype.indexOf=function(e,t){var a,r;for(a=t||0,r=this.length;a'),s=e('
    '),i.append(s),u.addClass("xdsoft_scroller_box").append(i),D=function(e){var t=d(e).y-c+p;t<0&&(t=0),t+s[0].offsetHeight>h&&(t=h-s[0].offsetHeight),u.trigger("scroll_element.xdsoft_scroller",[l?t/l:0])},s.on("touchstart.xdsoft_scroller mousedown.xdsoft_scroller",function(r){n||u.trigger("resize_scroll.xdsoft_scroller",[a]),c=d(r).y,p=parseInt(s.css("margin-top"),10),h=i[0].offsetHeight,"mousedown"===r.type||"touchstart"===r.type?(t.ownerDocument&&e(t.ownerDocument.body).addClass("xdsoft_noselect"),e([t.ownerDocument.body,t.contentWindow]).on("touchend mouseup.xdsoft_scroller",function a(){e([t.ownerDocument.body,t.contentWindow]).off("touchend mouseup.xdsoft_scroller",a).off("mousemove.xdsoft_scroller",D).removeClass("xdsoft_noselect")}),e(t.ownerDocument.body).on("mousemove.xdsoft_scroller",D)):(g=!0,r.stopPropagation(),r.preventDefault())}).on("touchmove",function(e){g&&(e.preventDefault(),D(e))}).on("touchend touchcancel",function(){g=!1,p=0}),u.on("scroll_element.xdsoft_scroller",function(e,t){n||u.trigger("resize_scroll.xdsoft_scroller",[t,!0]),t=t>1?1:t<0||isNaN(t)?0:t,s.css("margin-top",l*t),setTimeout(function(){r.css("marginTop",-parseInt((r[0].offsetHeight-n)*t,10))},10)}).on("resize_scroll.xdsoft_scroller",function(e,t,a){var d,f;n=u[0].clientHeight,o=r[0].offsetHeight,f=(d=n/o)*i[0].offsetHeight,d>1?s.hide():(s.show(),s.css("height",parseInt(f>10?f:10,10)),l=i[0].offsetHeight-s[0].offsetHeight,!0!==a&&u.trigger("scroll_element.xdsoft_scroller",[t||Math.abs(parseInt(r.css("marginTop"),10))/(o-n)]))}),u.on("mousewheel",function(e){var t=Math.abs(parseInt(r.css("marginTop"),10));return(t-=20*e.deltaY)<0&&(t=0),u.trigger("scroll_element.xdsoft_scroller",[t/(o-n)]),e.stopPropagation(),!1}),u.on("touchstart",function(e){f=d(e),m=Math.abs(parseInt(r.css("marginTop"),10))}),u.on("touchmove",function(e){if(f){e.preventDefault();var t=d(e);u.trigger("scroll_element.xdsoft_scroller",[(m-(t.y-f.y))/(o-n)])}}),u.on("touchend touchcancel",function(){f=!1,m=0})),u.trigger("resize_scroll.xdsoft_scroller",[a])):u.find(".xdsoft_scrollbar").hide()})},e.fn.datetimepicker=function(n,i){var s,u,d=this,l=48,f=57,c=96,m=105,h=17,g=46,p=13,D=27,v=8,y=37,b=38,k=39,x=40,T=9,S=116,M=65,w=67,O=86,W=90,_=89,F=!1,C=e.isPlainObject(n)||!n?e.extend(!0,{},a,n):e.extend(!0,{},a),P=0,Y=function(e){e.on("open.xdsoft focusin.xdsoft mousedown.xdsoft touchstart",function t(){e.is(":disabled")||e.data("xdsoft_datetimepicker")||(clearTimeout(P),P=setTimeout(function(){e.data("xdsoft_datetimepicker")||s(e),e.off("open.xdsoft focusin.xdsoft mousedown.xdsoft touchstart",t).trigger("open.xdsoft")},100))})};return s=function(a){function i(){var e,t=!1;return C.startDate?t=A.strToDate(C.startDate):(t=C.value||(a&&a.val&&a.val()?a.val():""))?(t=A.strToDateTime(t),C.yearOffset&&(t=new Date(t.getFullYear()-C.yearOffset,t.getMonth(),t.getDate(),t.getHours(),t.getMinutes(),t.getSeconds(),t.getMilliseconds()))):C.defaultDate&&(t=A.strToDateTime(C.defaultDate),C.defaultTime&&(e=A.strtotime(C.defaultTime),t.setHours(e.getHours()),t.setMinutes(e.getMinutes()))),t&&A.isValidDate(t)?j.data("changed",!0):t="",t||0}function s(t){var n=function(e,t){var a=e.replace(/([\[\]\/\{\}\(\)\-\.\+]{1})/g,"\\$1").replace(/_/g,"{digit+}").replace(/([0-9]{1})/g,"{digit$1}").replace(/\{digit([0-9]{1})\}/g,"[0-$1_]{1}").replace(/\{digit[\+]\}/g,"[0-9_]{1}");return new RegExp(a).test(t)},o=function(e,a){if(!(e="string"==typeof e||e instanceof String?t.ownerDocument.getElementById(e):e))return!1;if(e.createTextRange){var r=e.createTextRange();return r.collapse(!0),r.moveEnd("character",a),r.moveStart("character",a),r.select(),!0}return!!e.setSelectionRange&&(e.setSelectionRange(a,a),!0)};t.mask&&a.off("keydown.xdsoft"),!0===t.mask&&(r.formatMask?t.mask=r.formatMask(t.format):t.mask=t.format.replace(/Y/g,"9999").replace(/F/g,"9999").replace(/m/g,"19").replace(/d/g,"39").replace(/H/g,"29").replace(/i/g,"59").replace(/s/g,"59")),"string"===e.type(t.mask)&&(n(t.mask,a.val())||(a.val(t.mask.replace(/[0-9]/g,"_")),o(a[0],0)),a.on("paste.xdsoft",function(r){var i=(r.clipboardData||r.originalEvent.clipboardData||window.clipboardData).getData("text"),s=this.value,u=this.selectionStart;return s=s.substr(0,u)+i+s.substr(u+i.length),u+=i.length,n(t.mask,s)?(this.value=s,o(this,u)):""===e.trim(s)?this.value=t.mask.replace(/[0-9]/g,"_"):a.trigger("error_input.xdsoft"),r.preventDefault(),!1}),a.on("keydown.xdsoft",function(r){var i,s=this.value,u=r.which,d=this.selectionStart,C=this.selectionEnd,P=d!==C;if(u>=l&&u<=f||u>=c&&u<=m||u===v||u===g){for(i=u===v||u===g?"_":String.fromCharCode(c<=u&&u<=m?u-l:u),u===v&&d&&!P&&(d-=1);;){var Y=t.mask.substr(d,1),A=d0;if(!(/[^0-9_]/.test(Y)&&A&&H))break;d+=u!==v||P?1:-1}if(P){var j=C-d,J=t.mask.replace(/[0-9]/g,"_"),z=J.substr(d,j).substr(1);s=s.substr(0,d)+(i+z)+s.substr(d+j)}else s=s.substr(0,d)+i+s.substr(d+1);if(""===e.trim(s))s=J;else if(d===t.mask.length)return r.preventDefault(),!1;for(d+=u===v?0:1;/[^0-9_]/.test(t.mask.substr(d,1))&&d0;)d+=u===v?0:1;n(t.mask,s)?(this.value=s,o(this,d)):""===e.trim(s)?this.value=t.mask.replace(/[0-9]/g,"_"):a.trigger("error_input.xdsoft")}else if(-1!==[M,w,O,W,_].indexOf(u)&&F||-1!==[D,b,x,y,k,S,h,T,p].indexOf(u))return!0;return r.preventDefault(),!1}))}var u,d,P,Y,A,H,j=e('
    '),J=e(''),z=e('
    '),I=e('
    '),N=e('
    '),L=e('
    '),E=L.find(".xdsoft_time_box").eq(0),R=e('
    '),V=e(''),B=e('
    '),G=e('
    '),U=!1,q=0;C.id&&j.attr("id",C.id),C.style&&j.attr("style",C.style),C.weeks&&j.addClass("xdsoft_showweeks"),C.rtl&&j.addClass("xdsoft_rtl"),j.addClass("xdsoft_"+C.theme),j.addClass(C.className),I.find(".xdsoft_month span").after(B),I.find(".xdsoft_year span").after(G),I.find(".xdsoft_month,.xdsoft_year").on("touchstart mousedown.xdsoft",function(t){var a,r,n=e(this).find(".xdsoft_select").eq(0),o=0,i=0,s=n.is(":visible");for(I.find(".xdsoft_select").hide(),A.currentTime&&(o=A.currentTime[e(this).hasClass("xdsoft_month")?"getMonth":"getFullYear"]()),n[s?"hide":"show"](),a=n.find("div.xdsoft_option"),r=0;rC.touchMovedThreshold&&(this.touchMoved=!0)};I.find(".xdsoft_select").xdsoftScroller(C).on("touchstart mousedown.xdsoft",function(e){var t=e.originalEvent;this.touchMoved=!1,this.touchStartPosition=t.touches?t.touches[0]:t,e.stopPropagation(),e.preventDefault()}).on("touchmove",".xdsoft_option",X).on("touchend mousedown.xdsoft",".xdsoft_option",function(){if(!this.touchMoved){void 0!==A.currentTime&&null!==A.currentTime||(A.currentTime=A.now());var t=A.currentTime.getFullYear();A&&A.currentTime&&A.currentTime[e(this).parent().parent().hasClass("xdsoft_monthselect")?"setMonth":"setFullYear"](e(this).data("value")),e(this).parent().parent().hide(),j.trigger("xchange.xdsoft"),C.onChangeMonth&&e.isFunction(C.onChangeMonth)&&C.onChangeMonth.call(j,A.currentTime,j.data("input")),t!==A.currentTime.getFullYear()&&e.isFunction(C.onChangeYear)&&C.onChangeYear.call(j,A.currentTime,j.data("input"))}}),j.getValue=function(){return A.getCurrentTime()},j.setOptions=function(n){var o={};C=e.extend(!0,{},C,n),n.allowTimes&&e.isArray(n.allowTimes)&&n.allowTimes.length&&(C.allowTimes=e.extend(!0,[],n.allowTimes)),n.weekends&&e.isArray(n.weekends)&&n.weekends.length&&(C.weekends=e.extend(!0,[],n.weekends)),n.allowDates&&e.isArray(n.allowDates)&&n.allowDates.length&&(C.allowDates=e.extend(!0,[],n.allowDates)),n.allowDateRe&&"[object String]"===Object.prototype.toString.call(n.allowDateRe)&&(C.allowDateRe=new RegExp(n.allowDateRe)),n.highlightedDates&&e.isArray(n.highlightedDates)&&n.highlightedDates.length&&(e.each(n.highlightedDates,function(a,n){var i,s=e.map(n.split(","),e.trim),u=new t(r.parseDate(s[0],C.formatDate),s[1],s[2]),d=r.formatDate(u.date,C.formatDate);void 0!==o[d]?(i=o[d].desc)&&i.length&&u.desc&&u.desc.length&&(o[d].desc=i+"\n"+u.desc):o[d]=u}),C.highlightedDates=e.extend(!0,[],o)),n.highlightedPeriods&&e.isArray(n.highlightedPeriods)&&n.highlightedPeriods.length&&(o=e.extend(!0,[],C.highlightedDates),e.each(n.highlightedPeriods,function(a,n){var i,s,u,d,l,f,c;if(e.isArray(n))i=n[0],s=n[1],u=n[2],c=n[3];else{var m=e.map(n.split(","),e.trim);i=r.parseDate(m[0],C.formatDate),s=r.parseDate(m[1],C.formatDate),u=m[2],c=m[3]}for(;i<=s;)d=new t(i,u,c),l=r.formatDate(i,C.formatDate),i.setDate(i.getDate()+1),void 0!==o[l]?(f=o[l].desc)&&f.length&&d.desc&&d.desc.length&&(o[l].desc=f+"\n"+d.desc):o[l]=d}),C.highlightedDates=e.extend(!0,[],o)),n.disabledDates&&e.isArray(n.disabledDates)&&n.disabledDates.length&&(C.disabledDates=e.extend(!0,[],n.disabledDates)),n.disabledWeekDays&&e.isArray(n.disabledWeekDays)&&n.disabledWeekDays.length&&(C.disabledWeekDays=e.extend(!0,[],n.disabledWeekDays)),!C.open&&!C.opened||C.inline||a.trigger("open.xdsoft"),C.inline&&(U=!0,j.addClass("xdsoft_inline"),a.after(j).hide()),C.inverseButton&&(C.next="xdsoft_prev",C.prev="xdsoft_next"),C.datepicker?z.addClass("active"):z.removeClass("active"),C.timepicker?L.addClass("active"):L.removeClass("active"),C.value&&(A.setCurrentTime(C.value),a&&a.val&&a.val(A.str)),isNaN(C.dayOfWeekStart)?C.dayOfWeekStart=0:C.dayOfWeekStart=parseInt(C.dayOfWeekStart,10)%7,C.timepickerScrollbar||E.xdsoftScroller(C,"hide"),C.minDate&&/^[\+\-](.*)$/.test(C.minDate)&&(C.minDate=r.formatDate(A.strToDateTime(C.minDate),C.formatDate)),C.maxDate&&/^[\+\-](.*)$/.test(C.maxDate)&&(C.maxDate=r.formatDate(A.strToDateTime(C.maxDate),C.formatDate)),C.minDateTime&&/^\+(.*)$/.test(C.minDateTime)&&(C.minDateTime=A.strToDateTime(C.minDateTime).dateFormat(C.formatDate)),C.maxDateTime&&/^\+(.*)$/.test(C.maxDateTime)&&(C.maxDateTime=A.strToDateTime(C.maxDateTime).dateFormat(C.formatDate)),V.toggle(C.showApplyButton),I.find(".xdsoft_today_button").css("visibility",C.todayButton?"visible":"hidden"),I.find("."+C.prev).css("visibility",C.prevButton?"visible":"hidden"),I.find("."+C.next).css("visibility",C.nextButton?"visible":"hidden"),s(C),C.validateOnBlur&&a.off("blur.xdsoft").on("blur.xdsoft",function(){if(C.allowBlank&&(!e.trim(e(this).val()).length||"string"==typeof C.mask&&e.trim(e(this).val())===C.mask.replace(/[0-9]/g,"_")))e(this).val(null),j.data("xdsoft_datetime").empty();else{var t=r.parseDate(e(this).val(),C.format);if(t)e(this).val(r.formatDate(t,C.format));else{var a=+[e(this).val()[0],e(this).val()[1]].join(""),n=+[e(this).val()[2],e(this).val()[3]].join("");!C.datepicker&&C.timepicker&&a>=0&&a<24&&n>=0&&n<60?e(this).val([a,n].map(function(e){return e>9?e:"0"+e}).join(":")):e(this).val(r.formatDate(A.now(),C.format))}j.data("xdsoft_datetime").setCurrentTime(e(this).val())}j.trigger("changedatetime.xdsoft"),j.trigger("close.xdsoft")}),C.dayOfWeekStartPrev=0===C.dayOfWeekStart?6:C.dayOfWeekStart-1,j.trigger("xchange.xdsoft").trigger("afterOpen.xdsoft")},j.data("options",C).on("touchstart mousedown.xdsoft",function(e){return e.stopPropagation(),e.preventDefault(),G.hide(),B.hide(),!1}),E.append(R),E.xdsoftScroller(C),j.on("afterOpen.xdsoft",function(){E.xdsoftScroller(C)}),j.append(z).append(L),!0!==C.withoutCopyright&&j.append(J),z.append(I).append(N).append(V),e(C.parentID).append(j),A=new function(){var t=this;t.now=function(e){var a,r,n=new Date;return!e&&C.defaultDate&&(a=t.strToDateTime(C.defaultDate),n.setFullYear(a.getFullYear()),n.setMonth(a.getMonth()),n.setDate(a.getDate())),n.setFullYear(n.getFullYear()),!e&&C.defaultTime&&(r=t.strtotime(C.defaultTime),n.setHours(r.getHours()),n.setMinutes(r.getMinutes()),n.setSeconds(r.getSeconds()),n.setMilliseconds(r.getMilliseconds())),n},t.isValidDate=function(e){return"[object Date]"===Object.prototype.toString.call(e)&&!isNaN(e.getTime())},t.setCurrentTime=function(e,a){"string"==typeof e?t.currentTime=t.strToDateTime(e):t.isValidDate(e)?t.currentTime=e:e||a||!C.allowBlank||C.inline?t.currentTime=t.now():t.currentTime=null,j.trigger("xchange.xdsoft")},t.empty=function(){t.currentTime=null},t.getCurrentTime=function(){return t.currentTime},t.nextMonth=function(){void 0!==t.currentTime&&null!==t.currentTime||(t.currentTime=t.now());var a,r=t.currentTime.getMonth()+1;return 12===r&&(t.currentTime.setFullYear(t.currentTime.getFullYear()+1),r=0),a=t.currentTime.getFullYear(),t.currentTime.setDate(Math.min(new Date(t.currentTime.getFullYear(),r+1,0).getDate(),t.currentTime.getDate())),t.currentTime.setMonth(r),C.onChangeMonth&&e.isFunction(C.onChangeMonth)&&C.onChangeMonth.call(j,A.currentTime,j.data("input")),a!==t.currentTime.getFullYear()&&e.isFunction(C.onChangeYear)&&C.onChangeYear.call(j,A.currentTime,j.data("input")),j.trigger("xchange.xdsoft"),r},t.prevMonth=function(){void 0!==t.currentTime&&null!==t.currentTime||(t.currentTime=t.now());var a=t.currentTime.getMonth()-1;return-1===a&&(t.currentTime.setFullYear(t.currentTime.getFullYear()-1),a=11),t.currentTime.setDate(Math.min(new Date(t.currentTime.getFullYear(),a+1,0).getDate(),t.currentTime.getDate())),t.currentTime.setMonth(a),C.onChangeMonth&&e.isFunction(C.onChangeMonth)&&C.onChangeMonth.call(j,A.currentTime,j.data("input")),j.trigger("xchange.xdsoft"),a},t.getWeekOfYear=function(t){if(C.onGetWeekOfYear&&e.isFunction(C.onGetWeekOfYear)){var a=C.onGetWeekOfYear.call(j,t);if(void 0!==a)return a}var r=new Date(t.getFullYear(),0,1);return 4!==r.getDay()&&r.setMonth(0,1+(4-r.getDay()+7)%7),Math.ceil(((t-r)/864e5+r.getDay()+1)/7)},t.strToDateTime=function(e){var a,n,o=[];return e&&e instanceof Date&&t.isValidDate(e)?e:((o=/^([+-]{1})(.*)$/.exec(e))&&(o[2]=r.parseDate(o[2],C.formatDate)),o&&o[2]?(a=o[2].getTime()-6e4*o[2].getTimezoneOffset(),n=new Date(t.now(!0).getTime()+parseInt(o[1]+"1",10)*a)):n=e?r.parseDate(e,C.format):t.now(),t.isValidDate(n)||(n=t.now()),n)},t.strToDate=function(e){if(e&&e instanceof Date&&t.isValidDate(e))return e;var a=e?r.parseDate(e,C.formatDate):t.now(!0);return t.isValidDate(a)||(a=t.now(!0)),a},t.strtotime=function(e){if(e&&e instanceof Date&&t.isValidDate(e))return e;var a=e?r.parseDate(e,C.formatTime):t.now(!0);return t.isValidDate(a)||(a=t.now(!0)),a},t.str=function(){var e=C.format;return C.yearOffset&&(e=(e=e.replace("Y",t.currentTime.getFullYear()+C.yearOffset)).replace("y",String(t.currentTime.getFullYear()+C.yearOffset).substring(2,4))),r.formatDate(t.currentTime,e)},t.currentTime=this.now()},V.on("touchend click",function(e){e.preventDefault(),j.data("changed",!0),A.setCurrentTime(i()),a.val(A.str()),j.trigger("close.xdsoft")}),I.find(".xdsoft_today_button").on("touchend mousedown.xdsoft",function(){j.data("changed",!0),A.setCurrentTime(0,!0),j.trigger("afterOpen.xdsoft")}).on("dblclick.xdsoft",function(){var e,t,r=A.getCurrentTime();r=new Date(r.getFullYear(),r.getMonth(),r.getDate()),e=A.strToDate(C.minDate),r<(e=new Date(e.getFullYear(),e.getMonth(),e.getDate()))||(t=A.strToDate(C.maxDate),r>(t=new Date(t.getFullYear(),t.getMonth(),t.getDate()))||(a.val(A.str()),a.trigger("change"),j.trigger("close.xdsoft")))}),I.find(".xdsoft_prev,.xdsoft_next").on("touchend mousedown.xdsoft",function(){var t=e(this),a=0,r=!1;!function e(n){t.hasClass(C.next)?A.nextMonth():t.hasClass(C.prev)&&A.prevMonth(),C.monthChangeSpinner&&(r||(a=setTimeout(e,n||100)))}(500),e([C.ownerDocument.body,C.contentWindow]).on("touchend mouseup.xdsoft",function t(){clearTimeout(a),r=!0,e([C.ownerDocument.body,C.contentWindow]).off("touchend mouseup.xdsoft",t)})}),L.find(".xdsoft_prev,.xdsoft_next").on("touchend mousedown.xdsoft",function(){var t=e(this),a=0,r=!1,n=110;!function e(o){var i=E[0].clientHeight,s=R[0].offsetHeight,u=Math.abs(parseInt(R.css("marginTop"),10));t.hasClass(C.next)&&s-i-C.timeHeightInTimePicker>=u?R.css("marginTop","-"+(u+C.timeHeightInTimePicker)+"px"):t.hasClass(C.prev)&&u-C.timeHeightInTimePicker>=0&&R.css("marginTop","-"+(u-C.timeHeightInTimePicker)+"px"),E.trigger("scroll_element.xdsoft_scroller",[Math.abs(parseInt(R[0].style.marginTop,10)/(s-i))]),n=n>10?10:n-10,r||(a=setTimeout(e,o||n))}(500),e([C.ownerDocument.body,C.contentWindow]).on("touchend mouseup.xdsoft",function t(){clearTimeout(a),r=!0,e([C.ownerDocument.body,C.contentWindow]).off("touchend mouseup.xdsoft",t)})}),u=0,j.on("xchange.xdsoft",function(t){clearTimeout(u),u=setTimeout(function(){void 0!==A.currentTime&&null!==A.currentTime||(A.currentTime=A.now());for(var t,i,s,u,d,l,f,c,m,h,g="",p=new Date(A.currentTime.getFullYear(),A.currentTime.getMonth(),1,12,0,0),D=0,v=A.now(),y=!1,b=!1,k=!1,x=!1,T=[],S=!0,M="";p.getDay()!==C.dayOfWeekStart;)p.setDate(p.getDate()-1);for(g+="",C.weeks&&(g+=""),t=0;t<7;t+=1)g+="";g+="",g+="",!1!==C.maxDate&&(y=A.strToDate(C.maxDate),y=new Date(y.getFullYear(),y.getMonth(),y.getDate(),23,59,59,999)),!1!==C.minDate&&(b=A.strToDate(C.minDate),b=new Date(b.getFullYear(),b.getMonth(),b.getDate())),!1!==C.minDateTime&&(k=A.strToDate(C.minDateTime),k=new Date(k.getFullYear(),k.getMonth(),k.getDate(),k.getHours(),k.getMinutes(),k.getSeconds())),!1!==C.maxDateTime&&(x=A.strToDate(C.maxDateTime),x=new Date(x.getFullYear(),x.getMonth(),x.getDate(),x.getHours(),x.getMinutes(),x.getSeconds()));var w;for(!1!==x&&(w=31*(12*x.getFullYear()+x.getMonth())+x.getDate());D0&&-1===C.allowDates.indexOf(r.formatDate(p,C.formatDate))&&T.push("xdsoft_disabled");var O=31*(12*p.getFullYear()+p.getMonth())+p.getDate();(!1!==y&&p>y||!1!==k&&pw||c&&!1===c[0])&&T.push("xdsoft_disabled"),-1!==C.disabledDates.indexOf(r.formatDate(p,C.formatDate))&&T.push("xdsoft_disabled"),-1!==C.disabledWeekDays.indexOf(s)&&T.push("xdsoft_disabled"),a.is("[disabled]")&&T.push("xdsoft_disabled"),c&&""!==c[1]&&T.push(c[1]),A.currentTime.getMonth()!==l&&T.push("xdsoft_other_month"),(C.defaultSelect||j.data("changed"))&&r.formatDate(A.currentTime,C.formatDate)===r.formatDate(p,C.formatDate)&&T.push("xdsoft_current"),r.formatDate(v,C.formatDate)===r.formatDate(p,C.formatDate)&&T.push("xdsoft_today"),0!==p.getDay()&&6!==p.getDay()&&-1===C.weekends.indexOf(r.formatDate(p,C.formatDate))||T.push("xdsoft_weekend"),void 0!==C.highlightedDates[r.formatDate(p,C.formatDate)]&&(i=C.highlightedDates[r.formatDate(p,C.formatDate)],T.push(void 0===i.style?"xdsoft_highlighted_default":i.style),h=void 0===i.desc?"":i.desc),C.beforeShowDay&&e.isFunction(C.beforeShowDay)&&T.push(C.beforeShowDay(p)),S&&(g+="",S=!1,C.weeks&&(g+="")),g+='",p.getDay()===C.dayOfWeekStartPrev&&(g+="",S=!0),p.setDate(u+1)}g+="
    "+C.i18n[o].dayOfWeekShort[(t+C.dayOfWeekStart)%7]+"
    "+f+"
    '+u+"
    ",N.html(g),I.find(".xdsoft_label span").eq(0).text(C.i18n[o].months[A.currentTime.getMonth()]),I.find(".xdsoft_label span").eq(1).text(A.currentTime.getFullYear()+C.yearOffset),M="",l="";var W=0;if(!1!==C.minTime){F=A.strtotime(C.minTime);W=60*F.getHours()+F.getMinutes()}var _=1440;if(!1!==C.maxTime){F=A.strtotime(C.maxTime);_=60*F.getHours()+F.getMinutes()}if(!1!==C.minDateTime){F=A.strToDateTime(C.minDateTime);r.formatDate(A.currentTime,C.formatDate)===r.formatDate(F,C.formatDate)&&(l=60*F.getHours()+F.getMinutes())>W&&(W=l)}if(!1!==C.maxDateTime){var F=A.strToDateTime(C.maxDateTime);r.formatDate(A.currentTime,C.formatDate)===r.formatDate(F,C.formatDate)&&(l=60*F.getHours()+F.getMinutes())<_&&(_=l)}if(m=function(t,n){var o,i=A.now(),s=C.allowTimes&&e.isArray(C.allowTimes)&&C.allowTimes.length;i.setHours(t),t=parseInt(i.getHours(),10),i.setMinutes(n),n=parseInt(i.getMinutes(),10),T=[];var u=60*t+n;(a.is("[disabled]")||u>=_||u59||o.getMinutes()===parseInt(n,10))&&(C.defaultSelect||j.data("changed")?T.push("xdsoft_current"):C.initTime&&T.push("xdsoft_init_time")),parseInt(v.getHours(),10)===parseInt(t,10)&&parseInt(v.getMinutes(),10)===parseInt(n,10)&&T.push("xdsoft_today"),M+='
    '+r.formatDate(i,C.formatTime)+"
    "},C.allowTimes&&e.isArray(C.allowTimes)&&C.allowTimes.length)for(D=0;D=_||m((D<10?"0":"")+D,l=(t<10?"0":"")+t))}for(R.html(M),n="",D=parseInt(C.yearStart,10);D<=parseInt(C.yearEnd,10);D+=1)n+='
    '+(D+C.yearOffset)+"
    ";for(G.children().eq(0).html(n),D=parseInt(C.monthStart,10),n="";D<=parseInt(C.monthEnd,10);D+=1)n+='
    '+C.i18n[o].months[D]+"
    ";B.children().eq(0).html(n),e(j).trigger("generate.xdsoft")},10),t.stopPropagation()}).on("afterOpen.xdsoft",function(){if(C.timepicker){var e,t,a,r;R.find(".xdsoft_current").length?e=".xdsoft_current":R.find(".xdsoft_init_time").length&&(e=".xdsoft_init_time"),e?(t=E[0].clientHeight,(a=R[0].offsetHeight)-t<(r=R.find(e).index()*C.timeHeightInTimePicker+1)&&(r=a-t),E.trigger("scroll_element.xdsoft_scroller",[parseInt(r,10)/(a-t)])):E.trigger("scroll_element.xdsoft_scroller",[0])}}),d=0,N.on("touchend click.xdsoft","td",function(t){t.stopPropagation(),d+=1;var r=e(this),n=A.currentTime;if(void 0!==n&&null!==n||(A.currentTime=A.now(),n=A.currentTime),r.hasClass("xdsoft_disabled"))return!1;n.setDate(1),n.setFullYear(r.data("year")),n.setMonth(r.data("month")),n.setDate(r.data("date")),j.trigger("select.xdsoft",[n]),a.val(A.str()),C.onSelectDate&&e.isFunction(C.onSelectDate)&&C.onSelectDate.call(j,A.currentTime,j.data("input"),t),j.data("changed",!0),j.trigger("xchange.xdsoft"),j.trigger("changedatetime.xdsoft"),(d>1||!0===C.closeOnDateSelect||!1===C.closeOnDateSelect&&!C.timepicker)&&!C.inline&&j.trigger("close.xdsoft"),setTimeout(function(){d=0},200)}),R.on("touchstart","div",function(e){this.touchMoved=!1}).on("touchmove","div",X).on("touchend click.xdsoft","div",function(t){if(!this.touchMoved){t.stopPropagation();var a=e(this),r=A.currentTime;if(void 0!==r&&null!==r||(A.currentTime=A.now(),r=A.currentTime),a.hasClass("xdsoft_disabled"))return!1;r.setHours(a.data("hour")),r.setMinutes(a.data("minute")),j.trigger("select.xdsoft",[r]),j.data("input").val(A.str()),C.onSelectTime&&e.isFunction(C.onSelectTime)&&C.onSelectTime.call(j,A.currentTime,j.data("input"),t),j.data("changed",!0),j.trigger("xchange.xdsoft"),j.trigger("changedatetime.xdsoft"),!0!==C.inline&&!0===C.closeOnTimeSelect&&j.trigger("close.xdsoft")}}),z.on("mousewheel.xdsoft",function(e){return!C.scrollMonth||(e.deltaY<0?A.nextMonth():A.prevMonth(),!1)}),a.on("mousewheel.xdsoft",function(e){return!C.scrollInput||(!C.datepicker&&C.timepicker?((P=R.find(".xdsoft_current").length?R.find(".xdsoft_current").eq(0).index():0)+e.deltaY>=0&&P+e.deltaYc+m?(l="bottom",r=c+m-t.top):r-=m):r+j[0].offsetHeight>c+m&&(r=t.top-j[0].offsetHeight+1),r<0&&(r=0),n+a.offsetWidth>d&&(n=d-a.offsetWidth)),i=j[0],H(i,function(e){if("relative"===C.contentWindow.getComputedStyle(e).getPropertyValue("position")&&d>=e.offsetWidth)return n-=(d-e.offsetWidth)/2,!1}),(f={position:o,left:n,top:"",bottom:""})[l]=r,j.css(f)},j.on("open.xdsoft",function(t){var a=!0;C.onShow&&e.isFunction(C.onShow)&&(a=C.onShow.call(j,A.currentTime,j.data("input"),t)),!1!==a&&(j.show(),Y(),e(C.contentWindow).off("resize.xdsoft",Y).on("resize.xdsoft",Y),C.closeOnWithoutClick&&e([C.ownerDocument.body,C.contentWindow]).on("touchstart mousedown.xdsoft",function t(){j.trigger("close.xdsoft"),e([C.ownerDocument.body,C.contentWindow]).off("touchstart mousedown.xdsoft",t)}))}).on("close.xdsoft",function(t){var a=!0;I.find(".xdsoft_month,.xdsoft_year").find(".xdsoft_select").hide(),C.onClose&&e.isFunction(C.onClose)&&(a=C.onClose.call(j,A.currentTime,j.data("input"),t)),!1===a||C.opened||C.inline||j.hide(),t.stopPropagation()}).on("toggle.xdsoft",function(){j.is(":visible")?j.trigger("close.xdsoft"):j.trigger("open.xdsoft")}).data("input",a),q=0,j.data("xdsoft_datetime",A),j.setOptions(C),A.setCurrentTime(i()),a.data("xdsoft_datetimepicker",j).on("open.xdsoft focusin.xdsoft mousedown.xdsoft touchstart",function(){a.is(":disabled")||a.data("xdsoft_datetimepicker").is(":visible")&&C.closeOnInputClick||C.openOnFocus&&(clearTimeout(q),q=setTimeout(function(){a.is(":disabled")||(U=!0,A.setCurrentTime(i(),!0),C.mask&&s(C),j.trigger("open.xdsoft"))},100))}).on("keydown.xdsoft",function(t){var a,r=t.which;return-1!==[p].indexOf(r)&&C.enterLikeTab?(a=e("input:visible,textarea:visible,button:visible,a:visible"),j.trigger("close.xdsoft"),a.eq(a.index(this)+1).focus(),!1):-1!==[T].indexOf(r)?(j.trigger("close.xdsoft"),!0):void 0}).on("blur.xdsoft",function(){j.trigger("close.xdsoft")})},u=function(t){var a=t.data("xdsoft_datetimepicker");a&&(a.data("xdsoft_datetime",null),a.remove(),t.data("xdsoft_datetimepicker",null).off(".xdsoft"),e(C.contentWindow).off("resize.xdsoft"),e([C.contentWindow,C.ownerDocument.body]).off("mousedown.xdsoft touchstart"),t.unmousewheel&&t.unmousewheel())},e(C.ownerDocument).off("keydown.xdsoftctrl keyup.xdsoftctrl").on("keydown.xdsoftctrl",function(e){e.keyCode===h&&(F=!0)}).on("keyup.xdsoftctrl",function(e){e.keyCode===h&&(F=!1)}),this.each(function(){var t=e(this).data("xdsoft_datetimepicker");if(t){if("string"===e.type(n))switch(n){case"show":e(this).select().focus(),t.trigger("open.xdsoft");break;case"hide":t.trigger("close.xdsoft");break;case"toggle":t.trigger("toggle.xdsoft");break;case"destroy":u(e(this));break;case"reset":this.value=this.defaultValue,this.value&&t.data("xdsoft_datetime").isValidDate(r.parseDate(this.value,C.format))||t.data("changed",!1),t.data("xdsoft_datetime").setCurrentTime(this.value);break;case"validate":t.data("input").trigger("blur.xdsoft");break;default:t[n]&&e.isFunction(t[n])&&(d=t[n](i))}else t.setOptions(n);return 0}"string"!==e.type(n)&&(!C.lazyInit||C.open||C.inline?s(e(this)):Y(e(this)))}),d},e.fn.datetimepicker.defaults=a};!function(e){"function"==typeof define&&define.amd?define(["jquery","jquery-mousewheel"],e):"object"==typeof exports?module.exports=e(require("jquery")):e(jQuery)}(datetimepickerFactory),function(e){"function"==typeof define&&define.amd?define(["jquery"],e):"object"==typeof exports?module.exports=e:e(jQuery)}(function(e){function t(t){var i=t||window.event,s=u.call(arguments,1),d=0,f=0,c=0,m=0,h=0,g=0;if(t=e.event.fix(i),t.type="mousewheel","detail"in i&&(c=-1*i.detail),"wheelDelta"in i&&(c=i.wheelDelta),"wheelDeltaY"in i&&(c=i.wheelDeltaY),"wheelDeltaX"in i&&(f=-1*i.wheelDeltaX),"axis"in i&&i.axis===i.HORIZONTAL_AXIS&&(f=-1*c,c=0),d=0===c?f:c,"deltaY"in i&&(d=c=-1*i.deltaY),"deltaX"in i&&(f=i.deltaX,0===c&&(d=-1*f)),0!==c||0!==f){if(1===i.deltaMode){var p=e.data(this,"mousewheel-line-height");d*=p,c*=p,f*=p}else if(2===i.deltaMode){var D=e.data(this,"mousewheel-page-height");d*=D,c*=D,f*=D}if(m=Math.max(Math.abs(c),Math.abs(f)),(!o||m=1?"floor":"ceil"](d/o),f=Math[f>=1?"floor":"ceil"](f/o),c=Math[c>=1?"floor":"ceil"](c/o),l.settings.normalizeOffset&&this.getBoundingClientRect){var v=this.getBoundingClientRect();h=t.clientX-v.left,g=t.clientY-v.top}return t.deltaX=f,t.deltaY=c,t.deltaFactor=o,t.offsetX=h,t.offsetY=g,t.deltaMode=0,s.unshift(t,d,f,c),n&&clearTimeout(n),n=setTimeout(a,200),(e.event.dispatch||e.event.handle).apply(this,s)}}function a(){o=null}function r(e,t){return l.settings.adjustOldDeltas&&"mousewheel"===e.type&&t%120==0}var n,o,i=["wheel","mousewheel","DOMMouseScroll","MozMousePixelScroll"],s="onwheel"in document||document.documentMode>=9?["wheel"]:["mousewheel","DomMouseScroll","MozMousePixelScroll"],u=Array.prototype.slice;if(e.event.fixHooks)for(var d=i.length;d;)e.event.fixHooks[i[--d]]=e.event.mouseHooks;var l=e.event.special.mousewheel={version:"3.1.12",setup:function(){if(this.addEventListener)for(var a=s.length;a;)this.addEventListener(s[--a],t,!1);else this.onmousewheel=t;e.data(this,"mousewheel-line-height",l.getLineHeight(this)),e.data(this,"mousewheel-page-height",l.getPageHeight(this))},teardown:function(){if(this.removeEventListener)for(var a=s.length;a;)this.removeEventListener(s[--a],t,!1);else this.onmousewheel=null;e.removeData(this,"mousewheel-line-height"),e.removeData(this,"mousewheel-page-height")},getLineHeight:function(t){var a=e(t),r=a["offsetParent"in e.fn?"offsetParent":"parent"]();return r.length||(r=e("body")),parseInt(r.css("fontSize"),10)||parseInt(a.css("fontSize"),10)||16},getPageHeight:function(t){return e(t).height()},settings:{adjustOldDeltas:!0,normalizeOffset:!0}};e.fn.extend({mousewheel:function(e){return e?this.bind("mousewheel",e):this.trigger("mousewheel")},unmousewheel:function(e){return this.unbind("mousewheel",e)}})}); \ No newline at end of file diff --git a/BaCa2/assets/js/login.js b/BaCa2/assets/js/login.js new file mode 100644 index 00000000..c42d56ad --- /dev/null +++ b/BaCa2/assets/js/login.js @@ -0,0 +1,49 @@ +function transformLoginFormBtn(loginFormBtn, text, cls, loginForm) { + loginFormBtn.position("relative"); + loginFormBtn.text(text); + loginFormBtn.attr("class", cls); + loginFormBtn.click(() => loginForm.submit()); +} + +function initLoginPage() { + const loginForm = $("#login_form"); + const loginBtn = loginForm.find("button"); + const loginFormBtn = $("#login-form-btn"); + const alert = $(".alert-danger"); + + if (!sessionStorage.getItem("loginFormRefresh")) { + loginForm.hide(); + loginBtn.hide(); + loginFormBtn.css("transition", "all 0.8s ease"); + loginFormBtn.click(() => { + $("#login_form").slideDown("slow", () => loginForm.find("#username").focus()); + transformLoginFormBtn(loginFormBtn, loginBtn.text(), loginBtn.attr("class"), loginForm); + shrinkLogo($(".logo-wrapper")); + }); + } + else { + loginBtn.hide(); + shrinkLogo($(".logo-wrapper"), false); + transformLoginFormBtn(loginFormBtn, loginBtn.text(), loginBtn.attr("class"), loginForm); + + if (!sessionStorage.getItem("loginFormAlert")) + alert.hide(); + } + + sessionStorage.removeItem("loginFormRefresh"); + + $(document).on("submit", "#login_form", + (e) => sessionStorage.setItem("loginFormRefresh", "true")) +} + +function displayLoginFormAlert() { + const alert = $(".alert-danger"); + + if (!sessionStorage.getItem("loginFormAlert")) + alert.slideDown("slow"); + + if (alert.length > 0) + sessionStorage.setItem("loginFormAlert", "true"); + else + sessionStorage.removeItem("loginFormAlert"); +} diff --git a/BaCa2/assets/js/logo.js b/BaCa2/assets/js/logo.js new file mode 100644 index 00000000..7eac3e41 --- /dev/null +++ b/BaCa2/assets/js/logo.js @@ -0,0 +1,16 @@ +function shrinkLogo(logoWrapper, animated = true) { + const prevTransition = logoWrapper.find("baca2-logo").css("transition"); + + if (!animated) + logoWrapper.find("baca2-logo").css("transition", "none"); + + if (logoWrapper.hasClass("logo-l")) + logoWrapper.removeClass("logo-l").addClass("logo-m"); + else if (logoWrapper.hasClass("logo-m")) + logoWrapper.removeClass("logo-m").addClass("logo-s"); + else if (logoWrapper.hasClass("logo-s")) + logoWrapper.removeClass("logo-s").addClass("logo-xs"); + + if (!animated) + logoWrapper.find("baca2-logo").css("transition", prevTransition); +} diff --git a/BaCa2/assets/js/pdf_displayer.js b/BaCa2/assets/js/pdf_displayer.js new file mode 100644 index 00000000..7104ec5b --- /dev/null +++ b/BaCa2/assets/js/pdf_displayer.js @@ -0,0 +1,84 @@ +class PDFDisplayer { + constructor(widgetId, pdfUrl) { + this.pdfUrl = pdfUrl; + this.pdfDoc = null; + this.pageNum = 1; + this.pageRendering = false; + this.pageNumPending = null; + this.scale = 2.5; + this.displayer = $(`#${widgetId}`); + this.canvas = this.displayer.find('canvas')[0]; + this.context = this.canvas.getContext('2d'); + this.init(); + } + + renderPage(num) { + this.pageRendering = true; + + this.pdfDoc.getPage(num).then(page => { + const viewport = page.getViewport({ scale: this.scale }); + this.canvas.height = viewport.height; + this.canvas.width = viewport.width; + + const renderContext = { + canvasContext: this.context, + viewport: viewport + }; + + page.render(renderContext).promise.then(() => { + this.pageRendering = false; + + if (this.pageNumPending !== null) { + this.renderPage(this.pageNumPending); + this.pageNumPending = null; + } + }); + + this.pageNum = num; + this.displayer.find('.pdf-page-number').val(num); + }); + } + + queueRenderPage(num) { + if (this.pageRendering) + this.pageNumPending = num; + else + this.renderPage(num); + } + + showPrevPage() { + if (this.pageNum <= 1) + return; + this.pageNum--; + this.queueRenderPage(this.pageNum); + } + + showNextPage() { + if (this.pageNum >= this.pdfDoc.numPages) + return; + this.pageNum++; + this.queueRenderPage(this.pageNum); + } + + init() { + pdfjsLib.getDocument(this.pdfUrl).promise.then(pdf => { + this.pdfDoc = pdf; + + if (this.pdfDoc.numPages <= 1) { + this.displayer.find('.pdf-navigation').remove(); + } else { + this.displayer.find('.pdf-page-count').text(this.pdfDoc.numPages); + this.displayer.find('.prev-page-btn').on('click', () => this.showPrevPage()); + this.displayer.find('.next-page-btn').on('click', () => this.showNextPage()); + this.displayer.find('.pdf-page-change').submit(e => { + e.preventDefault(); + const pageNum = parseInt(this.displayer.find('.pdf-page-number').val()); + if (pageNum > 0 && pageNum <= this.pdfDoc.numPages) + this.queueRenderPage(pageNum); + }); + } + + this.renderPage(this.pageNum); + }); + } +} \ No newline at end of file diff --git a/BaCa2/assets/js/side_nav.js b/BaCa2/assets/js/side_nav.js new file mode 100644 index 00000000..8cc1c67b --- /dev/null +++ b/BaCa2/assets/js/side_nav.js @@ -0,0 +1,75 @@ +function sideNavSetup() { + $('.side-nav-link').click(function () { + clickSidenavLink($(this)); + }); + + let link; + const tab = new URL(window.location.href).searchParams.get('tab'); + + if (tab !== null) + link = $(document.body).find('.side-nav-link[data-id="' + tab + '"]'); + if (link === undefined || link.length === 0) + link = $(document.body).find('.side-nav-content') + .find('.tab-button:last') + .find('.side-nav-link:first'); + + clickSidenavLink(link); +} + +function clickSidenavLink(link) { + const clicked_button = link.closest('.side-nav-button'); + const side_nav = clicked_button.closest('.side-nav-content'); + const active_button = side_nav.find('.active'); + const expanded_button = side_nav.find('.expanded'); + const active_link = active_button.find('.side-nav-link'); + let activated_link; + + if (clicked_button[0] === active_button[0] || clicked_button[0] === expanded_button[0]) + return; + + if (clicked_button.hasClass('sub-tabs')) { + const activated_button = clicked_button.find('.sub-tab-button:first'); + activated_button.addClass('active'); + clicked_button.addClass('expanded'); + activated_link = activated_button.find('.side-nav-link'); + expanded_button.removeClass('expanded'); + } else { + clicked_button.addClass('active') + activated_link = clicked_button.find('.side-nav-link'); + } + + if (clicked_button.hasClass('sub-tab-button') && + !clicked_button.closest('.sub-tabs').hasClass('expanded')) { + clicked_button.closest('.sub-tabs').addClass('expanded'); + expanded_button.removeClass('expanded'); + } else if (clicked_button.hasClass('tab-button')) { + expanded_button.removeClass('expanded'); + } + + const activeTab = $(`#${active_link.data('id')}`); + const activatedTab = $(`#${activated_link.data('id')}`); + + activeTab.trigger('tab-deactivated').removeClass('active').slideUp(); + activatedTab.trigger('tab-activated').addClass('active').slideDown(); + active_button.removeClass('active'); + addURLParameter(activated_link.data('id')); +} + +function toggleSidenavButton(button, textCollapsed, textExpanded) { + const side_nav = $(document.body).find('.side-nav'); + if (side_nav.hasClass('collapsed')) { + side_nav.removeClass('collapsed'); + side_nav.addClass('expanded'); + $(button).text(textExpanded); + } else { + side_nav.removeClass('expanded'); + side_nav.addClass('collapsed'); + $(button).text(textCollapsed); + } +} + +function addURLParameter(param) { + const url = new URL(window.location.href); + url.searchParams.set('tab', param); + window.history.replaceState({}, '', url); +} diff --git a/BaCa2/assets/js/tables.js b/BaCa2/assets/js/tables.js new file mode 100644 index 00000000..9d935b90 --- /dev/null +++ b/BaCa2/assets/js/tables.js @@ -0,0 +1,736 @@ +// ------------------------------------ table widget class ------------------------------------ // + +class TableWidget { + constructor(tableId, DTObj, ajax) { + this.DTObj = DTObj; + this.table = $(`#${tableId}`); + this.ajax = ajax; + this.widgetWrapper = this.table.closest('.table-wrapper'); + this.lastSelectedRow = null; + this.lastDeselectedRow = null; + } + + reload() { + if (!this.ajax) return; + const tableWidget = this; + const table = this.table; + const DTObj = this.DTObj; + + DTObj.ajax.reload(function () { + DTObj.columns.adjust().draw(); + tableWidget.updateSelectHeader(); + table.trigger('table-reload'); + }); + } + + init() { + this.formSubmitColsInit(); + } + + formSubmitColsInit() { + const tableWidget = this; + + this.table.find('th.form-submit[data-refresh-table-on-submit]').each(function () { + $(this).find('form').on('submit-complete', function () { + tableWidget.reload(); + }); + }); + } + + // -------------------------------- record select methods --------------------------------- // + + toggleSelectRow(row, on) { + if (on) { + this.lastSelectedRow = row; + this.lastDeselectedRow = null; + row.addClass('row-selected'); + } else { + this.lastSelectedRow = null; + this.lastDeselectedRow = row; + row.removeClass('row-selected'); + } + } + + toggleSelectRows(rows, on) { + rows.each(function () { + $(this).find('.select-checkbox').prop('checked', on); + + if (on) + $(this).addClass('row-selected'); + else + $(this).removeClass('row-selected'); + }); + } + + toggleSelectRange(row, on) { + const rows = this.getRowsInOrder(); + const currentIndex = this.getRowIndex(row) + const lastIndex = on ? + this.getRowIndex(this.lastSelectedRow) : + this.getRowIndex(this.lastDeselectedRow); + + let selecting = false; + let last = false; + + for (let i = 0; i < rows.length; i++) { + const row = rows[i]; + const index = this.getRowIndex(row); + + if (index === currentIndex || index === lastIndex) { + if (selecting) + last = true; + else + selecting = true; + } + + if (selecting) { + if (on) + $(row).addClass('row-selected'); + else + $(row).removeClass('row-selected'); + $(row).find('.select-checkbox').prop('checked', on); + } + + if (last) + break; + } + } + + toggleSelectAll(on) { + this.getCurrentRowsInOrder().each(function () { + $(this).find('.select-checkbox').prop('checked', on); + + if (on) + $(this).addClass('row-selected'); + else + $(this).removeClass('row-selected'); + }); + } + + updateSelectHeader() { + const headerCheckbox = this.widgetWrapper.find('.select-header-checkbox'); + let allSelected = true; + let noneSelected = true; + let rows = this.getCurrentPageRowsInOrder(); + + if (this.table.hasClass('filtered')) + rows = this.getCurrentRowsInOrder(); + + rows.each(function () { + if ($(this).hasClass('row-selected')) + noneSelected = false; + else + allSelected = false; + }); + + if (noneSelected) { + headerCheckbox.prop('checked', false); + headerCheckbox.prop('indeterminate', false); + headerCheckbox.data('state', 'off'); + } else if (allSelected) { + headerCheckbox.prop('checked', true); + headerCheckbox.prop('indeterminate', false); + headerCheckbox.data('state', 'on'); + } else { + headerCheckbox.prop('checked', false); + headerCheckbox.prop('indeterminate', true); + headerCheckbox.data('state', 'indeterminate'); + } + } + + // ------------------------------------ getter methods ------------------------------------ // + + getRowsInOrder() { + return this.DTObj.rows({order: 'applied'}).nodes().to$(); + } + + getCurrentPageRowsInOrder() { + return this.DTObj.rows({order: 'applied', page: 'current'}).nodes().to$(); + } + + getCurrentRowsInOrder() { + return this.DTObj + .rows({order: 'applied', page: 'current', search: 'applied'}) + .nodes().to$(); + } + + getAllSelectedRows() { + return this.DTObj.rows().nodes().to$().filter(function () { + return $(this).hasClass('row-selected'); + }); + } + + getFilteredOutRows() { + return this.DTObj.rows({search: 'removed'}).nodes().to$(); + } + + getRowIndex(row) { + return this.DTObj.row(row).index(); + } + + // ----------------------------------- row check methods ---------------------------------- // + + hasLastSelectedRow() { + return this.lastSelectedRow !== null; + } + + hasLastDeselectedRow() { + return this.lastDeselectedRow !== null; + } +} + + +// --------------------------------------- tables setup --------------------------------------- // + +function tablesPreSetup() { + tableResizeSetup(); + + $(document).on('tab-activated', function (e) { + const tab = $(e.target); + const tableId = tab.find('.table-wrapper').data('table-id'); + + if (tableId === undefined) + return; + + const tableWidget = window.tableWidgets[`#${tableId}`]; + + if (!tableWidget.ajax) + return; + + tableWidget.DTObj.ajax.reload(function () { + tableWidget.DTObj.columns.adjust().draw(); + $(`#${tableId}`).trigger('table-reload'); + }); + }); +} + +function tablesSetup() { + $('.delete-record-form').each(function () { + deleteRecordFormSetup($(this).find('form'), $(this).data('table-id')); + }); + + $('.table-refresh-btn').on('click', function () { + refreshButtonClickHandler($(this)); + }); + + $('.table-wrapper').each(function () { + const tableId = $(this).data('table-id'); + + $(this).find('th.select').each(function () { + renderSelectHeader($(this), tableId); + }); + + globalSearchSetup($(this)); + columnSearchSetup($(this)); + }); + + $('.link-records').each(function () { + recordLinkSetup($(this).attr('id')); + }); + + lengthMenuSetup(); +} + + +// --------------------------------------- table search --------------------------------------- // + +function globalSearchSetup(tableWrapper) { + const search = tableWrapper.find('.dataTables_filter'); + + if (search.length === 0) + return; + + const searchInput = search.find('input'); + const searchWrapper = tableWrapper.find('.table-util-header .table-search'); + const tableId = tableWrapper.data('table-id'); + const table = tableWrapper.find(`#${tableId}`); + const tableWidget = window.tableWidgets[`#${tableId}`]; + + searchInput.addClass('form-control').attr('placeholder', 'Search').attr('type', 'text'); + searchInput.attr('id', `${tableId}_search`); + searchInput.on('input', function () { + globalSearchInputHandler($(this), table, tableWidget); + }); + + if (searchWrapper.length === 0) + return; + + searchWrapper.append(searchInput); + search.remove(); +} + + +function globalSearchInputHandler(inputField, table, tableWidget) { + if (table.data('deselect-on-filter')) + tableWidget.toggleSelectRows(tableWidget.getFilteredOutRows(), false); + + if (inputField.val() === '') + table.removeClass('filtered'); + else + table.addClass('filtered'); + + tableWidget.updateSelectHeader(); +} + + +function columnSearchSetup(tableWrapper) { + const tableId = tableWrapper.data('table-id'); + const table = tableWrapper.find(`#${tableId}`); + const tableWidget = window.tableWidgets[`#${table.attr('id')}`] + + tableWrapper.find('.column-search').on('click', function (e) { + e.stopPropagation(); + }).on('input', function () { + columnSearchInputHandler($(this), table, tableWidget) + }); +} + + +function columnSearchInputHandler(inputField, table, tableWidget) { + tableWidget.DTObj.column(inputField.closest('th')).search(inputField.val()).draw(); + + if (table.data('deselect-on-filter')) + tableWidget.toggleSelectRows(tableWidget.getFilteredOutRows(), false); + + if (inputField.val() === '') + table.removeClass('filtered'); + else + table.addClass('filtered'); + + tableWidget.updateSelectHeader(); +} + + +// --------------------------------------- table paging --------------------------------------- // + +function lengthMenuSetup() { + $('.dataTables_length').each(function () { + const lengthMenu = $(this); + const label = lengthMenu.find('label'); + const select = lengthMenu.find('select'); + + label.addClass('d-flex align-items-center'); + select.addClass('form-select form-select-fm auto-width ms-2 me-2'); + + const tableWrapper = $(this).closest('.table-wrapper'); + const lengthMenuWrapper = tableWrapper.find('.table-util-header .table-length-menu'); + + if (lengthMenuWrapper.length === 0) + return; + + lengthMenuWrapper.append(lengthMenu); + }); +} + + +// --------------------------------------- table buttons -------------------------------------- // + +function refreshButtonClickHandler(button) { + const tableId = button.data('refresh-target'); + const tableWidget = window.tableWidgets[`#${tableId}`]; + tableWidget.DTObj.ajax.reload(function () { + tableWidget.DTObj.columns.adjust().draw(); + tableWidget.updateSelectHeader(); + $(`#${tableId}`).trigger('table-reload'); + }); +} + + +// ---------------------------------------- table forms --------------------------------------- // + +function deleteRecordFormSetup(form, tableId) { + form.on('submit-success', function (e, data) { + window.tableWidgets[`#${tableId}`].DTObj.ajax.reload(); + }); +} + + +function recordLinkSetup(tableId) { + $(`#${tableId}`).on('click', 'tbody tr[data-record-link]', function () { + window.location.href = $(this).data('record-link'); + }); +} + + +// -------------------------------------- DataTables init ------------------------------------- // + +function initTable( + { + tableId, + ajax, + dataSourceUrl, + dataSource, + linkFormatString, + cols, + defaultSorting, + defaultOrder, + defaultOrderCol, + searching, + paging, + limitHeight, + height, + refresh, + refreshInterval, + localisation_cdn, + } = {} +) { + const tableParams = {}; + const table = $(`#${tableId}`); + + if (ajax) + tableParams['ajax'] = dataSourceUrl; + else + tableParams['data'] = dataSource; + + if (defaultSorting) + tableParams['order'] = [[defaultOrderCol, defaultOrder]]; + else + tableParams['order'] = []; + + if (limitHeight) { + tableParams['scrollResize'] = true; + tableParams['scrollY'] = height; + tableParams['scrollCollapse'] = true; + } + + if (localisation_cdn){ + tableParams['language'] = { + "url": localisation_cdn + } + } + + tableParams['searching'] = searching; + + const columns = []; + cols.forEach(col => { + columns.push({'data': col['name']}) + }); + tableParams['columns'] = columns; + + const columnDefs = []; + cols.forEach(col => columnDefs.push(createColumnDef(col, cols.indexOf(col)))); + tableParams['columnDefs'] = columnDefs; + + tableParams['rowCallback'] = createRowCallback(linkFormatString); + + createPagingDef(paging, tableParams, table, tableId); + + if (!window.tableWidgets) + window.tableWidgets = {}; + + const tableWidget = new TableWidget( + tableId, + table.DataTable(tableParams), + ajax + ); + tableWidget.init(); + + window.tableWidgets[`#${tableId}`] = tableWidget; + + if (refresh) + setRefresh(tableId, refreshInterval); +} + + +function createPagingDef(paging, DTParams, table, tableId) { + if (paging) { + DTParams['pageLength'] = paging['page_length']; + + if (JSON.parse(paging['allow_length_change'])) { + const pagingMenuVals = []; + const pagingMenuLabels = []; + + paging['length_change_options'].forEach(option => { + pagingMenuVals.push(option); + pagingMenuLabels.push(option === -1 ? 'All' : `${option}`); + }) + + DTParams['lengthMenu'] = [pagingMenuVals, pagingMenuLabels]; + } else + DTParams['lengthChange'] = false; + + if (JSON.parse(paging['deselect_on_page_change'])) { + const tableWrapper = table.closest('.table-wrapper'); + + table.on('page.dt', function () { + const selectHeaderCheckbox = tableWrapper.find('th .select-header-checkbox'); + window.tableWidgets[`#${tableId}`].toggleSelectAll(false); + selectHeaderCheckbox.prop('checked', false); + selectHeaderCheckbox.prop('indeterminate', false); + }); + } + } else + DTParams['paging'] = false; +} + + +function setRefresh(tableId, interval) { + setInterval(function () { + window.tableWidgets[`#${tableId}`].DTObj.ajax.reload(); + }, interval); +} + + +function createRowCallback(linkFormatString) { + return function (row, data) { + $(row).attr('data-record-id', `${data.id}`); + $(row).attr('data-record-data', JSON.stringify(data)); + + if (linkFormatString) { + $(row).attr('data-record-link', generateFormattedString(data, linkFormatString)); + } + } +} + + +function createColumnDef(col, index) { + const def = { + 'targets': [index], + 'orderable': JSON.parse(col['sortable']), + 'searchable': JSON.parse(col['searchable']), + 'className': col['col_type'] + }; + + if (JSON.parse(col['data_null'])) + def['data'] = null; + + if (!JSON.parse(col['auto_width'])) { + def['autoWidth'] = false; + def['width'] = col['width']; + } else + def['autoWidth'] = true; + + switch (col['col_type']) { + case 'select': + def['render'] = renderSelectField; + break; + case 'delete': + def['render'] = renderDeleteField; + break; + case 'datetime': + def['render'] = DataTable.render.datetime(col['formatter']); + break; + case 'form-submit': + def['render'] = renderFormSubmitField(col); + break; + } + + return def; +} + + +// ------------------------------- special field render methods ------------------------------- // + +function renderSelectField(data, type, row, meta) { + return $('') + .attr('type', 'checkbox') + .attr('class', 'form-check-input select-checkbox') + .attr('data-record-target', row['id']) + .attr('onclick', 'selectCheckboxClickHandler(event, $(this))') + [0].outerHTML; +} + + +function renderSelectHeader(header, tableId) { + const checkbox = $('') + .attr('type', 'checkbox') + .attr('class', 'form-check-input select-header-checkbox') + .attr('data-state', 'off') + .on('click', function () { + selectHeaderClickHandler($(this), tableId); + }); + header.append(checkbox); +} + + +function renderDeleteField(data, type, row, meta) { + return $('') + .attr('href', '#') + .attr('data-record-target', row['id']) + .attr('onclick', 'deleteButtonClickHandler(event, $(this))') + .html('') + [0].outerHTML; +} + +function renderFormSubmitField(col) { + const mappings = col['mappings']; + const form_id = col['form_id']; + const btnIcon = col['btn_icon']; + const btnText = col['btn_text']; + const conditional =JSON.parse(col['conditional']); + const conditionKey = col['condition_key']; + const conditionValue = col['condition_value']; + + return function (data, type, row, meta) { + let disabled = false; + + if (conditional && row[conditionKey].toString() !== conditionValue) { + const disabledAppearance = col['disabled_appearance']; + const disabledContent = col['disabled_content']; + + switch (disabledAppearance) { + case 'disabled': + disabled = true; + break; + case 'hidden': + return ''; + case 'text': + return disabledContent; + case 'icon': + return `
    + +
    `; + } + } + + const button = $('
    ') + .attr('class', 'btn btn-outline-primary') + .attr('href', '#') + .attr('data-mappings', mappings) + .attr('data-form-id', form_id) + .attr('onclick', 'formSubmitButtonClickHandler(event, $(this))'); + + const content = $('
    ').addClass('d-flex'); + const text = generateFormattedString(row, btnText); + + if (btnIcon) { + const icon = $('').addClass(`bi bi-${btnIcon}`).addClass(btnText ? 'me-2' : ''); + content.append(icon); + } + + if (disabled) { + button.addClass('disabled') + button.attr('disabled', true); + } + + content.append(text); + button.append(content); + return button[0].outerHTML; + }; +} + + +// ------------------------------- special field click handlers ------------------------------- // + +function selectHeaderClickHandler(checkbox, tableId) { + const table = checkbox.closest('.table-wrapper').find(`#${tableId}`); + const tableWidget = window.tableWidgets[`#${tableId}`]; + + tableWidget.toggleSelectAll( + checkbox.prop('checked') + ); + + if (table.hasClass('filtered')) { + tableWidget.updateSelectHeader(); + } +} + + +function selectCheckboxClickHandler(e, checkbox) { + e.stopPropagation(); + const table = window.tableWidgets[`#${checkbox.closest('table').attr('id')}`] + const row = checkbox.closest('tr'); + const on = checkbox.prop('checked'); + + if (e.shiftKey && on && table.hasLastSelectedRow()) + table.toggleSelectRange(row, on); + else if (e.ctrlKey && !on && table.hasLastDeselectedRow()) + table.toggleSelectRange(row, on); + else + table.toggleSelectRow(row, on); + + table.updateSelectHeader(); +} + + +function deleteButtonClickHandler(e, button) { + e.stopPropagation(); + const form = button.closest('.table-wrapper').find('.delete-record-form form'); + const input = form.find('input').filter(function () { + return $(this).hasClass('model-id') + }); + input.val(button.data('record-target')); + form.find('.submit-btn')[0].click(); +} + + +function formSubmitButtonClickHandler(e, button) { + e.stopPropagation(); + const mappings = button.data('mappings'); + const formId = button.data('form-id'); + const form = $(`#${formId}`); + const data = button.closest('tr').data('record-data'); + + for (const key in mappings) { + const input = form.find(`input[name="${key}"]`); + input.val(data[mappings[key]]); + } + + form.find('.submit-btn')[0].click(); +} + + +// -------------------------------------- resizable table ------------------------------------- // + +function tableResizeSetup() { + $(document).on('init.dt', function (e) { + const table = $(e.target); + const resizeWrapper = table.closest('.resize-wrapper'); + + if (!resizeWrapper.length) return; + + const DTLength = resizeWrapper.find('.dataTables_length'); + const DTInfo = resizeWrapper.find('.dataTables_info'); + const DTPaging = resizeWrapper.find('.dataTables_paginate'); + + const lengthSelect = $('
    ').addClass('dataTables_wrapper mb-1'); + lengthSelect.append(DTLength); + lengthSelect.insertBefore(resizeWrapper); + + const pagingInfo = $('
    ').addClass('dataTables_wrapper mb-1') + pagingInfo.append(DTInfo); + pagingInfo.append(DTPaging); + pagingInfo.insertAfter(resizeWrapper); + + const DTScroll = resizeWrapper.find('.dataTables_scroll'); + const DTScrollHead = DTScroll.find('.dataTables_scrollHead'); + const DTScrollBody = DTScroll.find('.dataTables_scrollBody'); + + const bodyHeight = DTScroll.height() - DTScrollHead.height(); + + DTScrollBody.height(bodyHeight); + DTScrollBody.css('max-height', bodyHeight); + + const resizeHandle = $(this).find('.resize-handle'); + const rowHeight = table.find('tbody tr').first().height(); + + resizeHandle.on('mousedown', function (e) { + e.preventDefault(); + const startY = e.pageY; + const startHeight = resizeWrapper.height(); + + + $(document).on('mousemove', function (e) { + e.preventDefault(); + let newHeight = startHeight + e.pageY - startY; + let newBodyHeight = newHeight - DTScrollHead.height(); + + if (newBodyHeight < rowHeight) { + newBodyHeight = rowHeight; + newHeight = newBodyHeight + DTScrollHead.height(); + } + + if (newBodyHeight > table.height()) { + newBodyHeight = table.height(); + newHeight = newBodyHeight + DTScrollHead.height(); + } + + resizeWrapper.height(newHeight); + DTScrollBody.height(newBodyHeight); + DTScrollBody.css('max-height', newBodyHeight); + }).on('mouseup', function () { + $(document).off('mousemove mouseup'); + }); + }); + }); +} diff --git a/BaCa2/assets/js/themes.js b/BaCa2/assets/js/themes.js new file mode 100644 index 00000000..fa83e637 --- /dev/null +++ b/BaCa2/assets/js/themes.js @@ -0,0 +1,39 @@ +function themePreSetup() { + $(document).on('theme-switched', function (e, theme) { + codeHighlightingThemeSwitch(theme); + }); +} + +function themeSetup() { + codeHighlightingThemeSwitch($('body').attr('data-bs-theme')); +} + +function codeHighlightingThemeSwitch(theme) { + $('link.code-highlighting').attr("disabled", true); + $('link.code-highlighting[data-bs-theme=' + theme + ']').removeAttr("disabled"); +} + +function switchTheme(post_url, csrf_token) { + const body = $('body'); + const theme = body.attr('data-bs-theme') === 'dark' ? 'light' : 'dark'; + + body.attr('data-bs-theme', theme); + body.removeClass('dark-theme light-theme').addClass(theme + '-theme'); + body.trigger('theme-switched', theme); + + postThemeSwitch(body, post_url, csrf_token); +} + +function postThemeSwitch(body, post_url, csrf_token) { + $.ajax({ + type: "POST", + url: post_url, + data: { + csrfmiddlewaretoken: csrf_token, + theme: body.attr('data-bs-theme') + }, + success: function (data) { + console.log(data); + } + }) +} diff --git a/BaCa2/assets/scss/_baca2_logo.scss b/BaCa2/assets/scss/_baca2_logo.scss new file mode 100644 index 00000000..303a384e --- /dev/null +++ b/BaCa2/assets/scss/_baca2_logo.scss @@ -0,0 +1,66 @@ +@import "baca2_variables"; + +// color styling +.logo-wrapper { + .logo-stroke { + fill: $logo_letters_light_theme; + } + .logo-large-triangle { + fill: $logo_large_triangle; + } + .logo-medium-triangle { + fill: $logo_medium_triangle; + } + .logo-small-triangle { + fill: $logo_small_triangle; + } +} +[data-bs-theme="dark"] .logo-wrapper { + .logo-stroke { + fill: $logo_letters_dark_theme; + } +} + +// display and size styling +.logo-wrapper { + .baca2-logo { + transition: height 0.8s ease; + } + + @each $version in $logo_versions { + .logo-#{$version} { + display: none; + } + } + + .logo-#{$default_logo_version} { + display: block; + } + + @each $version in $logo_versions { + &.logo-#{$version} { + @each $v in $logo_versions { + @if $v != $version { + .logo-#{$v} { + display: none; + } + } + } + + .logo-#{$version} { + display: block; + } + } + } + + @each $size in map-keys($logo_sizes) { + &.logo-#{$size} { + @each $version in $logo_versions { + .logo-#{$version} { + width: auto; + height: map-get(map-get($logo_sizes, $size), $version); + } + } + } + } +} diff --git a/BaCa2/assets/scss/_baca2_variables.scss b/BaCa2/assets/scss/_baca2_variables.scss new file mode 100644 index 00000000..e5c7aea3 --- /dev/null +++ b/BaCa2/assets/scss/_baca2_variables.scss @@ -0,0 +1,255 @@ +@use 'sass:map'; + +// ----------------------------------------- themes ------------------------------------------- // + +$themes: light, dark; + +// ----------------------------- bootstrap theme colors overrides ----------------------------- // + +$primary: #FE2E63; +$secondary: #D3CABD; +$danger: #FE2E63; +$success: #08D9D6; +$muted: gray; + +// ------------------------------------ custom theme colors ----------------------------------- // + +$baca2_pink: #FE2E63; +$baca2_beige: #D3CABD; +$baca2_blue: #08D9D6; +$dark_muted: #3e4042; +$light_muted: #A0A0A0FF; +$pale_muted: #d0d0d0; +$darker: #171a1d; + +// ------------------------ imports necessary for extending theme-colors ---------------------- // + +@import '../../../node_modules/bootstrap/scss/_functions'; +@import '../../../node_modules/bootstrap/scss/_variables'; + +// ------------------------------ bootstrap theme colors extension ---------------------------- // + +$baca2-theme-colors: ( + "baca2_blue": $baca2_blue, + "baca2_beige": $baca2_beige, + "baca2_pink": $baca2_pink, + "dark_muted": $dark_muted, + "light_muted": $light_muted, + "pale_muted": $pale_muted, + "darker": $darker, +); +$theme-colors: map-merge($baca2-theme-colors, $theme-colors); + +// ------------------------ bootstrap custom color properties overrides ----------------------- // + +:root { + --bs-form-valid-border-color: $red; +} + +// ------------------------------------------- logo ------------------------------------------- // + +$logo_versions: horizontal, square, icon; +$logo_sizes: ( + xs: ( + horizontal: 1.5rem, + square: 2rem, + icon: 1.5rem + ), + s: ( + horizontal: 3rem, + square: 4rem, + icon: 2rem + ), + m: ( + horizontal: 4rem, + square: 8rem, + icon: 4rem + ), + l: ( + horizontal: 8rem, + square: 12rem, + icon: 8rem + ) +); +$default_logo_version: horizontal; +$logo_letters_light_theme: #000000; +$logo_letters_dark_theme: #ffffff; +$logo_small_triangle: $baca2_pink; +$logo_medium_triangle: $baca2_beige; +$logo_large_triangle: $baca2_blue; + +// ------------------------------------------ icons ------------------------------------------- // + +$icon_sizes: ( + s: 1rem, + m: 1.5rem, + l: 2rem +); + +// ------------------------------------------ cards ------------------------------------------- // + +$card_sizes: ( + s: 15rem, + m: 25rem, + l: 40rem +); + +// ----------------------------------------- scrollbar ---------------------------------------- // + +$scrollbar_width: 20px; +$scrollbar_default_variables: ( + color: $pale_muted, + color_hover: $light_muted, +); +$scrollbar_theme_variables: ( + light: (), + dark: ( + color: $dark_muted, + color_hover: $light_muted, + ) +); + +// ------------------------------------------ navbar ------------------------------------------ // + +$navbar_default_variables: ( + bg: $light, + link_color: $dark, + active_link_color: $baca2_pink, + icon_color: $dark, + hover_color: $baca2_pink, + logo_hover_color: $baca2_blue, + divider_color: $light_muted, +); +$navbar_theme_variables: ( + light: (), + dark: ( + bg: $black, + link_color: $muted, + active_link_color: $white, + icon_color: $white, + divider_color: $dark_muted, + ) +); +$navbar_padding: 4.25rem; + +// ----------------------------------------- sidenav ------------------------------------------ // + +$side_nav_default_variables: ( + tab_bg_color: $light, + tab_bg_color_hover: $white, + tab_bg_color_active: $baca2_beige, + tab_bg_color_expanded: $white, + tab_bg_color_expanded_hover: $white, + tab_bg_color_active_hover: $white, + sub_tab_bg_color: $light, + sub_tab_bg_color_expanded: $white, + sub_tab_bg_color_hover: $baca2_beige, + sub_tab_bg_color_active: $baca2_beige, + link_color: $muted, + link_color_hover: $dark, + link_color_active: $dark, + link_color_active_hover: $dark, + border_color: $light_muted, + border_color_hover: $dark, + border_color_active: $dark, +); +$side_nav_theme_variables: ( + light: (), + dark: ( + tab_bg_color: $black, + tab_bg_color_hover: $dark, + tab_bg_color_active: $dark_muted, + tab_bg_color_expanded: $dark, + tab_bg_color_expanded_hover: $dark, + tab_bg_color_active_hover: $dark, + sub_tab_bg_color: $black, + sub_tab_bg_color_expanded: $black, + sub_tab_bg_color_hover: $dark_muted, + sub_tab_bg_color_active: $dark_muted, + link_color: $muted, + link_color_hover: $white, + link_color_active: $white, + link_color_active_hover: $white, + border_color: $muted, + border_color_hover: $white, + border_color_active: $white, + ) +); +$side_nav_border_line_width: 1px; +$side_nav_corner_radius: 6px; + +// ------------------------------------------ tables ------------------------------------------ // + +$table_default_variables: ( + delete_icon_color: $muted, + delete_icon_color_hover: $baca2_pink, + selected_row_bg_color: rgba(0, 0, 0, 0.08), + row_bg_color_hover: rgba(0, 0, 0, 0.08), + util_header_bg_color: $light, + resize_handle_color: $light, + resize_handle_color_hover: rgba(0, 0, 0, 0.03), +); +$table_theme_variables: ( + light: (), + dark: ( + util_header_bg_color: rgba(0, 0, 0, 0.3), + selected_row_bg_color: rgba(255, 255, 255, 0.03), + row_bg_color_hover: rgba(255, 255, 255, 0.03), + resize_handle_color: rgba(0, 0, 0, 0.3), + resize_handle_color_hover: rgba(0, 0, 0, 0.4), + ) +); + +// ------------------------------------------ popups ------------------------------------------ // + +$submit_success_color: $success; +$submit_failure_color: $danger; + +// -------------------------------------- datetimepicker -------------------------------------- // + +$datetimepicker_default_variables: ( + body_bg_color: $light, + body_text_color: $dark, + icon_color: 'black', + datetime_bg_color: $white, + datetime_text_color: $dark, + current_datetime_border_color: $baca2_pink, + current_datetime_text_color: $dark, + datetime_hover_bg_color: $baca2_pink, + datetime_hover_text_color: $light, + other_month_bg_color: $light, + other_month_text_color: $dark, + other_month_hover_bg_color: $dark, + other_month_hover_text_color: $light, +); +$datetimepicker_theme_variables: ( + light: (), + dark: ( + body_bg_color: $black, + body_text_color: $light, + icon_color: 'white', + datetime_bg_color: $pale_muted, + datetime_text_color: $dark, + current_datetime_border_color: $baca2_pink, + current_datetime_text_color: $dark, + datetime_hover_bg_color: $baca2_pink, + datetime_hover_text_color: $light, + other_month_bg_color: $light, + other_month_text_color: $dark, + other_month_hover_bg_color: $dark, + other_month_hover_text_color: $light, + ) +); + +// ---------------------------------------- codeblock ---------------------------------------- // + +$codeblock_default_variables: ( + bg_color: rgb(245, 242, 240), +); + +$codeblock_theme_variables: ( + light: (), + dark: ( + bg_color: $darker, + ), +) diff --git a/BaCa2/assets/scss/_buttons.scss b/BaCa2/assets/scss/_buttons.scss new file mode 100644 index 00000000..3c6ea007 --- /dev/null +++ b/BaCa2/assets/scss/_buttons.scss @@ -0,0 +1,21 @@ +@import "baca2_variables"; + +.btn-outline-secondary:hover { + background-color: rgba($baca2_beige, 0.3); + color: white; +} + +.btn-outline-success:hover { + background-color: rgba($baca2_blue, 0.3); + color: white; +} + +.btn-outline-danger:hover { + background-color: rgba($baca2_pink, 0.3); + color: white; +} + +.btn-outline-light_muted:hover { + background-color: rgba($light_muted, 0.3); + color: white; +} diff --git a/BaCa2/assets/scss/_cards.scss b/BaCa2/assets/scss/_cards.scss new file mode 100644 index 00000000..9a5b4dbe --- /dev/null +++ b/BaCa2/assets/scss/_cards.scss @@ -0,0 +1,9 @@ +@import "baca2_variables"; + +.card { + @each $size in map-keys($card_sizes) { + &.card-#{$size} { + width: map-get($card_sizes, $size); + } + } +} diff --git a/BaCa2/assets/scss/_code_block.scss b/BaCa2/assets/scss/_code_block.scss new file mode 100644 index 00000000..1a2f50df --- /dev/null +++ b/BaCa2/assets/scss/_code_block.scss @@ -0,0 +1,41 @@ +@import "baca2_variables"; +@import "functions"; + +pre.code-block { + &.wrap-lines { + overflow: hidden; + + code { + white-space: pre-wrap; + } + } +} + +@each $theme, $map in $codeblock_theme_variables { + $bg_color: safe_get($map, $codeblock_default_variables, bg_color); + + [data-bs-theme="#{$theme}"] { + pre.code-block { + background: $bg_color !important; + + .token.operator { + background: $bg_color !important; + } + } + + .test-summary .compile-tabs-nav .nav-link.active { + background-color: $bg_color; + } + } +} + +.test-summary { + .compile-tabs .code-block { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + + .compile-tabs-nav { + overflow: hidden; + } +} diff --git a/BaCa2/assets/scss/_datetime_picker.scss b/BaCa2/assets/scss/_datetime_picker.scss new file mode 100644 index 00000000..e2a77a9d --- /dev/null +++ b/BaCa2/assets/scss/_datetime_picker.scss @@ -0,0 +1,191 @@ +@import "baca2_variables"; +@import "functions"; + +.xdsoft_datetimepicker { + border-radius : 5px; + + .xdsoft_select { + border : none !important; + + .xdsoft_current { + box-shadow : none !important; + } + } + + .xdsoft_calendar { + th, td { + box-shadow : none !important; + border : none !important; + box-sizing : border-box !important; + } + + .xdsoft_current { + box-shadow : none !important; + } + } + + .xdsoft_time_box { + border : none !important; + + .xdsoft_time { + border: none !important; + } + + .xdsoft_current { + box-shadow : none !important; + } + } +} + +@each $theme, $map in $datetimepicker_theme_variables { + $icon_color : safe_get( + $map, + $datetimepicker_default_variables, + icon_color + ); + $body_bg_color : safe_get( + $map, + $datetimepicker_default_variables, + body_bg_color + ); + $body_text_color : safe_get( + $map, + $datetimepicker_default_variables, + body_text_color + ); + + $datetime_bg_color : safe_get( + $map, + $datetimepicker_default_variables, + datetime_bg_color + ); + $datetime_text_color : safe_get( + $map, + $datetimepicker_default_variables, + datetime_text_color + ); + $current_datetime_border_color : safe_get( + $map, + $datetimepicker_default_variables, + current_datetime_border_color + ); + $current_datetime_text_color : safe_get( + $map, + $datetimepicker_default_variables, + current_datetime_text_color + ); + $datetime_hover_bg_color : safe_get( + $map, + $datetimepicker_default_variables, + datetime_hover_bg_color + ); + $datetime_hover_text_color : safe_get( + $map, + $datetimepicker_default_variables, + datetime_hover_text_color + ); + + $other_month_bg_color : safe_get( + $map, + $datetimepicker_default_variables, + other_month_bg_color + ); + $other_month_text_color : safe_get( + $map, + $datetimepicker_default_variables, + other_month_text_color + ); + $other_month_hover_bg_color : safe_get( + $map, + $datetimepicker_default_variables, + other_month_hover_bg_color + ); + $other_month_hover_text_color : safe_get( + $map, + $datetimepicker_default_variables, + other_month_hover_text_color + ); + + [data-bs-theme="#{$theme}"] .xdsoft_datetimepicker { + background : $body_bg_color !important; + + .xdsoft_label { + background-color : $body_bg_color !important; + } + + .xdsoft_label i, .xdsoft_prev, .xdsoft_next, .xdsoft_today_button { + background-image : unquote( + "url('../img/datetime_picker_icons_#{$icon_color}.png')" + ) !important; + } + + .xdsoft_label { + color : $body_text_color !important; + } + + .xdsoft_select { + .xdsoft_option { + background : $body_bg_color !important; + + &:hover { + background : $datetime_hover_bg_color !important; + color : $datetime_hover_text_color !important; + } + } + + .xdsoft_current { + border : 2px solid $current_datetime_border_color !important; + color : $body_text_color !important; + } + } + + .xdsoft_calendar { + th { + background : $body_bg_color !important; + color : $body_text_color !important; + } + + td:not(.xdsoft_other_month) { + background : $datetime_bg_color !important; + color : $datetime_text_color !important; + } + + td:not(.xdsoft_other_month):hover { + background : $datetime_hover_bg_color !important; + color : $datetime_hover_text_color !important; + } + + .xdsoft_current { + border : 2px solid $current_datetime_border_color !important; + color : $current_datetime_text_color !important; + } + + .xdsoft_other_month { + background : $other_month_bg_color !important; + color : $other_month_text_color !important; + + &:hover { + background : $other_month_hover_bg_color !important; + color : $other_month_hover_text_color !important; + } + } + } + + .xdsoft_time_box { + .xdsoft_time { + background : $datetime_bg_color !important; + color : $datetime_text_color !important; + + &:hover { + background : $datetime_hover_bg_color !important; + color : $datetime_hover_text_color !important; + } + } + + .xdsoft_current { + border : 2px solid $current_datetime_border_color !important; + color : $current_datetime_text_color !important; + } + } + } +} diff --git a/BaCa2/assets/scss/_forms.scss b/BaCa2/assets/scss/_forms.scss new file mode 100644 index 00000000..b91d48cc --- /dev/null +++ b/BaCa2/assets/scss/_forms.scss @@ -0,0 +1,62 @@ +@import "baca2_variables"; + +.toggleable { + button { + width:35%; + font-size: 0.85rem; + } +} + +.invalid-feedback { + display: block; +} + +.table-select-field { + &.is-valid .table-wrapper .card { + --bs-card-border-color: var(--bs-form-valid-border-color); + } + + &.is-invalid .table-wrapper .card { + --bs-card-border-color: var(--bs-form-invalid-border-color); + } +} + +.table-select-input { + display: none; +} + +.form-confirmation-popup { + .modal-header, .modal-body, .modal-footer { + padding-left: 1.5rem; + padding-right: 1.5rem; + } + + .modal-footer { + .row { + margin: 0; + } + + .submit-btn-wrapper { + padding-left: 0; + } + + .cancel-btn-wrapper { + padding-right: 0; + } + } +} + +.submit-btn { + transition: background-color 0.3s ease-out, transform 0.3s ease-out; + + &.submit-enabled { + background-color: mix($primary, white, 80%); + transform: scale(1.01, 1.05); + } +} + +.form-observer { + .group-summary:not(.has-fields) { + display: none; + } +} diff --git a/BaCa2/assets/scss/_functions.scss b/BaCa2/assets/scss/_functions.scss new file mode 100644 index 00000000..713406df --- /dev/null +++ b/BaCa2/assets/scss/_functions.scss @@ -0,0 +1,7 @@ +@function safe_get($map, $default_map, $key) { + @if map-has-key($map, $key) { + @return map-get($map, $key); + } @else { + @return map-get($default_map, $key); + } +} diff --git a/BaCa2/assets/scss/_icons.scss b/BaCa2/assets/scss/_icons.scss new file mode 100644 index 00000000..18f72133 --- /dev/null +++ b/BaCa2/assets/scss/_icons.scss @@ -0,0 +1,31 @@ +@import "baca2_variables"; + +.icon-wrapper { + @each $size in map-keys($icon_sizes) { + &.icon-#{$size} { + svg { + width: auto; + height: map-get($icon_sizes, $size); + } + } + } + + @each $color in map-keys($theme-colors) { + &.icon-fill-#{$color} { + svg { + fill: map-get($theme-colors, $color); + } + } + } + + @each $color in map-keys($theme-colors) { + &.icon-stroke-#{$color} { + svg { + stroke: map-get($theme-colors, $color); + } + } + } + + width: fit-content; + height: fit-content; +} diff --git a/BaCa2/assets/scss/_navbar.scss b/BaCa2/assets/scss/_navbar.scss new file mode 100644 index 00000000..8640c924 --- /dev/null +++ b/BaCa2/assets/scss/_navbar.scss @@ -0,0 +1,89 @@ +@import "baca2_variables"; +@import "functions"; + +.nav-item .dropdown-toggle::after { + display: none; +} + +.nav-link { + position: relative; +} + +.navbar-padding { + padding-top: $navbar_padding; +} + +@each $theme, $map in $navbar_theme_variables { + [data-bs-theme="#{$theme}"] .navbar { + background-color: safe_get($map, $navbar_default_variables, bg); + + .links { + .nav-item a { + color: safe_get($map, $navbar_default_variables, link_color); + } + + .nav-item.active a { + color: safe_get($map, $navbar_default_variables, active_link_color); + } + + .nav-divider { + width: 1px; + background-color: safe_get($map, $navbar_default_variables, divider_color); + } + } + + .icon-wrapper svg { + stroke: safe_get($map, $navbar_default_variables, icon_color); + } + + .hover-underline::after { + content: ''; + height: 2px; + width: 100%; + background-color: safe_get($map, $navbar_default_variables, hover_color); + position: absolute; + bottom: 0; + left: 0; + opacity: 0; + transition: opacity 0.25s ease-in-out; + } + + .hover-underline:hover::after { + opacity: 1; + } + + .hover-highlight { + transition: color 0.25s ease-in-out; + + svg { + transition: stroke 0.25s ease-in-out; + } + } + + .hover-highlight:hover { + color: safe_get($map, $navbar_default_variables, hover_color); + + svg { + stroke: safe_get($map, $navbar_default_variables, hover_color); + } + } + + .navbar-brand:hover { + .logo-wrapper { + .logo-stroke { + fill: safe_get($map, $navbar_default_variables, logo_hover_color); + } + + #triangle { + path { + fill: safe_get($map, $navbar_default_variables, logo_hover_color); + } + } + + path { + transition: fill 0.5s; + } + } + } + } +} diff --git a/BaCa2/assets/scss/_pdf_displayer.scss b/BaCa2/assets/scss/_pdf_displayer.scss new file mode 100644 index 00000000..7b7e5874 --- /dev/null +++ b/BaCa2/assets/scss/_pdf_displayer.scss @@ -0,0 +1,15 @@ +.pdf-displayer { + .pdf-page-info { + display: flex; + line-height: 1.8rem; + } + + .pdf-page-change { + display: flex; + } + + .pdf-page-number { + flex: 1; + width: 2.5rem; + } +} \ No newline at end of file diff --git a/BaCa2/assets/scss/_popups.scss b/BaCa2/assets/scss/_popups.scss new file mode 100644 index 00000000..e89c240b --- /dev/null +++ b/BaCa2/assets/scss/_popups.scss @@ -0,0 +1,104 @@ +@import "baca2_variables"; + +// --------------------------------- submit confirmation popup -------------------------------- // + +.form-confirmation-popup { + p.popup-message { + margin-bottom: 0; + } + + .popup-summary { + margin-top: 1rem; + } +} + +// ------------------------------ submit success & failure popups ----------------------------- // + +.form-success-popup .path { + stroke: $submit_success_color; +} + +.form-failure-popup .path { + stroke: $submit_failure_color; +} + +.form-success-popup, .form-failure-popup { + .modal-content { + svg { + width: 100px; + } + + .path { + stroke-dasharray: 1000; + stroke-dashoffset: 0; + + &.circle { + -webkit-animation: dash 0.9s ease-in-out; + animation: dash 0.9s ease-in-out; + } + + &.line { + stroke-dashoffset: 1000; + -webkit-animation: dash 0.95s 0.35s ease-in-out forwards; + animation: dash 0.95s 0.35s ease-in-out forwards; + } + + &.check { + stroke-dashoffset: -100; + -webkit-animation: dash-check 0.95s 0.35s ease-in-out forwards; + animation: dash-check 0.95s 0.35s ease-in-out forwards; + } + } + } +} + +// ------------------------------- success & failure animations ------------------------------- // + +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100%{ + stroke-dashoffset: 0; + } +} +@-webkit-keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@keyframes dash { + 0% { + stroke-dashoffset: 1000; + } + 100% { + stroke-dashoffset: 0; + } +} +@-webkit-keyframes dash-check { + 0% { + stroke-dashoffset: -100; + } + 100% { + stroke-dashoffset: 900; + } +} +@keyframes dash-check { + 0% { + stroke-dashoffset: -100; + } + 100% { + stroke-dashoffset: 900; + } +} diff --git a/BaCa2/assets/scss/_scrollbar.scss b/BaCa2/assets/scss/_scrollbar.scss new file mode 100644 index 00000000..12bbe2b8 --- /dev/null +++ b/BaCa2/assets/scss/_scrollbar.scss @@ -0,0 +1,24 @@ +@import "baca2_variables"; +@import "functions"; + +::-webkit-scrollbar { + width: $scrollbar_width; +} + +::-webkit-scrollbar-thumb { + border-radius: $scrollbar_width; + border: 6px solid transparent; + background-clip: content-box; +} + +@each $theme, $map in $scrollbar_theme_variables { + [data-bs-theme="#{$theme}"] { + ::-webkit-scrollbar-thumb { + background-color: safe_get($map, $scrollbar_default_variables, color); + + &:hover { + background-color: safe_get($map, $scrollbar_default_variables, color_hover); + } + } + } +} diff --git a/BaCa2/assets/scss/_separator.scss b/BaCa2/assets/scss/_separator.scss new file mode 100644 index 00000000..66426714 --- /dev/null +++ b/BaCa2/assets/scss/_separator.scss @@ -0,0 +1,21 @@ +.separator { + display: flex; + align-items: center; + text-align: center; +} + +.separator::before, +.separator::after { + content: ''; + flex: 1; + border-bottom: 1px solid; + border-bottom-color: lightgray; +} + +.separator:not(:empty)::before { + margin-right: .25em; +} + +.separator:not(:empty)::after { + margin-left: .25em; +} diff --git a/BaCa2/assets/scss/_side_nav.scss b/BaCa2/assets/scss/_side_nav.scss new file mode 100644 index 00000000..b901f472 --- /dev/null +++ b/BaCa2/assets/scss/_side_nav.scss @@ -0,0 +1,295 @@ +@import "baca2_variables"; +@import "functions"; + +// ---------------------------------------- display ------------------------------------------- // + +.side-nav { + @media (min-width: 768px) { + &.sticky-side-nav { + position: sticky; + top: $navbar_padding; + } + } + + .side-nav-content { + list-style: none; + display: flex; + flex-direction: column-reverse; + } + + .tab-button { + display: inline-block; + width: 100%; + box-sizing: border-box; + + .side-nav-link { + text-decoration: none; + display: block; + } + + .sub-tabs-content { + list-style: none; + overflow: hidden; + + .sub-tab-button { + display: inline-block; + width: 100%; + box-sizing: border-box; + } + } + } +} + +.tab-wrapper { + display: none; + + .side-nav-tab { + overflow: hidden; + } +} + +// ------------------------------------ padding & margin -------------------------------------- // + +.side-nav { + padding: 0; + + .side-nav-content { + padding: 0.5rem 0 0; + margin: 0; + + .tab-button { + margin: 0; + padding: 0; + + .side-nav-link { + padding: 1rem; + } + + .sub-tabs-content { + padding: 0; + margin: 0; + + .sub-tab-button { + margin: 0; + padding: 0; + + .side-nav-link { + padding: 0.7rem 1rem 0.7rem 2rem; + } + } + } + } + } +} + +.tab-wrapper .side-nav-tab { + padding: 0.5rem; +} + +// ----------------------------------------- dropdown ----------------------------------------- // + +.side-nav { + .tab-button { + .sub-tabs-wrapper { + display: grid; + grid-template-rows: 0fr; + transition: grid-template-rows 0.5s ease-out; + } + + &.expanded, &:hover { + .sub-tabs-wrapper { + grid-template-rows: 1fr; + } + } + } + + &.expanded { + .sub-tabs-wrapper { + grid-template-rows: 1fr; + } + } +} + +// ------------------------------------------ shape ------------------------------------------- // + +.side-nav { + border-radius: $side_nav_corner_radius; + height: 100%; + + .tab-button { + &:last-child { + border-radius: $side_nav_corner_radius $side_nav_corner_radius 0 0; + } + + &:first-child { + border-radius: 0 0 $side_nav_corner_radius $side_nav_corner_radius; + + .sub-tab-button:last-child { + border-radius: 0 0 $side_nav_corner_radius $side_nav_corner_radius; + } + } + } +} + +// ---------------------------------------- borders ------------------------------------------- // + +@each $theme, $map in $side_nav_theme_variables { + [data-bs-theme="#{$theme}"] .side-nav .tab-button { + // Tab - default border + border-bottom: safe_get($map, $side_nav_default_variables, border_color) + $side_nav_border_line_width solid; + transition: border-bottom-color ease 0.5s, + border-top-color ease 0.5s; + + &:first-child { + border-bottom: none; + } + + // Tab - hover border + &:hover { + border-bottom-color: safe_get( + $map, + $side_nav_default_variables, + border_color_hover + ); + + &:not(:last-child) + .tab-button { + border-bottom-color: safe_get( + $map, + $side_nav_default_variables, + border_color_hover + ); + } + } + + // Tab - active border + &.active, &.expanded { + border-bottom-color: safe_get( + $map, + $side_nav_default_variables, + border_color_active + ); + + &:not(:last-child) + .tab-button { + border-bottom-color: safe_get( + $map, + $side_nav_default_variables, + border_color_active + ); + } + } + + // Sub tab - border + .sub-tab-button:first-child { + border-top: safe_get($map, $side_nav_default_variables, border_color) + 1px solid; + } + } +} + +// --------------------------------------- link color ----------------------------------------- // + +@each $theme, $map in $side_nav_theme_variables { + [data-bs-theme="#{$theme}"] .side-nav .tab-button { + // Tab - default link color + .side-nav-link { + color: safe_get($map, $side_nav_default_variables, link_color); + transition: color ease 0.3s; + } + + // Tab - hover link color + &:hover .side-nav-link { + color: safe_get($map, $side_nav_default_variables, link_color_hover); + } + + // Tab - active link color + &.active, &.expanded { + .side-nav-link { + color: safe_get($map, $side_nav_default_variables, link_color_active); + } + + // Tab - active/expanded hover link color + &:hover .side-nav-link { + color: safe_get($map, $side_nav_default_variables, link_color_active_hover); + } + } + } +} + +// ---------------------------------------- bg color ------------------------------------------ // + +@each $theme, $map in $side_nav_theme_variables { + [data-bs-theme="#{$theme}"] .side-nav .tab-button { + // Tab - default bg color + background-color: safe_get($map, $side_nav_default_variables, tab_bg_color); + transition: background-color ease 0.4s; + + // Tab - hover bg color + &:hover { + background-color: safe_get($map, $side_nav_default_variables, tab_bg_color_hover) + } + + // Tab - expanded bg color + &.expanded { + background-color: safe_get($map, $side_nav_default_variables, tab_bg_color_expanded); + } + + // Tab - active bg color + &.active { + background-color: safe_get($map, $side_nav_default_variables, tab_bg_color_active); + } + + // Tab - active hover bg color + &.active:hover { + background-color: safe_get( + $map, + $side_nav_default_variables, + tab_bg_color_active_hover + ); + } + + // Tab - expanded hover bg color + &.expanded:hover { + background-color: safe_get( + $map, + $side_nav_default_variables, + tab_bg_color_expanded_hover + ); + } + + // Sub tab - expanded bg color + &.expanded, &:hover { + .sub-tab-button { + background-color: safe_get( + $map, + $side_nav_default_variables, + sub_tab_bg_color_expanded + ); + } + } + + // Sub tab - default bg color + .sub-tab-button { + background-color: safe_get($map, $side_nav_default_variables, sub_tab_bg_color); + transition: background-color ease 0.3s; + + // Sub tab - hover bg color + &:hover { + background-color: safe_get( + $map, + $side_nav_default_variables, + sub_tab_bg_color_hover + ); + } + + // Sub tab - active bg color + &.active { + background-color: safe_get( + $map, + $side_nav_default_variables, + sub_tab_bg_color_active + ); + } + } + } +} diff --git a/BaCa2/assets/scss/_table.scss b/BaCa2/assets/scss/_table.scss new file mode 100644 index 00000000..cc7573ce --- /dev/null +++ b/BaCa2/assets/scss/_table.scss @@ -0,0 +1,122 @@ +@import "baca2_variables"; +@import "functions"; + +.link-records tbody tr[data-record-link] { + cursor: pointer; +} + +.no-header { + thead { + display: none; + } + + &.no-footer { + border-bottom: 0 !important; + } +} + +.table-wrapper { + .table-widget-forms-wrapper form { + display: none; + } + + .column-form-wrapper form { + display: none; + } + + td.form-submit { + padding-left: 0.25rem; + padding-right: 0.25rem; + + .btn { + padding: 0.25rem 0.5rem; + white-space: nowrap; + } + } + + th .icon-header { + height: 1.5rem; + width: auto; + } + + .table-buttons { + --bs-gutter-x: 0.5rem; + } + + .card { + overflow: hidden; + } + + .table-responsive[data-paging="false"] { + .dataTables_info { + display: none; + } + } + + .resize-wrapper { + position: relative; + + .table-responsive, .dataTables_wrapper, .dataTables_scroll { + height: 100%; + } + } + + .resize-handle { + cursor: pointer; + } + + .dataTables_length select { + padding: 0.375rem 2.25rem 0.375rem 0.75rem !important; + color: var(--bs-body-color) !important; + border: var(--bs-border-width) solid var(--bs-border-color) !important; + border-radius: var(--bs-border-radius) !important; + background-color: var(--bs-body-bg) !important; + } +} + +@each $theme, $map in $table_theme_variables { + $util_header_bg_color: safe_get($map, $table_default_variables, util_header_bg_color); + $delete_icon_color: safe_get($map, $table_default_variables, delete_icon_color); + $delete_icon_color_hover: safe_get($map, $table_default_variables, delete_icon_color_hover); + $selected_row_bg_color: safe_get($map, $table_default_variables, selected_row_bg_color); + $row_bg_color_hover: safe_get($map, $table_default_variables, row_bg_color_hover); + $resize_handle_color: safe_get($map, $table_default_variables, resize_handle_color); + $resize_handle_color_hover: safe_get($map, $table_default_variables, resize_handle_color_hover); + $length_menu_bg_color_hover: safe_get( + $map, + $table_default_variables, + length_menu_bg_color_hover + ); + + [data-bs-theme="#{$theme}"] .table-wrapper { + .table-util-header { + background-color: $util_header_bg_color; + } + + td.delete { + i { + color: $delete_icon_color; + } + + &:hover i { + color: $delete_icon_color_hover; + } + } + + .row-selected td { + background-color: $selected_row_bg_color; + } + + .row-hover tr[data-record-link]:hover td { + background-color: $row_bg_color_hover; + } + + .resize-handle { + background-color: $resize_handle_color; + + &:hover { + background-color: $resize_handle_color_hover; + } + } + } +} diff --git a/BaCa2/assets/scss/_theme_button.scss b/BaCa2/assets/scss/_theme_button.scss new file mode 100644 index 00000000..aa95ae26 --- /dev/null +++ b/BaCa2/assets/scss/_theme_button.scss @@ -0,0 +1,17 @@ +[data-bs-theme="dark"] .theme-button { + #moon svg { + display: none; + } + #sun svg { + display: block; + } +} + +[data-bs-theme="light"] .theme-button { + #moon svg { + display: block; + } + #sun svg { + display: none; + } +} diff --git a/BaCa2/assets/scss/_version_footer.scss b/BaCa2/assets/scss/_version_footer.scss new file mode 100644 index 00000000..b87cc7c6 --- /dev/null +++ b/BaCa2/assets/scss/_version_footer.scss @@ -0,0 +1,10 @@ +@import "baca2_variables"; + +#version_footer { + position: fixed; + bottom: 10px; + left: 10px; + color: $baca2_beige; + text-align: left; + font-size: 0.8em; +} diff --git a/BaCa2/assets/scss/baca2_theme.scss b/BaCa2/assets/scss/baca2_theme.scss new file mode 100644 index 00000000..4cdf3156 --- /dev/null +++ b/BaCa2/assets/scss/baca2_theme.scss @@ -0,0 +1,4 @@ +// custom variables and overrides +@import "baca2_variables"; +// bootstrap import +@import '../../../node_modules/bootstrap/scss/bootstrap'; diff --git a/BaCa2/assets/scss/base.scss b/BaCa2/assets/scss/base.scss new file mode 100644 index 00000000..5c4be3a8 --- /dev/null +++ b/BaCa2/assets/scss/base.scss @@ -0,0 +1,21 @@ +html, body { + height: 100%; +} + +@import "version_footer"; +@import "forms"; +@import "baca2_logo"; +@import "navbar"; +@import "cards"; +@import "icons"; +@import "theme_button"; +@import "separator"; +@import "side_nav"; +@import "table"; +@import "buttons"; +@import "popups"; +@import "pdf_displayer"; +@import "datetime_picker"; +@import "scrollbar"; +@import "code_block"; +@import "../../../node_modules/bootstrap-icons/font/bootstrap-icons.scss"; diff --git a/BaCa2/broker_api/__init__.py b/BaCa2/broker_api/__init__.py new file mode 100644 index 00000000..bfd6705a --- /dev/null +++ b/BaCa2/broker_api/__init__.py @@ -0,0 +1,7 @@ +from django.conf import settings + +from broker_api.scheduler import scheduler + +# Start the scheduler +if settings.BROKER_RETRY_POLICY.auto_start: + scheduler.start() diff --git a/BaCa2/broker_api/admin.py b/BaCa2/broker_api/admin.py new file mode 100644 index 00000000..846f6b40 --- /dev/null +++ b/BaCa2/broker_api/admin.py @@ -0,0 +1 @@ +# Register your models here. diff --git a/BaCa2/broker_api/apps.py b/BaCa2/broker_api/apps.py new file mode 100644 index 00000000..26280399 --- /dev/null +++ b/BaCa2/broker_api/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class BrokerApiConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'broker_api' + + def ready(self): + super().ready() + # call_command('markExpired') + # call_command('resendToBroker') diff --git a/BaCa2/BaCa2/apps_configurations/__init__.py b/BaCa2/broker_api/management/__init__.py similarity index 100% rename from BaCa2/BaCa2/apps_configurations/__init__.py rename to BaCa2/broker_api/management/__init__.py diff --git a/BaCa2/BaCa2/db/__init__.py b/BaCa2/broker_api/management/commands/__init__.py similarity index 100% rename from BaCa2/BaCa2/db/__init__.py rename to BaCa2/broker_api/management/commands/__init__.py diff --git a/BaCa2/broker_api/management/commands/deleteErrors.py b/BaCa2/broker_api/management/commands/deleteErrors.py new file mode 100644 index 00000000..71c5316f --- /dev/null +++ b/BaCa2/broker_api/management/commands/deleteErrors.py @@ -0,0 +1,29 @@ +import logging +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from broker_api.models import BrokerSubmit + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Deletes old error submits' # noqa: A003 + + deletion_timeout: float = settings.BROKER_RETRY_POLICY.deletion_timeout + + def handle(self, *args, **options): + submits_to_delete = BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.ERROR, + update_date__lte=timezone.now() - timedelta(seconds=self.deletion_timeout) + ) + delete_amount = len(submits_to_delete) + submits_to_delete.delete() + + if delete_amount: + logger.info(f'Deleted {delete_amount} submits.') + else: + logger.debug('No submits to delete.') diff --git a/BaCa2/broker_api/management/commands/markExpired.py b/BaCa2/broker_api/management/commands/markExpired.py new file mode 100644 index 00000000..3b11cdc0 --- /dev/null +++ b/BaCa2/broker_api/management/commands/markExpired.py @@ -0,0 +1,30 @@ +import logging +from datetime import timedelta + +from django.conf import settings +from django.core.management.base import BaseCommand +from django.utils import timezone + +from broker_api.models import BrokerSubmit + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Marks expired submits, and resends them as new' # noqa: A003 + + retry_timeout: float = settings.BROKER_RETRY_POLICY.expiration_timeout + + def handle(self, *args, **options): + data = BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + update_date__lte=timezone.now() - timedelta(seconds=self.retry_timeout) + ) + for submit in data: + if submit.update_date <= timezone.now() - timedelta(seconds=self.retry_timeout): + submit.update_status(BrokerSubmit.StatusEnum.EXPIRED) + + if data: + logger.info(f'Marked {len(data)} submits as expired.') + else: + logger.debug('No submits to mark as expired.') diff --git a/BaCa2/broker_api/management/commands/resendToBroker.py b/BaCa2/broker_api/management/commands/resendToBroker.py new file mode 100644 index 00000000..cfeb24a3 --- /dev/null +++ b/BaCa2/broker_api/management/commands/resendToBroker.py @@ -0,0 +1,36 @@ +import logging + +from django.conf import settings +from django.core.management.base import BaseCommand + +from broker_api.models import BrokerSubmit +from core.choices import ResultStatus + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = 'Resends expired submits' # noqa: A003 + + # maximum number of retries + retry_limit: int = settings.BROKER_RETRY_POLICY.resend_max_retries + + def handle(self, *args, **options): + data = BrokerSubmit.objects.filter(status=BrokerSubmit.StatusEnum.EXPIRED) + submits_resent = 0 + submits_exceeded = 0 + for submit in data: + if submit.retry_amount < self.retry_limit: + if submit.status == BrokerSubmit.StatusEnum.EXPIRED: + submit.resend() + submits_resent += 1 + else: + submit.submit.end_with_error(ResultStatus.INT, 'Retry limit exceeded') + submit.update_status(BrokerSubmit.StatusEnum.ERROR) + submits_exceeded += 1 + + if submits_resent > 0: + logger.info(f'Resent {submits_resent} submits, ' + f'{submits_exceeded} exceeded retry limit.') + else: + logger.debug('No submits to resend.') diff --git a/BaCa2/BaCa2/db/ext_databases.py b/BaCa2/broker_api/migrations/__init__.py similarity index 100% rename from BaCa2/BaCa2/db/ext_databases.py rename to BaCa2/broker_api/migrations/__init__.py diff --git a/BaCa2/broker_api/mock.py b/BaCa2/broker_api/mock.py new file mode 100644 index 00000000..d395af82 --- /dev/null +++ b/BaCa2/broker_api/mock.py @@ -0,0 +1,141 @@ +from pathlib import Path +from random import choice, randint, uniform +from typing import Iterable, Tuple + +from django.conf import settings + +from core.choices import EMPTY_FINAL_STATUSES, HALF_EMPTY_FINAL_STATUSES, ResultStatus +from course.models import Result, Submit, Test +from main.models import Course +from util.models_registry import ModelsRegistry + + +class BrokerMock: + MOCK_SRC = settings.BASE_DIR / 'broker_api' / 'mock_src' + COMPILE_LOGS = MOCK_SRC / 'compile_logs' + CHECKER_LOGS = MOCK_SRC / 'checker_logs' + + def __init__(self, + course: int | Course, + submit: int | Submit, + generate_results: bool = True, + add_logs: bool = True, + add_answers: bool = True, + available_statuses: Iterable[ResultStatus] = None, + range_time_real: Tuple[float, float] = (0, 5), + time_cpu_mod_range: Tuple[float, float] = (0.5, 0.9), + runtime_memory_range: Tuple[int, int] = (2000, 1e8), + **kwargs + ): + self.course = ModelsRegistry.get_course(course) + self.submit = course.get_submit(submit) + self.generate_results = generate_results + self.add_logs = add_logs + self.add_answers = add_answers + if not available_statuses: + available_statuses = [ResultStatus.ANS, ResultStatus.MEM, + ResultStatus.TLE, ResultStatus.OK] + self.available_statuses = available_statuses + self.range_time_real = range_time_real + self.time_cpu_mod_range = time_cpu_mod_range + self.runtime_memory_range = runtime_memory_range + + @classmethod + def get_random_log(cls, from_dir: Path) -> str: + logs = list(from_dir.iterdir()) + log_file = choice(logs) + with open(log_file, 'r', encoding='utf-8') as file: + return file.read() + + @property + def rand_time_real(self): + return uniform(*self.range_time_real) + + @property + def rand_time_cpu(self): + return self.rand_time_real * uniform(*self.time_cpu_mod_range) + + @property + def rand_runtime_memory(self): + return randint(*self.runtime_memory_range) + + def generate_fake_test_result(self, + test: Test, + status: ResultStatus, + ) -> Result: + if status in HALF_EMPTY_FINAL_STATUSES: + return Result.objects.create_result( + submit=self.submit, + test=test, + status=status, + time_real=-1, + time_cpu=-1, + runtime_memory=-1, + compile_log=self.get_random_log(self.COMPILE_LOGS) if self.add_logs else None, + ) + elif status == ResultStatus.ANS: + return Result.objects.create_result( + submit=self.submit, + test=test, + status=status, + time_real=self.rand_time_real, + time_cpu=self.rand_time_cpu, + runtime_memory=self.rand_runtime_memory, + answer='wrong answer\nwrong answer\nwrong answer\n' if self.add_answers else None, + ) + elif status in (ResultStatus.MEM, ResultStatus.TLE): + return Result.objects.create_result( + submit=self.submit, + test=test, + status=status, + time_real=self.rand_time_real, + time_cpu=self.rand_time_cpu, + runtime_memory=self.rand_runtime_memory, + compile_log=self.get_random_log(self.COMPILE_LOGS) if self.add_logs else None, + answer='rand answer\nrand answer\nrand answer\n' if self.add_answers else '' + ) + elif status == ResultStatus.OK: + return Result.objects.create_result( + submit=self.submit, + test=test, + status=status, + time_real=self.rand_time_real, + time_cpu=self.rand_time_cpu, + runtime_memory=self.rand_runtime_memory, + answer='ok answer\nok answer\nok answer\n' if self.add_answers else '', + checker_log=self.get_random_log(self.CHECKER_LOGS) if self.add_logs else None, + ) + else: + raise ValueError(f'Cant generate result from status: {status}') + + def generate_fake_results(self): + unused_results = [r for r in self.available_statuses] + for task_set in self.submit.task.sets: + for test in task_set.tests: + if unused_results: + result = self.generate_fake_test_result( + test, + choice(unused_results) + ) + unused_results.remove(ResultStatus[result.status]) + else: + self.generate_fake_test_result( + test, + choice(self.available_statuses) + ) + + def run(self) -> None: + submit_prestatus = choice(self.available_statuses) + if submit_prestatus in EMPTY_FINAL_STATUSES: + self.submit.end_with_error(submit_prestatus, 'mock error') + return + new_available_statuses = [] + for status in self.available_statuses: + if status not in EMPTY_FINAL_STATUSES: + new_available_statuses.append(status) + self.available_statuses = new_available_statuses + if not self.available_statuses: + self.submit.end_with_error(ResultStatus.INT, + 'no results available - cant generate results') + if self.generate_results: + self.generate_fake_results() diff --git a/BaCa2/broker_api/mock_src/checker_logs/1.txt b/BaCa2/broker_api/mock_src/checker_logs/1.txt new file mode 100644 index 00000000..9bc776f8 --- /dev/null +++ b/BaCa2/broker_api/mock_src/checker_logs/1.txt @@ -0,0 +1,2 @@ +delta=0.00000001 delta=0.00000031 +max_delta=0.00002221 diff --git a/BaCa2/broker_api/mock_src/compile_logs/1.txt b/BaCa2/broker_api/mock_src/compile_logs/1.txt new file mode 100644 index 00000000..23d56148 --- /dev/null +++ b/BaCa2/broker_api/mock_src/compile_logs/1.txt @@ -0,0 +1,12 @@ +/var/lib/kolejka/task/results/dosko2a/solution/src/9c7535ff-86a9-482a-9390-62e8fb3ed759.cpp:12:15: warning: multi-character character constant [-Wmultichar] + 12 | print('TAK') + | ^~~~~ +/var/lib/kolejka/task/results/dosko2a/solution/src/9c7535ff-86a9-482a-9390-62e8fb3ed759.cpp:14:15: warning: multi-character character constant [-Wmultichar] + 14 | print('NIE') + | ^~~~~ +/var/lib/kolejka/task/results/dosko2a/solution/src/9c7535ff-86a9-482a-9390-62e8fb3ed759.cpp:17:16: warning: character constant too long for its type + 17 | if __name__ == '__main__': + | ^~~~~~~~~~ +/var/lib/kolejka/task/results/dosko2a/solution/src/9c7535ff-86a9-482a-9390-62e8fb3ed759.cpp:2:1: error: ‘def’ does not name a type + 2 | def sumadziel(n): + | ^~~ diff --git a/BaCa2/broker_api/models.py b/BaCa2/broker_api/models.py new file mode 100644 index 00000000..010076e2 --- /dev/null +++ b/BaCa2/broker_api/models.py @@ -0,0 +1,269 @@ +import logging +from time import sleep + +from django.conf import settings +from django.db import models, transaction +from django.utils import timezone + +import baca2PackageManager.broker_communication as brcom +import requests +from core.choices import ResultStatus +from course.models import Result, Submit +from course.routing import InCourse +from main.models import Course +from package.models import PackageInstance +from util.models_registry import ModelsRegistry + +logger = logging.getLogger(__name__) + + +class BrokerSubmitManager(models.Manager): + ... + # TODO: move class methods from BrokerSubmit to BrokerSubmitManager + + +class BrokerSubmit(models.Model): + """Model for storing information about submits sent to broker.""" + + class StatusEnum(models.IntegerChoices): + """Enum for submit status.""" + ERROR = -2 # Error while sending or receiving submit + EXPIRED = -1 # Submit was not checked in time + NEW = 0 # Submit was created + PROCESSING = 1 # Submit is being processed + AWAITING_RESPONSE = 2 # Submit was sent to broker and is awaiting response + CHECKED = 3 # Submit was checked and results are being saved + SAVED = 4 # Results were saved + + #: course foreign key + course = models.ForeignKey(Course, on_delete=models.CASCADE) + #: package instance foreign key + package_instance = models.ForeignKey(PackageInstance, on_delete=models.CASCADE) + #: submit id + submit_id = models.BigIntegerField() + + #: status of this submit + status = models.IntegerField(StatusEnum, default=StatusEnum.NEW) + #: date of last status update + update_date = models.DateTimeField(default=timezone.now) + #: amount of times this submit was resent to broker + retry_amount = models.IntegerField(default=0) + + def __repr__(self): + return f'BrokerSubmit({self.broker_id})' + + @property + def broker_id(self): + """ + Returns broker_id of this submit. + + :return: broker_id of this submit + :rtype: str + """ + return brcom.create_broker_submit_id(self.course.short_name, int(self.submit_id)) + + @property + def submit(self) -> Submit: + """ + :return: Submit object from course database + :rtype: Submit + """ + return self.course.get_submit(self.submit_id) + + def hash_password(self, password: str) -> str: + """ + Hashes password with broker_id as salt. + + :param password: password to hash + :type password: str + + :return: hashed password + :rtype: str + """ + return brcom.make_hash(password, self.broker_id) + + def send_submit(self, url: str, password: str) -> tuple[brcom.BacaToBroker, int]: + """ + Sends submit to broker. + + :param url: url of broker + :type url: str + :param password: password for broker + :type password: str + + :return: tuple (message, status_code) where message is message sent to broker and + status_code is an HTTP status code or a negative number if an error occurred + :rtype: tuple[brcom.BacaToBroker, int] + """ + message = brcom.BacaToBroker( + pass_hash=self.hash_password(password), + submit_id=self.broker_id, + package_path=str(self.package_instance.package_source.path), + commit_id=self.package_instance.commit, + submit_path=self.solution + ) + try: + r = requests.post(url, headers={'content-type': 'application/json'}, + data=message.model_dump_json()) + except requests.exceptions.ConnectionError: + return message, -1 + except requests.exceptions.ChunkedEncodingError: + return message, -2 + else: + return message, r.status_code + + @classmethod + def send(cls, + course: Course, + submit_id: int, + package_instance: PackageInstance, + broker_url: str = settings.BROKER_URL, + broker_password: str = settings.BROKER_PASSWORD) -> 'BrokerSubmit': + """ + Creates new submit and sends it to broker. + + :param course: course of this submit + :type course: Course + :param submit_id: id of this submit + :type submit_id: int + :param package_instance: package instance of this submit + :type package_instance: PackageInstance + :param broker_url: url of broker + :type broker_url: str + :param broker_password: password for broker + :type broker_password: str + + :return: new submit + :rtype: BrokerSubmit + + :raises ConnectionError: if submit cannot be sent to broker + """ + if cls.objects.filter(course=course, submit_id=submit_id).exists(): + raise ValueError(f'Submit with id {submit_id} already exists.') + new_submit = cls.objects.create( + course=course, + submit_id=submit_id, + package_instance=package_instance + ) + new_submit.save() + new_submit.update_status(cls.StatusEnum.PROCESSING) + code = -100 + for _ in range(settings.BROKER_RETRY_POLICY.individual_max_retries): + _, code = cls.send_submit(new_submit, broker_url, broker_password) + if code == 200: + break + sleep(settings.BROKER_RETRY_POLICY.individual_submit_retry_interval) + else: + new_submit.update_status(cls.StatusEnum.ERROR) + new_submit.submit.end_with_error(ResultStatus.INT, + f'Cannot sent message to broker (error code: {code})') + raise ConnectionError(f'Cannot sent message to broker (error code: {code})') + new_submit.update_status(cls.StatusEnum.AWAITING_RESPONSE) + return new_submit + + def resend(self, + broker_url: str = settings.BROKER_URL, + broker_password: str = settings.BROKER_PASSWORD): + """ + Resends this submit to broker. + + :param broker_url: url of broker + :type broker_url: str + :param broker_password: password for broker + :type broker_password: str + """ + self.status = self.StatusEnum.PROCESSING + for _ in range(settings.BROKER_RETRY_POLICY.individual_max_retries): + _, code = self.send_submit(broker_url, broker_password) + if code == 200: + self.retry_amount += 1 + self.update_status(self.StatusEnum.AWAITING_RESPONSE) + break + sleep(settings.BROKER_RETRY_POLICY.individual_submit_retry_interval) + else: + self.submit.end_with_error(ResultStatus.INT, 'Cannot sent message to broker') + self.update_status(self.StatusEnum.ERROR) + + @classmethod + def authenticate(cls, response: brcom.BrokerToBaca) -> 'BrokerSubmit': + """ + Authenticates response from broker and returns the corresponding submit. + + :param response: response from broker + :type response: brcom.BrokerToBaca + + :return: submit corresponding to response + :rtype: BrokerSubmit + + :raises ValueError: if no submit with broker_id from response exists + :raises PermissionError: if password in response is wrong + """ + course_name, submit_id = brcom.split_broker_submit_id(response.submit_id) + broker_submit = cls.objects.filter(course__short_name=course_name, + submit_id=submit_id).first() + if broker_submit is None: + raise ValueError(f'No submit with broker_id {response.submit_id} exists.') + if response.pass_hash != broker_submit.hash_password(settings.BACA_PASSWORD): + raise PermissionError('Wrong password.') + return broker_submit + + @classmethod + def handle_result(cls, response: brcom.BrokerToBaca): + """ + Handles result from broker and saves it to database. + + :param response: response from broker + :type response: brcom.BrokerToBaca + """ + broker_submit = cls.authenticate(response) + course_name, submit_id = brcom.split_broker_submit_id(response.submit_id) + course = ModelsRegistry.get_course(course_name) + + logger.info(f'Handling result for submit {submit_id} in course {course_name}.') + broker_submit.update_status(cls.StatusEnum.CHECKED) + + print('unpack results') + with InCourse(course.short_name): + Result.objects.unpack_results(submit_id, response) + submit = Submit.objects.get(pk=submit_id) + submit.score() + print(submit) + print('done') + + broker_submit.update_status(cls.StatusEnum.SAVED) + + @classmethod + def handle_error(cls, response: brcom.BrokerToBacaError): + """ + Handles error from broker and sets status of corresponding submit to ERROR. + + :param response: response from broker + :type response: brcom.BrokerToBacaError + """ + broker_submit = cls.authenticate(response) + broker_submit.update_status(cls.StatusEnum.ERROR) + broker_submit.submit.end_with_error(ResultStatus.INT, + response.error_data['message'] + '\n' + + response.error_data['traceback']) + + @property + def solution(self): + """ + Returns source code of this submit. + + :return: source code of this submit + """ # TODO: specify return type + with InCourse(self.course.short_name): + return Submit.objects.get(id=self.submit_id).source_code + + @transaction.atomic + def update_status(self, new_status: StatusEnum): + """ + Updates status of this submit. + + :param new_status: new status of this submit + :type new_status: StatusEnum + """ + self.status = new_status + self.update_date = timezone.now() + self.save() diff --git a/BaCa2/broker_api/scheduler.py b/BaCa2/broker_api/scheduler.py new file mode 100644 index 00000000..3c304926 --- /dev/null +++ b/BaCa2/broker_api/scheduler.py @@ -0,0 +1,23 @@ +"""Contains the scheduler for the broker_api app.""" + +from django.conf import settings +from django.core.management import call_command + +from apscheduler.schedulers.background import BackgroundScheduler + +scheduler = BackgroundScheduler() + + +@scheduler.scheduled_job('interval', + minutes=settings.BROKER_RETRY_POLICY.retry_check_interval) +def check_model_updates(): + """Looks for submits that need to be resent to broker, marks them expired and resends them.""" + call_command('markExpired') + call_command('resendToBroker') + + +@scheduler.scheduled_job('interval', + minutes=settings.BROKER_RETRY_POLICY.deletion_check_interval) +def delete_old_errors(): + """Deletes errors that are older than BrokerRetryPolicy.error_deletion_time.""" + call_command('deleteErrors') diff --git a/BaCa2/broker_api/tests.py b/BaCa2/broker_api/tests.py new file mode 100644 index 00000000..2f826e64 --- /dev/null +++ b/BaCa2/broker_api/tests.py @@ -0,0 +1,406 @@ +import cgi +from asyncio import sleep +from datetime import datetime, timedelta +from http import server +from threading import Lock, Thread +from typing import Any + +from django.conf import settings +from django.core.management import call_command +from django.test import Client, TestCase + +from broker_api.views import * +from course.models import Result, Round, Submit, Task +from course.routing import InCourse +from main.models import Course, User +from package.models import PackageInstance + + +class DelayedAction(dict): + INSTANCE = None + + def __new__(cls, *args, **kwargs): + if cls.INSTANCE is None: + cls.INSTANCE = super().__new__(cls) + return cls.INSTANCE + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.locks: dict[Any, Lock] = {} + + def set_lock(self, key): + if key not in self.locks: + self.locks[key] = Lock() + self.locks[key].acquire() + + def release_lock(self, key): + if key not in self.locks: + self.set_lock(key) + if self.locks[key].locked(): + self.locks[key].release() + + def put_func(self, key, func, *args, **kwargs): + self[key] = (func, args, kwargs) + self.release_lock(key) + + def exec_func(self, key) -> Any: + func, args, kwargs = self[key] + del self[key] + return func(*args, **kwargs) + + def wait_for(self, key, timeout=5): + tmp = self.locks[key].acquire(timeout=timeout) + if not tmp: + return False + self.locks[key].release() + del self.locks[key] + return True + + def clear(self): + for key in self.locks: + if self.locks[key].locked(): + self.locks[key].release() + self.locks.clear() + super().clear() + + +def send(url, message): + cl = Client() + resp = cl.post(url, message, content_type='application/json') + return resp + + +class DummyBrokerHandler(server.BaseHTTPRequestHandler): + + def do_POST(self): + type_, pdict = cgi.parse_header(self.headers.get('content-type')) + + if type_ != 'application/json': + self.send_response(400) + self.end_headers() + return + + length = int(self.headers.get('content-length')) + message = json.loads(self.rfile.read(length)) + + try: + content = BacaToBroker.parse(message) + if content.pass_hash != make_hash(settings.BROKER_PASSWORD, content.submit_id): + out = BrokerToBacaError( + pass_hash=make_hash(settings.BACA_PASSWORD, content.submit_id), + submit_id=content.submit_id, + msg='Error' + ) + DelayedAction.INSTANCE.put_func(content.submit_id, send, + '/broker_api/error', json.dumps(out.serialize())) + else: + out = BrokerToBaca( + pass_hash=make_hash(settings.BACA_PASSWORD, content.submit_id), + submit_id=content.submit_id, + results={} + ) + DelayedAction.INSTANCE.put_func(content.submit_id, send, + '/broker_api/result', json.dumps(out.serialize())) + except TypeError: + self.send_response(400) + self.end_headers() + return + + self.send_response(200) + self.end_headers() + + +class General(TestCase): + instance = None + course = None + pkg_instance = None + task = None + user = None + + @classmethod + def setUpClass(cls) -> None: + DelayedAction() # Initialize singleton + + cls.course = Course.objects.create_course(name=f'course1_{datetime.now().timestamp()}') + + cls.pkg_instance = PackageInstance.objects.create_source_and_instance('dosko', '1') + cls.pkg_instance.save() + + cls.user = User.objects.create_user(password='user1', + email=f'test{datetime.now().timestamp()}@test.pl') + + with InCourse(cls.course.short_name): + round_ = Round.objects.create(start_date=datetime.now(), + deadline_date=datetime.now() + timedelta(days=1), + reveal_date=datetime.now() + timedelta(days=2)) + round_.save() + + cls.task = Task.objects.create_task( + task_name='Liczby doskonałe', + package_instance=cls.pkg_instance, + round_=round_, + points=10, + ) + cls.task.save() + + @classmethod + def tearDownClass(cls) -> None: + Course.objects.delete_course(cls.course) + + def setUp(self) -> None: + self.server = server.HTTPServer(('127.0.0.1', 8180), DummyBrokerHandler) + self.thread = Thread(target=self.server.serve_forever) + self.thread.start() + DelayedAction.INSTANCE.clear() + + def tearDown(self) -> None: + self.server.shutdown() + self.server.server_close() + self.thread.join() + + def test_broker_communication(self): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, task=self.task, + user=self.user, auto_send=False) + submit.pk = datetime.now().timestamp() + submit.save() + submit_id = submit.pk + DelayedAction.INSTANCE.set_lock( + create_broker_submit_id(self.course.short_name, int(submit_id))) + broker_submit = BrokerSubmit.send(self.course, + submit_id, + self.pkg_instance, + broker_password=settings.BROKER_PASSWORD) + + self.assertTrue(DelayedAction.INSTANCE.wait_for(broker_submit.broker_id, 2)) + self.assertEqual(200, + DelayedAction.INSTANCE.exec_func(broker_submit.broker_id).status_code) + + broker_submit.refresh_from_db() + self.assertTrue(broker_submit.status == BrokerSubmit.StatusEnum.SAVED) + + def test_broker_communication_error(self): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, task=self.task, + user=self.user) + submit.pk = 1 + submit.save() + submit_id = submit.pk + DelayedAction.INSTANCE.set_lock( + create_broker_submit_id(self.course.short_name, int(submit_id))) + broker_submit = BrokerSubmit.send(self.course, submit_id, + self.pkg_instance, broker_password='wrong') + + self.assertTrue(DelayedAction.INSTANCE.wait_for(broker_submit.broker_id, 2)) + self.assertEqual(200, + DelayedAction.INSTANCE.exec_func(broker_submit.broker_id).status_code) + + broker_submit.refresh_from_db() + self.assertTrue(broker_submit.status == BrokerSubmit.StatusEnum.ERROR) + + def test_wrong_requests(self): + cl = Client() + self.assertEqual(405, cl.get('/broker_api/result').status_code) + self.assertEqual(405, cl.get('/broker_api/error').status_code) + self.assertEqual(415, cl.post('/broker_api/result', + content_type='text/plain').status_code) + self.assertEqual(415, cl.post('/broker_api/error', + content_type='text/plain').status_code) + + def test_broker_no_communication(self): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, task=self.task, + user=self.user) + submit.pk = 1 + submit.save() + submit_id = submit.pk + self.assertRaises(ConnectionError, BrokerSubmit.send, self.course, submit_id, + self.pkg_instance, broker_url='http://127.0.0.1/wrong') + + broker_submit = BrokerSubmit.objects.get(course=self.course, submit_id=submit_id) + self.assertEqual(broker_submit.status, BrokerSubmit.StatusEnum.ERROR) + + def test_wrong_submit_id(self): + ret = send('/broker_api/result', json.dumps(BrokerToBaca( + pass_hash=make_hash(settings.BACA_PASSWORD, 'wrong___12'), + submit_id='wrong___12', + results={} + ).serialize())) + self.assertEqual(470, ret.status_code) + + ret = send('/broker_api/error', json.dumps(BrokerToBacaError( + pass_hash=make_hash(settings.BACA_PASSWORD, 'wrong___12'), + submit_id='wrong___12', + error='error' + ).serialize())) + self.assertEqual(470, ret.status_code) + + def test_wrong_password(self): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, task=self.task, + user=self.user, auto_send=False) + submit.pk = 1 + submit.save() + submit_id = submit.pk + DelayedAction.INSTANCE.set_lock( + create_broker_submit_id(self.course.short_name, int(submit_id))) + broker_submit = BrokerSubmit.send(self.course, submit_id, + self.pkg_instance, broker_password='wrong') + + ret = send('/broker_api/result', json.dumps(BrokerToBaca( + pass_hash=make_hash('wrong', broker_submit.broker_id), + submit_id=broker_submit.broker_id, + results={} + ).serialize())) + self.assertEqual(403, ret.status_code) + + def test_deleteErrors(self): + for i in range(10): + BrokerSubmit.objects.create(course=self.course, + submit_id=i, + package_instance=self.pkg_instance, + status=BrokerSubmit.StatusEnum.ERROR, + update_date=datetime.now() - timedelta( + settings.BROKER_RETRY_POLICY.deletion_timeout)) + for i in range(10): + BrokerSubmit.objects.create(course=self.course, + submit_id=i, + package_instance=self.pkg_instance, + status=BrokerSubmit.StatusEnum.ERROR, + update_date=datetime.now()) + self.assertEqual(20, + BrokerSubmit.objects.filter(status=BrokerSubmit.StatusEnum.ERROR).count()) + call_command('deleteErrors') + self.assertEqual(10, + BrokerSubmit.objects.filter(status=BrokerSubmit.StatusEnum.ERROR).count()) + + def test_markExpired(self): + for i in range(10): + BrokerSubmit.objects.create(course=self.course, + submit_id=i, + package_instance=self.pkg_instance, + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + update_date=datetime.now() - timedelta( + settings.BROKER_RETRY_POLICY.expiration_timeout)) + for i in range(10): + BrokerSubmit.objects.create(course=self.course, + submit_id=i, + package_instance=self.pkg_instance, + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + update_date=datetime.now()) + self.assertEqual(20, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE).count()) + call_command('markExpired') + self.assertEqual(10, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.EXPIRED).count()) + + def test_resendToBroker(self): + for i in range(10): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, task=self.task, + user=self.user, auto_send=False) + submit.pk = i + submit.save() + submit_id = submit.pk + BrokerSubmit.send(self.course, submit_id, + self.pkg_instance, broker_password=settings.BROKER_PASSWORD) + + call_command('resendToBroker') + self.assertEqual(10, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE).count()) + for _ in range(settings.BROKER_RETRY_POLICY.resend_max_retries): + BrokerSubmit.objects.filter(status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + submit_id__gte=5).update( + status=BrokerSubmit.StatusEnum.EXPIRED) + call_command('resendToBroker') + self.assertEqual(10, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE).count()) + self.assertEqual(0, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.EXPIRED).count()) + BrokerSubmit.objects.filter(status=BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + submit_id__gte=5).update( + status=BrokerSubmit.StatusEnum.EXPIRED) + call_command('resendToBroker') + self.assertEqual(5, BrokerSubmit.objects.filter( + status=BrokerSubmit.StatusEnum.ERROR).count()) + + +class OnlineTest(TestCase): + TIMEOUT = 60 + + course = None + pkg_instance = None + task = None + user = None + + @classmethod + def setUpClass(cls) -> None: + cls.course = Course.objects.create_course(name=f'new course1 {datetime.now().timestamp()}') + + cls.pkg_instance = PackageInstance.objects.create_source_and_instance('dosko', '1') + cls.pkg_instance.save() + + cls.user = User.objects.create_user(password='user1', + email=f'test{datetime.now().timestamp()}@test.pl') + + with InCourse(cls.course.short_name): + round_ = Round.objects.create_round(start_date=datetime.now(), + deadline_date=datetime.now() + timedelta(days=1), + reveal_date=datetime.now() + timedelta(days=2)) + round_.save() + + cls.task = Task.objects.create_task( + task_name='Liczby doskonałe', + package_instance=cls.pkg_instance, + round_=round_, + points=10, + initialise_task=True, + ) + cls.task.save() + + @classmethod + def tearDownClass(cls) -> None: + Course.objects.delete_course(cls.course) + + def test_broker_communication(self): + src_code = settings.SUBMITS_DIR / '1234.cpp' + src_code = src_code.absolute() + + with InCourse(self.course.short_name): + submit = Submit.objects.create_submit(source_code=src_code, + task=self.task, + user=self.user, + auto_send=False) + submit.save() + broker_submit = submit.send() + + start = datetime.now() + + while all(( + broker_submit.status == BrokerSubmit.StatusEnum.AWAITING_RESPONSE, + datetime.now() - start < timedelta(seconds=self.TIMEOUT) + )): + broker_submit.refresh_from_db() + sleep(0.1) + + self.assertTrue(broker_submit.status == BrokerSubmit.StatusEnum.SAVED) + with InCourse(self.course.short_name): + submit.refresh_from_db() + self.assertTrue(submit.score == 0) + results = Result.objects.all() + self.assertGreater(len(results), 0) diff --git a/BaCa2/broker_api/urls.py b/BaCa2/broker_api/urls.py new file mode 100644 index 00000000..b6b2901d --- /dev/null +++ b/BaCa2/broker_api/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path('result', views.handle_broker_result, name='brokerApiResult'), + path('error', views.handle_broker_error, name='brokerApiError') +] diff --git a/BaCa2/broker_api/views.py b/BaCa2/broker_api/views.py new file mode 100644 index 00000000..bf886363 --- /dev/null +++ b/BaCa2/broker_api/views.py @@ -0,0 +1,214 @@ +import json +import logging +import traceback + +from django.http import HttpResponse +from django.views.decorators.csrf import csrf_exempt + +from baca2PackageManager.broker_communication import * +from main.models import Course + +from .models import BrokerSubmit + +# Initialize logger +logger = logging.getLogger(__name__) + + +def initial_request_check(request) -> HttpResponse | None: + """ + Performs an initial check on the HTTP request. + + This function checks if the request method is POST and if the content type of the request + is 'application/json'. If any of these checks fail, it returns an HTTP response with + an appropriate status code and message. If all checks pass, it returns None. + + Possible status codes: + + * If the request method is not POST, it returns `405` (Method Not Allowed). + * If the content type of the request is not 'application/json', + it returns `415` (Unsupported Media Type). + + :param request: The HTTP request to check. + :type request: django.http.HttpRequest + :return: An HTTP response if any of the checks fail, None otherwise. + :rtype: django.http.HttpResponse | None + """ + # Check if the request method is POST + if request.method != 'POST': + return HttpResponse('Method other then POST is not allowed.', status=405) + + # Check if the content type of the request is 'application/json' + if request.headers.get('content-type') != 'application/json': + return HttpResponse('Wrong argument', status=415) + + return None + + +def check_submit_id(broker_submit_id: str) -> HttpResponse | None: + """ + Checks the validity of the submit ID. + + This function splits the broker_submit_id into course_short_name and submit_id. + It then retrieves the course with the short_name and checks if it exists. + If the course does not exist, it returns an HTTP response with status code `470` and + an appropriate message. + If the course exists, it retrieves the submit with the submit_id and checks if it exists. + If the submit does not exist, it returns an HTTP response with status code `471` and + an appropriate message. + If both the course and the submit exist, it returns None. + + If unknown error occurs, it returns an HTTP response with status code `400`. + + Possible status codes: + + * If the course does not exist, it returns `470` ([internal code]: bad course name). + * If the submit does not exist, it returns `471` ([internal code]: bad submit id). + + :param broker_submit_id: The submit ID to check. + :type broker_submit_id: str + :return: An HTTP response if the course or the submit do not exist, None otherwise. + :rtype: django.http.HttpResponse | None + """ + try: + course_short_name, submit_id = split_broker_submit_id(broker_submit_id) + except Exception as e: + logger.warning(f'Failed to split submit id {broker_submit_id}: {e}') + return HttpResponse(f'Wrong submit id: {e}', status=400) + + try: + course = Course.objects.get(short_name=course_short_name) + except Exception as e: + logger.warning(f'Failed to get course with short name {course_short_name}: {e}') + return HttpResponse(f'Wrong course name: {course_short_name}', status=470) + try: + course.get_submit(submit_id) + except Exception as e: + logger.warning(f'Failed to get submit with id {submit_id}: {e}') + return HttpResponse(f'Wrong submit id: {submit_id}', status=471) + return None + + +@csrf_exempt +def handle_broker_result(request): + """ + Handles the result sent by the broker. + + This function is exempt from CSRF verification. It expects a POST request with a JSON body. + The JSON body is parsed and handled by the BrokerSubmit.handle_result method. + + Possible status codes: + + * If the request method is not POST, it returns `405` (Method Not Allowed). + * If the content type of the request is not 'application/json', + it returns `415` (Unsupported Media Type). + * If there is a PermissionError during the processing of the request, + it returns `403` (Forbidden). + * If there is any other exception during the processing of the request, it returns + + - `550` [internal code]: Parsing error + - `551` [internal code]: Error, while handling error + - `552` [internal code]: Error, while handling result + + * If the request is processed successfully, it returns `200` (OK). + + :param request: The HTTP request received from the broker. + :type request: django.http.HttpRequest + :return: An HTTP response. The status code of the response depends on the processing of + the request. + :rtype: django.http.HttpResponse + """ + initial_check = initial_request_check(request) + if initial_check: + return initial_check + logger.warning(request.body.decode()) + + # Try to parse the JSON body of the request + try: + data = BrokerToBaca.parse_obj(json.loads(request.body)) + + submit_check = check_submit_id(data.submit_id) + if submit_check: + return submit_check + except Exception as e: + return HttpResponse(f'Failed to parse data: {e}', status=550) + + # Try to handle the parsed data + try: + BrokerSubmit.handle_result(data) + except PermissionError as e: + return HttpResponse(f'Authentication failed: {e}', status=403) + except Exception as e: + # Log the exception + logger.error(traceback.format_exc()) + # Create an error object + error = BrokerToBacaError( + pass_hash=data.pass_hash, + submit_id=data.submit_id, + error_data={'message': f'Failed to handle result: {e}', 'traceback': ''} + ) + # Try to handle the error + try: + BrokerSubmit.handle_error(error) + except Exception as e: + # Log the critical error + logger.critical(f'Failed to handle error: {e}') + return HttpResponse(f'Failed to handle error {e}', status=551) + return HttpResponse(f'Failed to handle result: {e}', status=552) + else: + return HttpResponse('Success', status=200) + + +@csrf_exempt +def handle_broker_error(request): + """ + Handles the error sent by the broker. + + This function is exempt from CSRF verification. It expects a POST request with a JSON body. + The JSON body is parsed and handled by the BrokerSubmit.handle_error method. + + Possible status codes: + + * If the request method is not POST, it returns `405` (Method Not Allowed). + * If the content type of the request is not 'application/json', + it returns `415` (Unsupported Media Type). + * If there is a PermissionError during the processing of the request, + it returns `403` (Forbidden). + * If there is any other exception during the processing of the request, it returns + + - `550` [internal code]: Parsing error + - `551` [internal code]: Error, while handling error + + * If the request is processed successfully, it returns `200` (OK). + + :param request: The HTTP request received from the broker. + :type request: django.http.HttpRequest + :return: An HTTP response. The status code of the response depends on the processing of + the request. + :rtype: django.http.HttpResponse + """ + initial_check = initial_request_check(request) + if initial_check: + return initial_check + + # Try to parse the JSON body of the request + try: + data = BrokerToBacaError.parse_obj(json.loads(request.body)) + logger.warning(data) + + submit_check = check_submit_id(data.submit_id) + if submit_check: + return submit_check + except Exception as e: + return HttpResponse(f'Failed to parse data: {e}', status=550) + + # Try to handle the parsed data + try: + BrokerSubmit.handle_error(data) + except PermissionError as e: + return HttpResponse(f'Authentication failed: {e}', status=403) + except Exception as e: + # Log the critical error + logger.critical(f'Failed to handle error: {e}') + return HttpResponse(f'Failed to handle error {e}', status=551) + else: + return HttpResponse('Success', status=200) diff --git a/BaCa2/package/packages/1/tests/set0/2.in b/BaCa2/core/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set0/2.in rename to BaCa2/core/__init__.py diff --git a/BaCa2/BaCa2/asgi.py b/BaCa2/core/asgi.py similarity index 74% rename from BaCa2/BaCa2/asgi.py rename to BaCa2/core/asgi.py index ae86227e..76ae1d87 100644 --- a/BaCa2/BaCa2/asgi.py +++ b/BaCa2/core/asgi.py @@ -1,5 +1,5 @@ """ -ASGI config for BaCa2 project. +ASGI config for core project. It exposes the ASGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'BaCa2.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') application = get_asgi_application() diff --git a/BaCa2/core/auth_backend.py b/BaCa2/core/auth_backend.py new file mode 100644 index 00000000..746748d6 --- /dev/null +++ b/BaCa2/core/auth_backend.py @@ -0,0 +1,35 @@ +from django.contrib.auth import get_user_model + +UserModel = get_user_model() + + +class BaCa2AuthBackend: + def authenticate(self, request, username=None, password=None, **kwargs): + if username is None: + username = kwargs.get(UserModel.USERNAME_FIELD) + if username is None or password is None: + return + try: + user = UserModel._default_manager.get_by_natural_key(username) + except UserModel.DoesNotExist: + # Run the default password hasher once to reduce the timing + # difference between an existing and a nonexistent user (#20760). + UserModel().set_password(password) + else: + if user.check_password(password) and self.user_can_authenticate(user): + return user + + def get_user(self, user_id): + try: + user = UserModel._default_manager.get(pk=user_id) + except UserModel.DoesNotExist: + return None + return user if self.user_can_authenticate(user) else None + + @staticmethod + def user_can_authenticate(user): + """ + Reject users with is_active=False. Custom user models that don't have + that attribute are allowed. + """ + return getattr(user, 'is_active', True) diff --git a/BaCa2/core/choices.py b/BaCa2/core/choices.py new file mode 100644 index 00000000..eb4459e6 --- /dev/null +++ b/BaCa2/core/choices.py @@ -0,0 +1,80 @@ +from typing import Self + +from django.db import models +from django.utils.translation import gettext_lazy as _ + + +class TaskJudgingMode(models.TextChoices): + UNA = 'UNA', _('Unanimous') + LIN = 'LIN', _('Linear') + + +class ResultStatus(models.TextChoices): + PND = 'PND', _('Pending') + OK = 'OK', _('Accepted') + ANS = 'ANS', _('Wrong answer') + TLE = 'TLE', _('Time limit exceeded') + RTE = 'RTE', _('Runtime error') + MEM = 'MEM', _('Memory exceeded') + CME = 'CME', _('Compilation error') + RUL = 'RUL', _('Rule violation') + EXT = 'EXT', _('Unknown extension') + ITL = 'ITL', _('Internal timeout') + INT = 'INT', _('Internal error') + + @classmethod + def compare(cls, status1: Self, status2: Self): + order = list(cls) + return order.index(status1) - order.index(status2) + + +EMPTY_FINAL_STATUSES = [ResultStatus.EXT, ResultStatus.ITL, ResultStatus.INT, ResultStatus.EXT] +HALF_EMPTY_FINAL_STATUSES = [ResultStatus.CME, ResultStatus.RTE, ResultStatus.RUL] + + +class PermissionCheck(models.TextChoices): + INDV = 'individual', _('Individual Permissions') + GRP = 'group', _('Group-level Permissions') + GEN = 'general', _('All Permissions') + + +class ModelAction(models.TextChoices): + pass + + +class BasicModelAction(ModelAction): + ADD = 'ADD', 'add' + DEL = 'DEL', 'delete' + EDIT = 'EDIT', 'change' + VIEW = 'VIEW', 'view' + + +class UserJob(models.TextChoices): + ST = 'ST', _('Student') + DC = 'DC', _('Doctoral') + EM = 'EM', _('Employee') + AD = 'AD', _('Admin') + + +class TaskDescriptionExtension(models.TextChoices): + PDF = 'PDF', _('PDF') + MD = 'MD', _('Markdown') + HTML = 'HTML', _('HTML') + TXT = 'TXT', _('Plain text') + + +class SubmitType(models.TextChoices): + STD = 'STD', _('Standard') + HID = 'HID', _('Hidden') + CTR = 'CTR', _('Control') + + +class ScoreSelectionPolicy(models.TextChoices): + BEST = 'BEST', _('Best submit') + LAST = 'LAST', _('Last submit') + + +class FallOffPolicy(models.TextChoices): + NONE = 'NONE', _('No fall-off (max points until deadline)') + LINEAR = 'LINEAR', _('Linear fall-off') + SQUARE = 'SQUARE', _('Square fall-off') diff --git a/BaCa2/package/packages/1/tests/set0/2.out b/BaCa2/core/db/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set0/2.out rename to BaCa2/core/db/__init__.py diff --git a/BaCa2/core/db/manager.py b/BaCa2/core/db/manager.py new file mode 100644 index 00000000..eb5e82a4 --- /dev/null +++ b/BaCa2/core/db/manager.py @@ -0,0 +1,347 @@ +import copy +import json +import logging +from pathlib import Path +from threading import Lock + +import psycopg2 + +logger = logging.getLogger(__name__) + +#: Postgres query to close all connections to a database +CLOSE_ALL_DB_CONNECTIONS = """SELECT pg_terminate_backend(pg_stat_activity.pid) +FROM pg_stat_activity +WHERE pg_stat_activity.datname = '%s' + AND pid <> pg_backend_pid(); +""" + + +class DB: + """ + A class to represent a database. + """ + + def __init__(self, + db_name: str, + defaults: dict = None, + key_is_name: bool = False, + **kwargs): + """ + It initializes the DB object. + + :param db_name: The name of the database. + :type db_name: str + :param defaults: The default settings for the database. + :type defaults: dict + :param key_is_name: Whether the key of the database is the name of the database. + :type key_is_name: bool + :param kwargs: The settings for the database. + """ + if defaults is None: + defaults = {} + if not db_name: + raise ValueError('db_name must be a non-empty string.') + self.db_name = db_name + self.key_is_name = key_is_name + self.key = self.db_name if key_is_name else self.db_name + '_db' + self.settings = defaults | kwargs | {'NAME': self.key} + if key_is_name: + self.settings['NAME'] = self.db_name + + @classmethod + def from_json(cls, db_dict: dict, db_name: str = None) -> 'DB': + """ + It creates a DB object from a dictionary. If the db_name is not provided, it is taken from + the dictionary, but without the '_db' suffix. + + :param db_dict: The dictionary with the database settings. + :type db_dict: dict + :param db_name: The name of the database. + :type db_name: str + + :return: The DB object. + :rtype: DB + """ + if 'NAME' not in db_dict: + raise ValueError('DB name not found in dictionary.') + if db_name is None: + db_name = db_dict.pop('NAME') + else: + db_dict.pop('NAME') + if db_name.endswith('_db'): + db_name = db_name[:-3] + logger.warning(f'Database name {db_name} has been stripped of the "_db" suffix.') + return cls(db_name, **db_dict) + + def to_dict(self) -> dict: + """ + It returns a deep copy of the database settings as a dictionary. + """ + return copy.deepcopy(self.settings) + + @property + def name(self): + """ + It returns the name of the database. + """ + return self.db_name + + +class DBManager: + """ + A class to manage databases in the system. It allows you to create, delete and migrate + databases on django runtime. + """ + + #: Maximum length of a database name. Used to detect SQL injection. + MAX_DB_NAME_LENGTH = 63 + + RESERVED_DB_KEYS = {'default', 'postgres', 'template0', 'template1'} + + class SQLInjectionError(Exception): + """ + An exception raised when SQL injection is detected. + """ + pass + + def __init__(self, + cache_file: Path, + default_settings: dict, + root_user: str, + root_password: str, + db_host: str = 'localhost', + databases: dict = None, + default_db_key: str = 'default', + add_default: bool = True, ): + """ + It initializes the DBManager object. + + :param cache_file: The file where the database settings are stored. + :type cache_file: Path + :param default_settings: The default settings for new databases. + :type default_settings: dict + :param root_user: The root username of the database server. + :type root_user: str + :param root_password: The root password of the database server. + :type root_password: str + :param db_host: The host of the database server. + :type db_host: str + :param databases: The databases that are already created - reference to django DATABASES + setting. + :type databases: dict + :param default_db_key: The default key for the default database. + :type default_db_key: str + :param add_default: Whether to add the default database to the runtime databases. + :type add_default: bool + """ + self.cache_file = cache_file + self.default_settings = default_settings + self.root_user = root_user + self.root_password = root_password + self.db_host = db_host + self.databases = databases + if self.databases is None: + self.databases = {} + self.default_db_key = default_db_key + self.databases_access_lock = Lock() + self.db_root_access_lock = Lock() + self.cache_lock = Lock() + + if add_default: + default = DB(self.default_db_key, self.default_settings, key_is_name=True) + self.databases['default'] = default.to_dict() + + logger.info('DBManager initialized.') + + def _raw_root_connection(self): + """ + It creates a raw connection to the database server as the root user + + :return: A connection to the postgres database. + """ + try: + conn = psycopg2.connect( + database='postgres', + user=self.root_user, + password=self.root_password, + host=self.db_host + ) + conn.autocommit = True + return conn + except psycopg2.OperationalError as e: + raise psycopg2.OperationalError(f'Error connecting to the database: {str(e)}') + + def detect_sql_injection(self, db_name: str) -> None: + """ + It detects if the database name is a SQL injection. + + :param db_name: The name of the database to check. + :type db_name: str + + :raises self.SQLInjectionError: If the database name is a SQL injection. + """ + db_name = db_name.strip() + if not db_name: + raise self.SQLInjectionError('Empty database name.') + + if ' ' in db_name: + raise self.SQLInjectionError( + 'Spaces detected in database name. Potential SQL injection.') + + if len(db_name) > self.MAX_DB_NAME_LENGTH: + raise self.SQLInjectionError( + 'Database name exceeds maximum length. Potential SQL injection.') + + if db_name.lower() in self.RESERVED_DB_KEYS: + raise self.SQLInjectionError( + 'Reserved database name detected. Potential SQL injection.') + + # if is_sql_injection(db_name)['is_sqli']: + # raise self.SQLInjectionError('SQL injection detected.') + + def create_db(self, db_name: str, **kwargs) -> None: + """ + It creates a new database, adds it to the settings file and to the runtime database + connections. + + :param db_name: The name of the database to create + :type db_name: str + """ + self.detect_sql_injection(db_name) + db = DB(db_name, self.default_settings, **kwargs) + + with self.databases_access_lock: + if db.key in self.databases: + raise ValueError(f'DB {db_name} already exists.') + + with self.db_root_access_lock: + conn = self._raw_root_connection() + cursor = conn.cursor() + + drop_if_exist = ' DROP DATABASE IF EXISTS %s; ' + sql = ' CREATE DATABASE %s; ' + + try: + cursor.execute(drop_if_exist % db.key) + cursor.execute(sql % db.key) + except Exception as e: + logger.error(f'Error executing SQL commands: {str(e)}') + + finally: + conn.close() + + self.databases.setdefault(db.name, db.to_dict()) + + with self.cache_lock: + self.save_cache(with_locks=False) + logger.info(f'Database {db_name} created.') + + def migrate_db(self, db_name: str, migrate_all: bool = False) -> None: + """ + It migrates the database to the latest version, using django management command + (``migrate``). + + :param db_name: The name of the database to migrate. + :type db_name: str + :param migrate_all: Should be set to True if method is called from ``migrate_all`` method. + :type migrate_all: bool + """ + from django.core.management import call_command + + if migrate_all: + if db_name not in self.databases: + raise ValueError(f'DB {db_name} does not exist or not registered properly.') + else: + with self.databases_access_lock: + if db_name not in self.databases: + raise ValueError(f'DB {db_name} does not exist or not registered properly.') + call_command('migrate', + database=db_name, + interactive=False, + skip_checks=True, + verbosity=0) + logger.info(f'Database {db_name} migrated.') + + def migrate_all(self): + """ + It migrates all the databases to the latest version. + """ + with self.databases_access_lock: + for db_key in self.databases.keys(): + if db_key != 'default': + self.migrate_db(db_key, migrate_all=True) + + def delete_db(self, db_name: str) -> None: + """ + It deletes a database from the database server and from the runtime databases. + + :param db_name: The name of the database to delete. + :type db_name: str + """ + self.detect_sql_injection(db_name) + db = DB(db_name, self.default_settings) + self.parse_cache() + + with self.databases_access_lock: + if db.name not in self.databases: + raise ValueError(f'DB {db_name} does not exist.') + + self.databases.pop(db.name, None) + + with self.cache_lock: + self.save_cache(with_locks=False) + + with self.db_root_access_lock: + conn = self._raw_root_connection() + cursor = conn.cursor() + try: + cursor.execute(CLOSE_ALL_DB_CONNECTIONS % db.key) + cursor.execute(' DROP DATABASE IF EXISTS %s; ' % db.key) + except Exception as e: + logger.error(f'Error deleting database: {str(e)}') + finally: + conn.close() + logger.info(f'Database {db_name} deleted.') + + def parse_cache(self) -> None: + """ + It parses the cache file and loads the databases into the runtime databases. + Best to be called on django app startup to load the databases from the cache file. + """ + with self.cache_lock: + try: + with self.cache_file.open('r') as f: + cache = json.load(f) + except FileNotFoundError: + logger.error('Cache file not found. Continuing with empty cache.') + cache = {} + except json.JSONDecodeError: + logger.error('Error loading JSON file. Continuing with empty cache.') + cache = {} + + with self.databases_access_lock: + for db_name, db_settings in cache.items(): + db = DB.from_json(db_settings, db_name=db_name) + self.databases.setdefault(db.name, db.to_dict()) + + logger.info('Databases loaded from cache.') + + def save_cache(self, with_locks: bool = True) -> None: + """ + It saves the runtime databases into the cache file. + + :param with_locks: Whether to use locks when accessing the databases. + :type with_locks: bool + """ + if with_locks: + with self.databases_access_lock: + databases = copy.deepcopy(self.databases) + else: + databases = copy.deepcopy(self.databases) + + databases.pop('default', None) + try: + self.cache_file.write_text(json.dumps(databases, indent=4)) + logger.info('Databases saved to cache.') + except Exception as e: + logger.error(f'Error saving databases to cache: {str(e)}') diff --git a/BaCa2/BaCa2/exceptions.py b/BaCa2/core/exceptions.py similarity index 58% rename from BaCa2/BaCa2/exceptions.py rename to BaCa2/core/exceptions.py index 39a9f403..3105ab13 100644 --- a/BaCa2/BaCa2/exceptions.py +++ b/BaCa2/core/exceptions.py @@ -1,6 +1,7 @@ class ModelValidationError(Exception): pass + class DataError(Exception): pass @@ -8,16 +9,6 @@ class DataError(Exception): class NewDBError(Exception): pass -class NoTestFound(Exception): - pass - -class NoSetFound(Exception): - pass class RoutingError(Exception): pass - -class TestExistError(Exception): - pass - - diff --git a/BaCa2/core/settings/__init__.py b/BaCa2/core/settings/__init__.py new file mode 100644 index 00000000..d7a6de00 --- /dev/null +++ b/BaCa2/core/settings/__init__.py @@ -0,0 +1,48 @@ +import os +from pathlib import Path + +from core.tools.path_creator import PathCreator +from dotenv import load_dotenv +from split_settings.tools import include, optional + +load_dotenv() + +# dirs to be auto-created if not exist +_auto_create_dirs = PathCreator() + +BASE_DIR = Path(__file__).resolve().parent.parent.parent +BASE_DIR = BASE_DIR.absolute() + +ENVVAR_SETTINGS_PREFIX = 'BACA2_WEB_' + +LOCAL_SETTINGS_PATH = os.getenv(f'{ENVVAR_SETTINGS_PREFIX}LOCAL_SETTINGS_PATH') +if not LOCAL_SETTINGS_PATH: + LOCAL_SETTINGS_PATH = BASE_DIR.parent / 'local' / 'settings.dev.py' + +LOCAL_SETTINGS_PATH = LOCAL_SETTINGS_PATH.absolute() + +OS_NAME = 'unknown' +if os.name == 'nt': + OS_NAME = 'windows' +elif os.name == 'posix': + OS_NAME = 'unix' + +include( + 'security.py', + 'apps.py', + 'base.py', + 'localization.py', + 'logs.py', + 'database.py', + 'authentication.py', + 'login.py', + 'packages.py', + 'broker.py', + 'static_files.py', + 'usos.py', + 'email.py', + optional(str(LOCAL_SETTINGS_PATH)), + 'envvars.py', +) + +_auto_create_dirs.create() diff --git a/BaCa2/core/settings/apps.py b/BaCa2/core/settings/apps.py new file mode 100644 index 00000000..98889a7e --- /dev/null +++ b/BaCa2/core/settings/apps.py @@ -0,0 +1,52 @@ +# APPLICATIONS ----------------------------------------------------------------------- +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + 'django.contrib.sites', + + 'django_extensions', # https://github.com/django-extensions/django-extensions + 'django_bootstrap_icons', # https://pypi.org/project/django-bootstrap-icons/ + 'dbbackup', # https://github.com/jazzband/django-dbbackup + 'widget_tweaks', # https://github.com/jazzband/django-widget-tweaks + + 'mozilla_django_oidc', # login uj required + + # LOCAL APPS + 'broker_api.apps.BrokerApiConfig', + 'main', + 'course', + 'package', + 'util', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'django.middleware.locale.LocaleMiddleware', + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'mozilla_django_oidc.middleware.SessionRefresh', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [BASE_DIR / 'templates'], # noqa: F821 + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + 'util.context_processors.version_tag', + ], + }, + }, +] diff --git a/BaCa2/core/settings/authentication.py b/BaCa2/core/settings/authentication.py new file mode 100644 index 00000000..2c7d317c --- /dev/null +++ b/BaCa2/core/settings/authentication.py @@ -0,0 +1,23 @@ +AUTH_PASSWORD_VALIDATORS = [ + { + 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', + }, + { + 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', + }, +] + +AUTHENTICATION_BACKENDS = [ + # Login UJ + 'core.uj_oidc_auth.BaCa2UJAuth', + # Custom authentication backend + 'core.auth_backend.BaCa2AuthBackend' +] + +AUTH_USER_MODEL = 'main.User' diff --git a/BaCa2/core/settings/base.py b/BaCa2/core/settings/base.py new file mode 100644 index 00000000..d6913856 --- /dev/null +++ b/BaCa2/core/settings/base.py @@ -0,0 +1,9 @@ +ROOT_URLCONF = 'core.urls' +WSGI_APPLICATION = 'core.wsgi.application' + +DATETIME_FORMAT_WITH_TZ = '%Y-%m-%d %H:%M %Z' +DATETIME_FORMAT = '%Y-%m-%d %H:%M' +DATE_FORMAT = '%Y-%m-%d' +TIME_FORMAT = '%H:%M' + +SITE_ID = 1 diff --git a/BaCa2/core/settings/broker.py b/BaCa2/core/settings/broker.py new file mode 100644 index 00000000..ff3e2516 --- /dev/null +++ b/BaCa2/core/settings/broker.py @@ -0,0 +1,39 @@ +import os + +MOCK_BROKER = False + +BROKER_URL = os.getenv('BROKER_URL') +BROKER_TIMEOUT = 600 # seconds + +SUBMITS_DIR = BASE_DIR / 'submits' # noqa: F821 +_auto_create_dirs.add_dir(SUBMITS_DIR) # noqa: F821 + +# Passwords for protecting communication channels between the broker and BaCa2. +# PASSWORDS HAVE TO DIFFERENT IN ORDER TO BE EFFECTIVE +BACA_PASSWORD = os.getenv('BACA_PASSWORD') +BROKER_PASSWORD = os.getenv('BROKER_PASSWORD') + + +class BrokerRetryPolicy: + """Broker retry policy settings""" + # HTTP post request be sent to the broker for one submit + individual_submit_retry_interval = 0.05 + individual_max_retries = 5 + + # (In seconds) how long it should take for a submit to become expired + expiration_timeout = 60.0 * 15 + # how many times a submit should be resent after it expires + resend_max_retries = 5 + # (In minutes) how often should expiration check be performed + retry_check_interval = 5 + + # (In minutes) specify how old should error submits be before they are deleted + deletion_timeout = 60.0 * 24 + # (In minutes) specify how often should the deletion check be performed + deletion_check_interval = 60.0 + + # Auto start broker daemons + auto_start = True + + +BROKER_RETRY_POLICY = BrokerRetryPolicy() diff --git a/BaCa2/core/settings/database.py b/BaCa2/core/settings/database.py new file mode 100644 index 00000000..c59e41dc --- /dev/null +++ b/BaCa2/core/settings/database.py @@ -0,0 +1,58 @@ +import os +from contextvars import ContextVar + +from core.db.manager import DBManager + +DB_BACKUP_DIR = BASE_DIR / 'backup' # noqa: F821 +_auto_create_dirs.add_dir(DB_BACKUP_DIR) # noqa: F821 +DB_SETTINGS_DIR = BASE_DIR / 'core' / 'db' # noqa: F821 +_auto_create_dirs.assert_exists_dir(DB_SETTINGS_DIR, instant=True) # noqa: F821 +DB_DEFINITIONS_FILE = DB_SETTINGS_DIR / 'db.cache' +_auto_create_dirs.add_file(DB_DEFINITIONS_FILE) # noqa: F821 + +BACA2_DB_USER = os.getenv('BACA2_DB_USER', 'baca2') +BACA2_DB_PASSWORD = os.getenv('BACA2_DB_PASSWORD') +BACA2_DB_ROOT_USER = os.getenv('BACA2_DB_ROOT_USER', 'root') +BACA2_DB_ROOT_PASSWORD = os.getenv('BACA2_DB_ROOT_PASSWORD') +DEFAULT_DB_KEY = 'baca2db' +DEFAULT_DB_HOST = 'localhost' + +DEFAULT_DB_SETTINGS = { + 'ENGINE': 'django.db.backends.postgresql_psycopg2', + 'USER': BACA2_DB_USER, + 'PASSWORD': BACA2_DB_PASSWORD, + 'HOST': 'localhost', + 'PORT': '', + 'TIME_ZONE': None, + 'CONN_HEALTH_CHECKS': False, + 'CONN_MAX_AGE': 0, + 'AUTOCOMMIT': True, + 'OPTIONS': {}, + 'ATOMIC_REQUESTS': False +} + +DATABASES = {} + +DB_MANAGER = DBManager( + cache_file=DB_DEFINITIONS_FILE, + default_settings=DEFAULT_DB_SETTINGS, + root_user=BACA2_DB_ROOT_USER, + root_password=BACA2_DB_ROOT_PASSWORD, + db_host=DEFAULT_DB_HOST, + databases=DATABASES, + default_db_key=DEFAULT_DB_KEY, +) +DB_MANAGER.parse_cache() + +# DB routing for courses +DATABASE_ROUTERS = ['course.routing.ContextCourseRouter'] +CURRENT_DB = ContextVar('CURRENT_DB') + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# Backup settings +DBBACKUP_STORAGE = 'django.core.files.storage.FileSystemStorage' +DBBACKUP_STORAGE_OPTIONS = { + 'location': DB_BACKUP_DIR +} diff --git a/BaCa2/core/settings/email.py b/BaCa2/core/settings/email.py new file mode 100644 index 00000000..907aa0c9 --- /dev/null +++ b/BaCa2/core/settings/email.py @@ -0,0 +1,9 @@ +import os + +EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend' +EMAIL_HOST = 'smtp.office365.com' +EMAIL_PORT = 587 +EMAIL_USE_TLS = True +EMAIL_HOST_USER = os.getenv('EMAIL_USER') +EMAIL_HOST_PASSWORD = os.getenv('EMAIL_PASSWORD') +DEFAULT_FROM_EMAIL = 'BaCa2 mailing system ' diff --git a/BaCa2/core/settings/envvars.py b/BaCa2/core/settings/envvars.py new file mode 100644 index 00000000..707adf95 --- /dev/null +++ b/BaCa2/core/settings/envvars.py @@ -0,0 +1,4 @@ +from core.tools.collections import deep_update +from core.tools.env_settings import get_settings_from_env + +deep_update(globals(), get_settings_from_env(ENVVAR_SETTINGS_PREFIX)) # noqa: F821 diff --git a/BaCa2/core/settings/localization.py b/BaCa2/core/settings/localization.py new file mode 100644 index 00000000..74c1ae60 --- /dev/null +++ b/BaCa2/core/settings/localization.py @@ -0,0 +1,18 @@ +from django.utils.translation import gettext_lazy as _ + +LANGUAGE_CODE = 'pl' +TIME_ZONE = 'Europe/Warsaw' +USE_I18N = True +USE_TZ = True + +LOCALE_PATHS = [ + BASE_DIR / 'locale' # noqa: F821 +] + +LANGUAGES = [ + ('pl', _('Polish')), + ('en', _('English')), +] + +for path in LOCALE_PATHS: + _auto_create_dirs.add_dir(path) # noqa: F821 diff --git a/BaCa2/core/settings/login.py b/BaCa2/core/settings/login.py new file mode 100644 index 00000000..3bfc24f8 --- /dev/null +++ b/BaCa2/core/settings/login.py @@ -0,0 +1,30 @@ +LOGIN_URL = '/login/' + +LOGIN_REDIRECT_URL = '/main/dashboard' + +# Login UJ +OIDC_RP_CLIENT_ID = os.getenv('OIDC_RP_CLIENT_ID') # noqa: F821 +OIDC_RP_CLIENT_SECRET = os.getenv('OIDC_RP_CLIENT_SECRET') # noqa: F821 + +AUTH_DOMAIN = 'auth.dev.uj.edu.pl' + +OIDC_OP_AUTHORIZATION_ENDPOINT = ( + f'https://{AUTH_DOMAIN}/auth/realms/uj/protocol/openid-connect/auth') +OIDC_OP_TOKEN_ENDPOINT = ( + f'https://{AUTH_DOMAIN}/auth/realms/uj/protocol/openid-connect/token') +OIDC_OP_USER_ENDPOINT = ( + f'https://{AUTH_DOMAIN}/auth/realms/uj/protocol/openid-connect/userinfo') +OIDC_RP_SIGN_ALGO = 'RS256' +OIDC_OP_JWKS_ENDPOINT = f'https://{AUTH_DOMAIN}/auth/realms/uj/protocol/openid-connect/certs' + +OIDC_OP_LOGOUT_URL = f'https://{AUTH_DOMAIN}/auth/realms/uj/protocol/openid-connect/logout' + +LOGOUT_REDIRECT_URL = '/login' + +ALLOWED_INTERNAL_EMAILS = [ + '@uj.edu.pl', + '@student.uj.edu.pl', + '@doctoral.uj.edu.pl', + '@doktorant.uj.edu.pl', + '@ii.uj.edu.pl', +] diff --git a/BaCa2/core/settings/logs.py b/BaCa2/core/settings/logs.py new file mode 100644 index 00000000..c3ac6e0b --- /dev/null +++ b/BaCa2/core/settings/logs.py @@ -0,0 +1,124 @@ +LOGS_DIR = BASE_DIR / 'logs' # noqa: F821 +_auto_create_dirs.add_dir(LOGS_DIR) # noqa: F821 + +FORMATTER_MODULE = 'core.tools.logs' + +FORMATTER = f'{FORMATTER_MODULE}.CustomFormatter' + +COLORED_FORMATTER = f'{FORMATTER_MODULE}.CustomColoredFormatter' + +VERBOSE_FORMATTER = { + 'windows': FORMATTER, + 'unix': COLORED_FORMATTER, +} + +FORMATTERS = { + 'simple': { + '()': COLORED_FORMATTER, + 'fmt': '%(levelname)s ' + '%(asctime)s ' + '%(pathname)s' + '%(funcName)s' + '%(lineno)d ' + '%(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + 'server_console': { + '()': COLORED_FORMATTER, + 'fmt': '%(levelname)s ' + '%(asctime)s ' + '%(name)s\033[95m::\033[0m ' + '%(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, + 'verbose': { + '()': VERBOSE_FORMATTER[OS_NAME], # noqa: F821 + 'fmt': '%(levelname)s ' + '%(asctime)s ' + '%(process)d ' + '%(pathname)s' + '%(funcName)s' + '%(lineno)d ' + '%(message)s', + 'datefmt': '%Y-%m-%d %H:%M:%S', + }, +} + +HANDLERS = { + 'console': { + 'class': 'logging.StreamHandler', + 'formatter': 'simple', + 'level': 'DEBUG', + }, + 'server_console': { + 'class': 'logging.StreamHandler', + 'formatter': 'server_console', + 'level': 'DEBUG', + }, + 'info': { + 'class': 'logging.handlers.RotatingFileHandler', + 'formatter': 'verbose', + 'level': 'INFO', + 'filename': str(LOGS_DIR / 'info.log'), + 'mode': 'a', + 'encoding': 'utf-8', + 'backupCount': 5, + 'maxBytes': 1024 * 1024, + }, + 'error': { + 'class': 'logging.handlers.RotatingFileHandler', + 'formatter': 'verbose', + 'level': 'ERROR', + 'filename': str(LOGS_DIR / 'error.log'), + 'mode': 'a', + 'encoding': 'utf-8', + 'backupCount': 5, + 'maxBytes': 1024 * 1024, + }, +} + +DJANGO_LOGGERS = { + 'django': { + 'handlers': ['console', 'info'], + 'level': 'INFO', + }, + 'django.request': { + 'handlers': ['error'], + 'level': 'INFO', + 'propagate': True, + }, + 'django.server': { + 'handlers': ['error', 'server_console'], + 'level': 'INFO', + 'propagate': False, + }, + 'django.template': { + 'handlers': ['error'], + 'level': 'DEBUG', + 'propagate': False, + }, +} + +BACA2_LOGGERS = { + app_name: { + 'handlers': ['console', 'info', 'error'], + 'level': 'DEBUG', + } for app_name in ('broker_api', 'course', 'package', 'util', 'main', 'core') +} + +OIDC_LOGGERS = { + 'mozilla_django_oidc': { + 'handlers': ['console', 'info', 'error'], + 'level': 'INFO', + } +} + +LOGGERS = DJANGO_LOGGERS | BACA2_LOGGERS | OIDC_LOGGERS + +LOGGING = { + 'version': 1, + 'disable_existing_loggers': False, + 'formatters': FORMATTERS, + 'handlers': HANDLERS, + 'loggers': LOGGERS, +} diff --git a/BaCa2/core/settings/packages.py b/BaCa2/core/settings/packages.py new file mode 100644 index 00000000..2e816e60 --- /dev/null +++ b/BaCa2/core/settings/packages.py @@ -0,0 +1,15 @@ +from typing import Dict + +import baca2PackageManager as pkg + +PACKAGES_DIR = BASE_DIR / 'packages_source' # noqa: F821 +_auto_create_dirs.add_dir(PACKAGES_DIR) # noqa: F821 +UPLOAD_DIR = BASE_DIR / 'uploads' # noqa: F821 +_auto_create_dirs.add_dir(UPLOAD_DIR) # noqa: F821 +TASK_DESCRIPTIONS_DIR = BASE_DIR / 'task_descriptions' # noqa: F821 +_auto_create_dirs.add_dir(TASK_DESCRIPTIONS_DIR) # noqa: F821 + +pkg.set_base_dir(PACKAGES_DIR) +pkg.add_supported_extensions('cpp') + +PACKAGES: Dict[str, pkg.Package] = {} diff --git a/BaCa2/core/settings/security.py b/BaCa2/core/settings/security.py new file mode 100644 index 00000000..4ab7830a --- /dev/null +++ b/BaCa2/core/settings/security.py @@ -0,0 +1,26 @@ +import os + +SECRET_KEY = os.getenv('SECRET_KEY') +DEBUG = os.getenv('DEBUG') + +HOST_NAME = os.getenv('HOST_NAME') +HOST_IP = os.getenv('HOST_IP') + +ALLOWED_HOSTS = [ + '0.0.0.0', + '127.0.0.1', + 'localhost', + HOST_NAME, + HOST_IP, +] + +# mark signal redirected via proxy as https +SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') +SECURE_SSL_REDIRECT = True +SESSION_COOKIE_SECURE = True +CSRF_COOKIE_SECURE = True + +CSRF_TRUSTED_ORIGINS = [ + f'https://{HOST_NAME}', + 'https://127.0.0.1' +] diff --git a/BaCa2/core/settings/static_files.py b/BaCa2/core/settings/static_files.py new file mode 100644 index 00000000..ffce7a4c --- /dev/null +++ b/BaCa2/core/settings/static_files.py @@ -0,0 +1,9 @@ +STATIC_URL = '/static/' +STATIC_ROOT = '/home/zyndram/core/static/' +STATICFILES_DIRS = ( + BASE_DIR / 'assets', # noqa: F821 + TASK_DESCRIPTIONS_DIR, # noqa: F821 + SUBMITS_DIR, # noqa: F821 +) +for d in STATICFILES_DIRS: + _auto_create_dirs.assert_exists_dir(d) # noqa: F821 diff --git a/BaCa2/core/settings/templates/baca2.conf b/BaCa2/core/settings/templates/baca2.conf new file mode 100644 index 00000000..242e74f4 --- /dev/null +++ b/BaCa2/core/settings/templates/baca2.conf @@ -0,0 +1,32 @@ + + ServerName baca2.ii.uj.edu.pl + + # Redirect non-kolejka URLs to https + RewriteCond %{REQUEST_URI} !^/kolejka [NC] + RewriteRule ^(.*)$ https://%{HTTP_HOST}%{REQUEST_URI} [L,R=301] + + + + ServerName baca2.ii.uj.edu.pl + + ProxyPreserveHost On + + # Proxy /kolejka requests to port 8180 + ProxyPass /kolejka http://127.0.0.1:8180/kolejka + ProxyPassReverse /kolejka http://127.0.0.1:8180/kolejka + + # Proxy all other requests to port 8000 + ProxyPass / http://127.0.0.1:8000/ + ProxyPassReverse / http://127.0.0.1:8000/ + + ErrorLog /var/log/baca2_error.log + CustomLog /var/log/baca2_access.log combined + + SSLEngine on + SSLCertificateFile /etc/letsencrypt/live/baca2.ii.uj.edu.pl/fullchain.pem + SSLCertificateKeyFile /etc/letsencrypt/live/baca2.ii.uj.edu.pl/privkey.pem + Include /etc/letsencrypt/options-ssl-apache.conf + + RequestHeader set X-Forwarded-Proto 'https' env=HTTPS + + diff --git a/BaCa2/core/settings/templates/create_db_root.sql b/BaCa2/core/settings/templates/create_db_root.sql new file mode 100644 index 00000000..27e36086 --- /dev/null +++ b/BaCa2/core/settings/templates/create_db_root.sql @@ -0,0 +1,11 @@ +CREATE ROLE root WITH + LOGIN + SUPERUSER + CREATEDB + CREATEROLE + INHERIT + REPLICATION + CONNECTION LIMIT -1 + PASSWORD 'BaCa2root'; +GRANT postgres TO root WITH ADMIN OPTION; +COMMENT ON ROLE root IS 'root db user for db management purposes'; diff --git a/BaCa2/core/settings/templates/create_db_user.sql b/BaCa2/core/settings/templates/create_db_user.sql new file mode 100644 index 00000000..ec4cf7cc --- /dev/null +++ b/BaCa2/core/settings/templates/create_db_user.sql @@ -0,0 +1,9 @@ +CREATE ROLE baca2 + LOGIN + SUPERUSER + CREATEDB + CREATEROLE + REPLICATION + PASSWORD 'zaqwsxcde'; + +GRANT postgres TO baca2 WITH ADMIN OPTION; diff --git a/BaCa2/core/settings/templates/settings.dev.py b/BaCa2/core/settings/templates/settings.dev.py new file mode 100644 index 00000000..34497d6a --- /dev/null +++ b/BaCa2/core/settings/templates/settings.dev.py @@ -0,0 +1,20 @@ +DEBUG = True + +# Disable SSL redirect +SECURE_SSL_REDIRECT = False + +# Disable broker connection +MOCK_BROKER = True + +# Disable oidc authentication +try: + AUTHENTICATION_BACKENDS.remove( # noqa: F821 + 'mozilla_django_oidc.auth.OIDCAuthenticationBackend') +except ValueError: + pass + +# Disable OIDC middleware +try: + MIDDLEWARE.remove('mozilla_django_oidc.middleware.SessionRefresh') # noqa: F821 +except ValueError: + pass diff --git a/BaCa2/core/settings/usos.py b/BaCa2/core/settings/usos.py new file mode 100644 index 00000000..6f19d42f --- /dev/null +++ b/BaCa2/core/settings/usos.py @@ -0,0 +1,15 @@ +import os + +USOS_CONSUMER_KEY = os.getenv('USOS_CONSUMER_KEY') +USOS_CONSUMER_SECRET = os.getenv('USOS_CONSUMER_SECRET') +USOS_GATEWAY = os.getenv('USOS_GATEWAY') +USOS_SCOPES = [ + 'offline_access', + 'crstests', + 'email', + 'grades', + 'grades_write', + 'mailclient', + 'other_emails', + 'staff_perspective', +] diff --git a/BaCa2/package/packages/1/tests/set0/3.in b/BaCa2/core/tools/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set0/3.in rename to BaCa2/core/tools/__init__.py diff --git a/BaCa2/core/tools/collections.py b/BaCa2/core/tools/collections.py new file mode 100644 index 00000000..155f54c8 --- /dev/null +++ b/BaCa2/core/tools/collections.py @@ -0,0 +1,22 @@ +from typing import Collection + + +def deep_update(base_dict, update_with): + for key, value in update_with.items(): + if isinstance(value, dict): + base_dict_value = base_dict.get(key) + if isinstance(base_dict_value, dict): + deep_update(base_dict_value, value) + else: + base_dict[key] = value + else: + base_dict[key] = value + + return base_dict + + +def remove_keys(dict_: dict, keys: Collection): + for key in keys: + del dict_[key] + + return dict_ diff --git a/BaCa2/core/tools/env_settings.py b/BaCa2/core/tools/env_settings.py new file mode 100644 index 00000000..adcdfbe3 --- /dev/null +++ b/BaCa2/core/tools/env_settings.py @@ -0,0 +1,12 @@ +import os + +from .misc import yaml_coerce + + +def get_settings_from_env(prefix: str): + """Get settings from environment variables with given prefix""" + return { + key[len(prefix):]: yaml_coerce(os.getenv(key)) + for key in os.environ + if key.startswith(prefix) + } diff --git a/BaCa2/core/tools/falloff.py b/BaCa2/core/tools/falloff.py new file mode 100644 index 00000000..48152b42 --- /dev/null +++ b/BaCa2/core/tools/falloff.py @@ -0,0 +1,167 @@ +from abc import ABC, abstractmethod +from datetime import datetime + +from core.choices import FallOffPolicy + + +class FallOff(ABC): + """ + Abstract base class for different types of fall-off policies. + + :param start: The start time of the policy + :type start: datetime + :param deadline: The deadline time of the policy + :type deadline: datetime + :param end: The end time of the policy, defaults to None + :type end: datetime, optional + """ + + def __init__(self, + start: datetime, + deadline: datetime, + end: datetime = None): + self.start = start + self.deadline = deadline + if end is None: + end = deadline + self.end = end + + def is_in_deadline(self, when: datetime) -> bool: + """ + Checks if a given time is within the start and deadline of the policy. + + :param when: The time to check + :type when: datetime + :return: True if the time is within the start and deadline, False otherwise + :rtype: bool + """ + return self.start <= when <= self.deadline + + def is_in_end(self, when: datetime) -> bool: + """ + Checks if a given time is within the start and end of the policy. + + :param when: The time to check + :type when: datetime + :return: True if the time is within the deadline and end, False otherwise + :rtype: bool + """ + return self.start <= when <= self.end + + @classmethod + def __class_getitem__(cls, item: FallOffPolicy): + """ + Returns the appropriate FallOff subclass based on the given policy. + + :param item: The policy to check + :type item: FallOffPolicy + :return: The appropriate FallOff subclass + :rtype: type + :raises ValueError: If the policy is not recognized + """ + if item == FallOffPolicy.NONE: + return NoFallOff + if item == FallOffPolicy.LINEAR: + return LinearFallOff + if item == FallOffPolicy.SQUARE: + return SquareFallOff + raise ValueError(f'Unknown fall-off policy: {item}') + + @abstractmethod + def get_factor(self, when: datetime) -> float: + """ + Abstract method to get the factor at a given time. + + :param when: The time to get the factor for + :type when: datetime + :return: The factor at the given time + :rtype: float + """ + pass + + +class NoFallOff(FallOff): + """ + Class representing a fall-off policy with no fall-off. + + This class is used to define a fall-off policy with no fall-off. The `get_factor` method always + returns 1.0 if the given time is within the deadline, and 0.0 otherwise. + """ + + def get_factor(self, when: datetime) -> float: + """ + Gets the factor at a given time for a NoFallOff policy. + + :param when: The time to get the factor for + :type when: datetime + :return: The factor at the given time, 1.0 if within the deadline, 0.0 otherwise + :rtype: float + """ + if self.is_in_deadline(when): + return 1.0 + return 0.0 + + +class LinearFallOff(FallOff): + """ + Class representing a fall-off policy with linear fall-off. + """ + + def get_factor(self, when: datetime) -> float: + """ + Gets the factor at a given time for a LinearFallOff policy. + + :param when: The time to get the factor for + :type when: datetime + :return: The factor at the given time, 1.0 if within the end, 0.0 otherwise + :rtype: float + """ + if self.is_in_end(when): + return 1.0 + if self.is_in_deadline(when): + return 1.0 - (when - self.end).total_seconds() / ( + self.deadline - self.end).total_seconds() + return 0.0 + + +class SquareFallOff(FallOff): + """ + Class representing a fall-off policy with square fall-off. + """ + + def evaluate_quadratic_formula(self, when: datetime) -> float: + """ + Evaluates the quadratic formula at a given time. + + This method is used to calculate the factor for the SquareFallOff policy. + The quadratic formula used is: a * when^2 + b * when + c, where: + + - a = -1 / ((self.deadline.timestamp() - self.end.timestamp()) ** 2) + - b = -2 * a * self.end.timestamp() + - c = a * self.end.timestamp() ** 2 + 1 + + :param when: The time to evaluate the quadratic formula at + :type when: datetime + :return: The evaluated value of the quadratic formula at the given time + :rtype: float + """ + a = -1 / ((self.deadline.timestamp() - self.end.timestamp()) ** 2) + b = -2 * a * self.end.timestamp() + c = a * self.end.timestamp() ** 2 + 1 + + return a * when.timestamp() ** 2 + b * when.timestamp() + c + + def get_factor(self, when: datetime) -> float: + """ + Gets the factor at a given time for a SquareFallOff policy. + + :param when: The time to get the factor for + :type when: datetime + :return: + :rtype: float + """ + if self.is_in_end(when): + return 1.0 + if self.is_in_deadline(when): + return self.evaluate_quadratic_formula(when) + return 0.0 diff --git a/BaCa2/core/tools/files.py b/BaCa2/core/tools/files.py new file mode 100644 index 00000000..649ce0e4 --- /dev/null +++ b/BaCa2/core/tools/files.py @@ -0,0 +1,183 @@ +import csv +from pathlib import Path +from typing import List, Optional, Sequence, Tuple + +from .misc import random_id + + +class FileHandler: + """ + A class used to handle file operations such as saving and deleting. + + :param path: The directory path where the file will be saved + :type path: Path + :param extension: The extension of the file + :type extension: str + :param file_data: The data that will be written to the file + :type file_data: Any + :raises FileNotFoundError: If the path does not exist or is not a directory + """ + + class FileContentError(Exception): + """ + An exception raised when the file content is invalid. + """ + pass + + def __init__(self, path: Path, extension: str, file_data): + if not path.exists() or not path.is_dir(): + raise FileNotFoundError('The path does not exist or is not a directory') + self.file_data = file_data + self.extension = extension + self.file_id = random_id() + self.path = path / f'{self.file_id}.{extension}' + + def save(self): + """ + Saves the file data to the file path. + """ + with open(self.path, 'wb+') as file: + for chunk in self.file_data.chunks(): + file.write(chunk) + + def delete(self): + """ + Deletes the file from the file path. + """ + self.path.unlink() + + +class CsvFileHandler(FileHandler): + def __init__(self, path: Path, file_data, fieldnames: Optional[List[str]] = None): + super().__init__(path, 'csv', file_data) + self.fieldnames = fieldnames + self.data = None + + def read_csv(self, + force_fieldnames: Optional[List[str]] = None, + restkey: Optional[str] = 'restkey', + ignore_first_line: bool = False) -> Tuple[Sequence[str], List[dict]]: + """ + Reads the csv file and returns the data as a list of dictionaries. + + :param force_fieldnames: The field names of the csv file, if not provided the first row of + the csv file will be used as the field names. + :type force_fieldnames: Optional[List[str]] + :param restkey: The key used to collect all the non-matching fields. Default is 'restkey'. + :type restkey: Optional[str] + :param ignore_first_line: If True, the first line of the csv file will be ignored. + :type ignore_first_line: bool + + :return: A tuple containing the field names and the data as list of dictionaries. + :rtype: Tuple[List[str], List[dict]] + """ + with open(self.path, newline='', encoding='utf-8') as csvfile: + dialect = csv.Sniffer().sniff(csvfile.read(1024)) + csvfile.seek(0) + + if force_fieldnames and ignore_first_line: + csvfile.readline() + + reader = csv.DictReader(csvfile, + restkey=restkey, + fieldnames=force_fieldnames, + dialect=dialect) + fieldnames = reader.fieldnames + data = [row for row in reader] + + self.data = data + self.fieldnames = fieldnames + return fieldnames, data + + def validate(self) -> None: + """ + Validates the csv file. + + + """ + if not self.data: + raise self.FileContentError('The csv file has not been read yet') + for row in self.data: + for field_name in self.fieldnames: + if field_name not in row: + raise self.FileContentError( + f'The field {field_name} is missing in the csv file (row: {row})') + +class DocFileHandler: + """ + A class used to handle document file operations such as saving and deleting. + + :param path: The file path of the document + :type path: Path + :param extension: The extension of the document file + :type extension: str + :param file_id: The unique identifier of the file, if not provided a random id will be generated + :type file_id: str, optional + :raises FileNotFoundError: If the path does not exist or is not a file + """ + + class FileNotStaticError(Exception): + """ + An exception raised when the file is not saved in a static directory. + """ + pass + + def __init__(self, path: Path, extension: str, file_id: str = None): + if not (path.exists() and path.is_file()): + raise FileNotFoundError('The path does not exist or is not a file') + self.path = path + self.extension = extension + if file_id: + self.file_id = file_id + else: + self.file_id = random_id() + + @classmethod + def check_if_path_in_docs(cls, path: Path) -> bool: + """ + Checks if the document file is saved in a static directory. + + :param path: The path to the file + :type path: Path + :return: True if the file is saved in a static directory, False otherwise + :rtype: bool + """ + from django.conf import settings + + if settings.TASK_DESCRIPTIONS_DIR in path.parents: + return True + return False + + @classmethod + def delete_doc(cls, path: Path) -> None: + """ + Deletes the document file from a static directory. + + :param path: The path to the file + :type path: Path + :raises FileNotStaticError: If the file is not saved in a static directory + """ + if not cls.check_if_path_in_docs(path): + raise cls.FileNotStaticError('The file is not saved in a static directory') + + if path.exists(): + path.unlink() + + def save_as_static(self) -> Path: + """ + Saves the document file to a static directory. + + :return: The path to the saved file + :rtype: Path + """ + from django.conf import settings + + static_path = settings.TASK_DESCRIPTIONS_DIR / f'{self.file_id}.{self.extension}' + while static_path.exists(): + self.file_id = random_id() + static_path = settings.TASK_DESCRIPTIONS_DIR / f'{self.file_id}.{self.extension}' + + with open(self.path, 'rb') as file: + with open(static_path, 'wb+') as static_file: + static_file.write(file.read()) + return static_path diff --git a/BaCa2/core/tools/logs.py b/BaCa2/core/tools/logs.py new file mode 100644 index 00000000..180ec81c --- /dev/null +++ b/BaCa2/core/tools/logs.py @@ -0,0 +1,303 @@ +import logging +import pathlib +from typing import Dict + + +class CustomFormatter(logging.Formatter): + """ + Custom formatter for BaCa2 logs. Adds levelname, pathname, and funcName formatting. + """ + + def __init__(self, + fmt: str, + const_levelname_width: bool = True, + levelname_width: int = 10, + dot_pathname: bool = True, + **kwargs) -> None: + """ + :param fmt: format string + :type fmt: str + :param const_levelname_width: whether to use constant levelname width + :type const_levelname_width: bool + :param levelname_width: constant levelname width, default is 10 + :type levelname_width: int + :param dot_pathname: whether to replace path separators with dots + :type dot_pathname: bool + """ + if not hasattr(self, 'funcName_after_pathname'): + self.funcName_after_pathname = self.funcname_after_pathname(fmt) + + fmt = self.format_lineno(fmt) + + self.const_levelname_width = const_levelname_width + self.levelname_width = levelname_width + self.dot_pathname = dot_pathname + + super().__init__(fmt=fmt, style='%', **kwargs) + + @staticmethod + def funcname_after_pathname(fmt: str) -> bool: + """ + :param fmt: format string + :type fmt: str + :return: whether the funcName is placed after the pathname in the format string + :rtype: bool + """ + if '%(pathname)s' in fmt and '%(funcName)s' in fmt: + p_index = fmt.find('%(pathname)s') + f_index = p_index + len('%(pathname)s') + return fmt.startswith('%(funcName)s', f_index) + else: + return False + + def format_lineno(self, fmt: str) -> str: + """ + :param fmt: format string + :type fmt: str + :return: format string with modified lineno format + :rtype: str + """ + return fmt.replace('%(lineno)d', '%(lineno)d::') + + def format(self, record) -> str: # noqa: A003 + """ + Format the levelname, pathname, and funcName in the log record. Restore the original + values after generating the log message. + + :param record: log record + :type record: logging.LogRecord + :return: formatted log message + :rtype: str + """ + levelname = record.levelname + pathname = record.pathname + funcname = record.funcName + self.format_levelname(record) + self.format_pathname(record) + self.format_funcname(record) + out = super().format(record) + record.levelname = levelname + record.pathname = pathname + record.funcName = funcname + return out + + def format_funcname(self, record) -> None: + """ + Formats the funcName in the log record. + + :param record: log record + :type record: logging.LogRecord + """ + record.funcName = f'{record.funcName}:' + + def format_pathname(self, record) -> None: + """ + Formats the pathname in the log record. Relativaize the pathname to the BASE_DIR or the + django directory, and replace path separators with dots if dot_pathname is True. + + :param record: log record + :type record: logging.LogRecord + """ + from django.conf import settings + + pathname = pathlib.Path(record.pathname) + + if 'django' in pathname.parts: + pathname = pathname.relative_to(pathname.parents[~(pathname.parts.index('django') - 1)]) + else: + pathname = pathname.relative_to(settings.BASE_DIR) + + pathname = str(pathname.with_suffix('')) + + if self.dot_pathname: + pathname = self.format_dot_pathname(pathname) + + record.pathname = pathname + + def format_dot_pathname(self, pathname: str) -> str: + """ + :param pathname: pathname + :type pathname: str + :return: pathname with path separators replaced by dots + :rtype: str + """ + pathname = pathname.replace('\\', '.').replace('/', '.') + + if self.funcName_after_pathname: + pathname = f'{pathname}.' + + return pathname + + def format_levelname(self, record) -> None: + """ + Formats the levelname in the log record. Adds square brackets around the levelname and + pads it to the levelname_width if const_levelname_width is True. + + :param record: log record + :type record: logging.LogRecord + """ + record.levelname = f'[{record.levelname}]' + + if self.const_levelname_width: + record.levelname = record.levelname.ljust(self.levelname_width) + + +class CustomColoredFormatter(CustomFormatter): + """ + Custom formatter for BaCa2 logs. Adds levelname and pathname formatting and allows for fully + customizable log coloring. + """ + + #: names of all allowed record attributes + RECORD_ATTRS = ('%(name)s', '%(levelno)s', '%(levelname)s', '%(pathname)s', '%(filename)s', + '%(module)s', '%(lineno)d', '%(funcName)s', '%(created)f', '%(asctime)s', + '%(msecs)d', '%(relativeCreated)d', '%(thread)d', '%(threadName)s', + '%(process)d', '%(message)s') + + #: default colors for log elements. Can be overridden by passing a custom colors_dict to the + #: formatter constructor. The keys are the names of the record attributes and levels, as well as + #: 'BRACES' for the square brackets around the levelname, 'DEFAULT' for the default color to use + #: on elements that do not have specific color assigned, 'RESET' for the reset color code, and + #: 'SPECIAL' used for special characters when formatting the log message. + COLORS = { + 'DEFAULT': '\033[97m', # White + 'DEBUG': '\033[94m', # Blue + 'INFO': '\033[92m', # Green + 'WARNING': '\033[93m', # Yellow + 'ERROR': '\033[91m', # Red + 'CRITICAL': '\033[101m\033[30m', # White text on red background + 'BRACES': '\033[37m', # Gray + 'RESET': '\033[0m', # Reset + 'SPECIAL': '\033[95m', # Purple + '%(asctime)s': '\033[32m', # Green + '%(pathname)s': '\033[37m', # Gray + '%(funcName)s': '\033[37m', # Gray + '%(lineno)d': '\033[36m', # Cyan + '%(name)s': '\033[37m', # Gray + } + + def __init__(self, + fmt: str, + colors_dict: Dict[str, str] = None, + const_levelname_width: bool = True, + levelname_width: int = 10, + dot_pathname: bool = True, + **kwargs) -> None: + """ + :param fmt: format string + :type fmt: str + :param colors_dict: custom colors for log elements + :type colors_dict: dict + :param const_levelname_width: whether to use constant levelname width + :type const_levelname_width: bool + :param levelname_width: constant levelname width, default is 10 + :type levelname_width: int + :param dot_pathname: whether to replace path separators with dots + :type dot_pathname: bool + """ + self.funcName_after_pathname = self.funcname_after_pathname(fmt) + self.colors_dict = CustomColoredFormatter.COLORS + + if colors_dict: + self.colors_dict.update(colors_dict) + + fmt = self.add_colors(fmt) + + super().__init__(fmt=fmt, + const_levelname_width=const_levelname_width, + levelname_width=levelname_width, + dot_pathname=dot_pathname, **kwargs) + + def add_colors(self, fmt: str) -> str: + """ + Add colors to the log elements in the format string according to the colors_dict. + + :param fmt: format string + :type fmt: str + :return: format string with colors + :rtype: str + """ + reset = self.colors_dict.get('RESET', '') + default = self.colors_dict.get('DEFAULT', reset) + + for attr in CustomColoredFormatter.RECORD_ATTRS: + if attr in fmt: + color = self.colors_dict.get(attr, default) + fmt = fmt.replace(attr, f'{color}{attr}{reset}') + + return fmt + + def special_symbol(self, symbol: str, following_element: str = '') -> str: + """ + Add color to a special symbol used in the log message formatting. + + :param symbol: special symbol + :type symbol: str + :param following_element: log message element following the special symbol + (e.g. '%(message)s'), its color will be added following the special symbol (optional) + :type following_element: str + :return: special symbol with color + :rtype: str + """ + reset = self.colors_dict.get('RESET', '') + default = self.colors_dict.get('DEFAULT', reset) + special_color = self.colors_dict.get('SPECIAL', default) + symbol = f'{special_color}{symbol}{reset}' + + if following_element: + following_color = self.colors_dict.get(following_element, default) + return f'{symbol}{following_color}' + + return symbol + + def format_lineno(self, fmt: str) -> str: + """ + :param fmt: format string + :type fmt: str + :return: format string with modified lineno format + :rtype: str + """ + return fmt.replace('%(lineno)d', f'%(lineno)d{self.special_symbol("::")}') + + def format_funcname(self, record) -> None: + """ + Formats the funcName in the log record. + + :param record: log record + :type record: logging.LogRecord + """ + record.funcName = f'{record.funcName}{self.special_symbol(":")}' + + def format_dot_pathname(self, pathname: str) -> str: + """ + :param pathname: pathname + :type pathname: str + :return: pathname with path separators replaced by dots + :rtype: str + """ + dot = self.special_symbol('.', '%(pathname)s') + pathname = pathname.replace('\\', dot).replace('/', dot) + + if self.funcName_after_pathname: + dot = self.special_symbol('.') + pathname = f'{pathname}{dot}' + + return pathname + + def format_levelname(self, record) -> None: + """ + Formats the levelname in the log record. Adds square brackets around the levelname and + pads it to the levelname_width if const_levelname_width is True. + + :param record: log record + :type record: logging.LogRecord + """ + added_width = 0 + r = self.colors_dict.get('RESET', '') + color = self.colors_dict.get(record.levelname, r) + braces_color = self.colors_dict.get('BRACES', '') + added_width += len(color) + len(r) * 3 + len(braces_color) * 2 + record.levelname = f'{braces_color}[{r}{color}{record.levelname}{r}{braces_color}]{r}' + + if self.const_levelname_width: + record.levelname = record.levelname.ljust(self.levelname_width + added_width) diff --git a/BaCa2/core/tools/mailer.py b/BaCa2/core/tools/mailer.py new file mode 100644 index 00000000..42b93b3a --- /dev/null +++ b/BaCa2/core/tools/mailer.py @@ -0,0 +1,163 @@ +import mimetypes +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Any, Dict, Iterable + +from django.conf import settings +from django.core.mail import EmailMessage, EmailMultiAlternatives +from django.template import TemplateDoesNotExist +from django.template.loader import render_to_string +from django.utils.translation import gettext_lazy as _ + + +class Mailer(ABC): + """ + Abstract base class for a mailer. + + :param mail_to: The recipient(s) of the email. Can be a string (for a single recipient) or an + iterable of strings (for multiple recipients). + :type mail_to: str | Iterable[str] + :param subject: The subject of the email. + :type subject: str + :raises TypeError: If mail_to is not a string or an iterable of strings. + """ + + class MailNotSent(Exception): + """Exception raised when an email fails to send.""" + pass + + def __init__(self, + mail_to: str | Iterable[str], + subject: str, + ignore_errors: bool = False): + """ + Initialize the Mailer object. + + :param mail_to: The recipient(s) of the email. + :type mail_to: str | Iterable[str] + :param subject: The subject of the email. + :type subject: str + :param ignore_errors: If True, ignore errors when sending the email. + :type ignore_errors: bool + """ + if isinstance(mail_to, str): + mail_to = [mail_to] + self.mail_to = mail_to + self.subject = subject + self.ignore_errors = ignore_errors + + @abstractmethod + def send(self) -> None: + """ + Abstract method for sending an email. + + :raises NotImplementedError: Always (this method should be overridden by subclasses). + """ + raise NotImplementedError('You must implement this method in your subclass.') + + +class TemplateMailer(Mailer): + """ + Mailer that uses a Django template for the email content. + + :param mail_to: The recipient(s) of the email. Can be a string (for a single recipient) or an + iterable of strings (for multiple recipients). + :type mail_to: str | Iterable[str] + :param subject: The subject of the email. + :type subject: str + :param template: The path to the Django template to use for the email content. + :type template: str + :param context: The context to render the template with. + :type context: dict + :raises TypeError: If mail_to is not a string or an iterable of strings. + """ + + #: The footer note to be added to the email. + FOOTER_NOTE = _('Email sent automatically by BaCa2. Please do not reply.') + + def __init__(self, + mail_to: str | Iterable[str], + subject: str, + template: str, + ignore_errors: bool = False, + attachments: Dict[str, Path] = None, + context: Dict[str, Any] = None, + add_footer: bool = True): + """ + Initialize the TemplateMailer object. + + :param mail_to: The recipient(s) of the email. + :type mail_to: str | Iterable[str] + :param subject: The subject of the email. + :type subject: str + :param template: The path to the Django template to use for the email content. + :type template: str + :param ignore_errors: If True, ignore errors when sending the email. + :type ignore_errors: bool + :param attachments: A dictionary of attachments to add to the email. The keys are the names + of the attachments, and the values are the paths to the files. + :type attachments: Dict[str, Path] + :param context: The context to render the template with. + :type context: Dict[str, Any] + :param add_footer: If True, add a footer to the email. + :type add_footer: bool + """ + super().__init__(mail_to, subject, ignore_errors) + self.template = f'mail/{template}' + if context is None: + context = {} + self.context = context + if attachments is None: + attachments = {} + self.attachments = attachments + if add_footer: + self.context['footer_note'] = self.FOOTER_NOTE + + @staticmethod + def add_file(email: EmailMessage, file: Path, name: str) -> None: + """ + Adds a file to the email as an attachment. + + :param email: The email to add the attachment to. + :type email: EmailMessage + :param file: The path to the file to attach. + :type file: Path + :param name: The name of the attachment. + :type name: str + """ + if not file.exists() or not file.is_file(): + raise FileNotFoundError(f'File {file} does not exist.') + + with open(file, 'rb') as f: + email.attach(name + file.suffix, f.read(), mimetypes.guess_type(file)[0]) + + def send(self) -> bool: + """ + Sends an email using a Django template for the content. + + :raises MailNotSent: If the email fails to send. + """ + try: + template_loc = f'{self.template}_{settings.LANGUAGE_CODE}.html' + html_content = render_to_string(template_loc, self.context) + except TemplateDoesNotExist: + template_loc = f'{self.template}.html' + html_content = render_to_string(template_loc, self.context) + + email = EmailMultiAlternatives( + subject=self.subject, + from_email=settings.DEFAULT_FROM_EMAIL, + to=self.mail_to + ) + email.attach_alternative(html_content, 'text/html') + email.content_subtype = 'html' + + for name, file in self.attachments.items(): + self.add_file(email, file, name) + + res = email.send(fail_silently=True) + + if res == 0 and not self.ignore_errors: + raise self.MailNotSent('Email cannot be sent.') + + return res != 0 diff --git a/BaCa2/core/tools/misc.py b/BaCa2/core/tools/misc.py new file mode 100644 index 00000000..85f11b17 --- /dev/null +++ b/BaCa2/core/tools/misc.py @@ -0,0 +1,47 @@ +import uuid +from random import choice +from threading import Lock +from typing import Tuple + +import yaml + +random_id_access_lock = Lock() + + +def random_string(length: int, array): + return ''.join(choice(array) for _ in range(length)) + + +def yaml_coerce(value): + if isinstance(value, str): + return yaml.load('dummy: ' + value, Loader=yaml.SafeLoader)['dummy'] + + return value + + +def random_id(): + with random_id_access_lock: + return str(uuid.uuid4()) + + +def str_to_datetime(date_str: str, dt_format: str = None): + from datetime import datetime + + from django.conf import settings + from django.utils import timezone + + if not dt_format: + dt_format = settings.DATETIME_FORMAT + + result = datetime.strptime(date_str, dt_format) + result = timezone.make_aware(result, timezone.get_current_timezone()) + + return result + + +def try_getting_name_from_email(email: str) -> Tuple[str, str]: + prefix = email.split('@')[0] + try: + return prefix.split('.')[0], prefix.split('.')[1] + except ValueError: + return prefix, '' diff --git a/BaCa2/core/tools/path_creator.py b/BaCa2/core/tools/path_creator.py new file mode 100644 index 00000000..7d6d8ec2 --- /dev/null +++ b/BaCa2/core/tools/path_creator.py @@ -0,0 +1,90 @@ +import shutil +from abc import ABC, abstractmethod +from pathlib import Path + + +class PathCreator: + class _Path(ABC): + def __init__(self, path: Path, overwrite: bool = False): + self.path = path + self.overwrite = overwrite + + def __str__(self): + return str(self.path) + + @abstractmethod + def type_check(self) -> bool: + pass + + @abstractmethod + def create(self): + pass + + class Dir(_Path): + def type_check(self) -> bool: + return self.path.is_dir() + + def create(self): + if not self.path.exists(): + self.path.mkdir(parents=True) + elif not self.type_check(): + raise FileExistsError(f'Path {self.path} exists but is not a directory') + elif self.overwrite: + shutil.rmtree(self.path) + self.path.mkdir(parents=True) + + class File(_Path): + def type_check(self) -> bool: + return self.path.is_file() + + def create(self): + if not self.path.exists(): + self.path.touch() + elif not self.type_check(): + raise FileExistsError(f'Path {self.path} exists but is not a file') + elif self.overwrite: + self.path.unlink() + self.path.touch() + + class AssertExist(_Path): + def __init__(self, path_: 'PathCreator._Path'): + self._path = path_ + + def type_check(self) -> bool: + return self._path.type_check() + + def create(self): + if not self._path.path.exists(): + raise FileNotFoundError(f'Path {self._path} does not exist') + if not self.type_check(): + raise FileNotFoundError(f'Path {self._path} exists but is not ' + f'a {self._path.__class__.__name__}') + + def __init__(self): + self._auto_create_dirs = [] + self._auto_assertions = [] + + def add_dir(self, path: Path, overwrite: bool = False): + self._auto_create_dirs.append(self.Dir(path, overwrite)) + + def add_file(self, path: Path, overwrite: bool = False): + self._auto_create_dirs.append(self.File(path, overwrite)) + + def assert_exists(self, path_with_type: 'PathCreator._Path', instant: bool = False): + exist_tester = self.AssertExist(path_with_type) + if instant: + exist_tester.create() + else: + self._auto_assertions.append(exist_tester) + + def assert_exists_dir(self, path: Path, instant: bool = False): + self.assert_exists(self.Dir(path), instant) + + def assert_exists_file(self, path: Path, instant: bool = False): + self.assert_exists(self.File(path), instant) + + def create(self): + for p in self._auto_create_dirs: + p.create() + for a in self._auto_assertions: + a.create() diff --git a/BaCa2/package/packages/1/tests/set0/3.out b/BaCa2/core/tools/tests/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set0/3.out rename to BaCa2/core/tools/tests/__init__.py diff --git a/BaCa2/core/tools/tests/test_falloff.py b/BaCa2/core/tools/tests/test_falloff.py new file mode 100644 index 00000000..0e56b1cb --- /dev/null +++ b/BaCa2/core/tools/tests/test_falloff.py @@ -0,0 +1,192 @@ +from datetime import datetime + +from django.test import TestCase + +from BaCa2.core.tools.falloff import * + +from parameterized import parameterized + + +class TestFallOff(TestCase): + + def setUp(self): + self.start = datetime(2022, 1, 1) + self.deadline = datetime(2022, 1, 10) + self.end = datetime(2022, 1, 5) + + def test_no_fall_off_within_deadline(self): + fall_off = FallOff[FallOffPolicy.NONE](self.start, self.deadline, self.end) + when = datetime(2022, 1, 7) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_linear_fall_off_within_end(self): + fall_off = FallOff[FallOffPolicy.LINEAR](self.start, self.deadline, self.end) + when = datetime(2022, 1, 3) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_no_fall_off_outside_deadline(self): + fall_off = FallOff[FallOffPolicy.NONE](self.start, self.deadline, self.end) + when = datetime(2022, 1, 15) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 0.0) + + def test_linear_fall_off_outside_end_and_deadline(self): + fall_off = FallOff[FallOffPolicy.LINEAR](self.start, self.deadline, self.end) + when = datetime(2022, 1, 15) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 0.0) + + def test_square_fall_off_within_end_date(self): + square_fall_off = FallOff[FallOffPolicy.SQUARE](self.start, self.deadline, self.end) + when = datetime(2022, 1, 4) + factor = square_fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_unrecognized_fall_off_policy(self): + with self.assertRaises(ValueError): + # noinspection PyTypeChecker + _ = FallOff['UNKNOWN'] + + def test_linear_fall_off_get_factor_between_end_and_deadline(self): + fall_off = FallOff[FallOffPolicy.LINEAR](self.start, self.deadline, self.end) + when = datetime(2022, 1, 7) + factor = fall_off.get_factor(when) + self.assertGreaterEqual(factor, 0.0) + self.assertLessEqual(factor, 1.0) + + def test_square_fall_off_get_factor_between_end_and_deadline(self): + square_fall_off = FallOff[FallOffPolicy.SQUARE](self.start, self.deadline, self.end) + when = datetime(2022, 1, 7) + factor = square_fall_off.get_factor(when) + self.assertGreaterEqual(factor, 0.0) + self.assertLessEqual(factor, 1.0) + + @parameterized.expand([ + ('none', FallOffPolicy.NONE), + ('linear', FallOffPolicy.LINEAR), + ('square', FallOffPolicy.SQUARE), + ]) + def test_without_end(self, _name, fall_off_policy): + fall_off = FallOff[fall_off_policy](self.start, self.deadline) + when = datetime(2022, 1, 7) + factor = fall_off.get_factor(when) + self.assertGreater(factor, 0.0) + self.assertLessEqual(factor, 1.0) + + when = datetime(2022, 1, 3) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + +class TestNoFallOff(TestCase): + + def setUp(self): + self.start = datetime(2022, 1, 1) + self.deadline = datetime(2022, 1, 10) + + def test_within_deadline(self): + no_fall_off = NoFallOff(self.start, self.deadline) + within_deadline = datetime(2022, 1, 5) + self.assertEqual(no_fall_off.get_factor(within_deadline), 1.0) + + def test_outside_deadline(self): + no_fall_off = NoFallOff(self.start, self.deadline) + outside_deadline = datetime(2022, 1, 15) + self.assertEqual(no_fall_off.get_factor(outside_deadline), 0.0) + + def test_equal_start_time(self): + no_fall_off = NoFallOff(self.start, self.deadline) + self.assertEqual(no_fall_off.get_factor(self.start), 1.0) + + def test_equal_deadline(self): + no_fall_off = NoFallOff(self.start, self.deadline) + self.assertEqual(no_fall_off.get_factor(self.deadline), 1.0) + + def test_returns_zero_when_time_before_start(self): + no_fall_off = NoFallOff(self.start, self.deadline) + time_before_start = datetime(2021, 12, 31) + factor = no_fall_off.get_factor(time_before_start) + self.assertEqual(factor, 0.0) + + +class TestLinearFallOff(TestCase): + + def setUp(self): + self.start = datetime(2022, 1, 1) + self.deadline = datetime(2022, 1, 10) + self.end = datetime(2022, 1, 5) + + def test_returns_1_if_within_end(self): + fall_off = LinearFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 4) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_returns_0_if_outside_start_and_end(self): + fall_off = LinearFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 11) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 0.0) + + def test_returns_1_if_same_as_start_time(self): + fall_off = LinearFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 1) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_returns_0_if_same_as_deadline(self): + fall_off = LinearFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 10) + factor = fall_off.get_factor(when) + self.assertEqual(factor, 0.0) + + @parameterized.expand([ + (datetime(2022, 1, 8), 0.0, 0.5), + (datetime(2022, 1, 6), 0.5, 1.0), + ]) + def test_linear_fall_off_behaviour(self, when, lower_bound, upper_bound): + fall_off = LinearFallOff(self.start, self.deadline, self.end) + factor = fall_off.get_factor(when) + self.assertGreaterEqual(factor, lower_bound) + self.assertLessEqual(factor, upper_bound) + + +class TestSquareFallOff(TestCase): + + def setUp(self): + self.start = datetime(2022, 1, 1) + self.deadline = datetime(2022, 1, 10) + self.end = datetime(2022, 1, 5) + + def test_returns_1_if_within_end_time(self): + square_fall_off = SquareFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 3) + factor = square_fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_returns_0_if_outside_deadline_and_end_time(self): + square_fall_off = SquareFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 15) + factor = square_fall_off.get_factor(when) + self.assertEqual(factor, 0.0) + + def test_returns_1_if_equal_to_start_time(self): + square_fall_off = SquareFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 1) + factor = square_fall_off.get_factor(when) + self.assertEqual(factor, 1.0) + + def test_returns_0_if_equal_to_deadline_time(self): + square_fall_off = SquareFallOff(self.start, self.deadline, self.end) + when = datetime(2022, 1, 10) + factor = square_fall_off.get_factor(when) + self.assertAlmostEqual(factor, 0.0, 5) + + def test_behaviour_between_end_and_deadline(self): + square_fall_off = SquareFallOff(self.start, self.deadline, self.end) + halfway_point = self.end + (self.deadline - self.end) / 2 + factor = square_fall_off.get_factor(halfway_point) + self.assertGreaterEqual(factor, 0.6) + self.assertLessEqual(factor, 1.0) diff --git a/BaCa2/core/uj_oidc_auth.py b/BaCa2/core/uj_oidc_auth.py new file mode 100644 index 00000000..2a500874 --- /dev/null +++ b/BaCa2/core/uj_oidc_auth.py @@ -0,0 +1,95 @@ +import logging + +from core.choices import UserJob +from main.models import User +from mozilla_django_oidc.auth import OIDCAuthenticationBackend + +logger = logging.getLogger(__name__) + + +class BaCa2UJAuth(OIDCAuthenticationBackend): + """ + This class is a custom authentication backend for the BaCa2 application. + It extends the OIDCAuthenticationBackend from the mozilla_django_oidc library. + """ + + def filter_users_by_claims(self, claims): + """ + Filters users based on the claims provided. + + :param claims: The claims to filter users by. + :type claims: dict + :return: QuerySet of User objects that match the claims. + :rtype: QuerySet + """ + email = claims.get('email') + if not email: + return User.objects.none() + + return User.objects.filter(email=email) + + def create_user(self, claims): + """ + Creates a new user based on the claims provided. + + :param claims: The claims to create a user from. + :type claims: dict + :return: The newly created User object, or None if the user could not be created. + :rtype: User or None + """ + email = claims.get('email') + first_name = claims.get('given_name', '') + last_name = claims.get('family_name', '') + + # UJ specific + usos_id = claims.get('ujUsosID') + is_student = claims.get('ujIsStudent', False) + is_employee = claims.get('ujIsEmployee', False) + is_doctoral = claims.get('ujIsDoctoral', False) + + user_job = User.get_user_job(is_student, is_employee, is_doctoral) + + if not email: + return None + + user = User.objects.create_user( + email=email, + first_name=first_name, + last_name=last_name, + usos_id=usos_id, + user_job=user_job, + ) + logger.info( + f'Created new user: {user} ({user.first_name} {user.last_name}) [{user.user_job}]') + + return user + + def update_user(self, user, claims): + """ + Updates an existing user based on the claims provided. + + :param user: The user to update. + :type user: User + :param claims: The claims to update the user with. + :type claims: dict + :return: The updated User object. + :rtype: User + """ + user.first_name = claims.get('given_name', '') + user.last_name = claims.get('family_name', '') + + # UJ specific + usos_id = claims.get('ujUsosID') + if usos_id: + user.usos_id = usos_id + user_job = User.get_user_job( + is_student=claims.get('ujIsStudent', False), + is_employee=claims.get('ujIsEmployee', False), + is_doctoral=claims.get('ujIsDoctoral', False) + ) + # prevent accidental degradation to student + if not (user.user_job != UserJob.ST.value and user_job == UserJob.ST.value): + user.user_job = user_job + user.save() + + return user diff --git a/BaCa2/core/urls.py b/BaCa2/core/urls.py new file mode 100644 index 00000000..29e83fd7 --- /dev/null +++ b/BaCa2/core/urls.py @@ -0,0 +1,27 @@ +from django.contrib import admin +from django.contrib.staticfiles.urls import staticfiles_urlpatterns +from django.urls import include, path + +from main.views import BaCa2LoginView, BaCa2LogoutView, LoginRedirectView +from util.views import FieldValidationView + +urlpatterns = [ + path('baca/', admin.site.urls), + + # ------------------------------------- Authentication ------------------------------------- # + path('', LoginRedirectView.as_view(), name='login-redirect'), + path('login/', BaCa2LoginView.as_view(), name='login'), + path('logout/', BaCa2LogoutView.as_view(), name='logout'), + + path('oidc/', include('mozilla_django_oidc.urls')), + + # ------------------------------------------ Apps ------------------------------------------ # + path('broker_api/', include('broker_api.urls')), + path('main/', include('main.urls')), + path('course//', include('course.urls')), + + # --------------------------------------- Auxiliary ---------------------------------------- # + path('field_validation', FieldValidationView.as_view(), name='field-validation'), +] + +urlpatterns += staticfiles_urlpatterns() diff --git a/BaCa2/BaCa2/wsgi.py b/BaCa2/core/wsgi.py similarity index 74% rename from BaCa2/BaCa2/wsgi.py rename to BaCa2/core/wsgi.py index 7d1f26be..63bb0999 100644 --- a/BaCa2/BaCa2/wsgi.py +++ b/BaCa2/core/wsgi.py @@ -1,5 +1,5 @@ """ -WSGI config for BaCa2 project. +WSGI config for core project. It exposes the WSGI callable as a module-level variable named ``application``. @@ -11,6 +11,6 @@ from django.core.wsgi import get_wsgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'BaCa2.settings') +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') application = get_wsgi_application() diff --git a/BaCa2/course/admin.py b/BaCa2/course/admin.py index 39a873c2..e69de29b 100644 --- a/BaCa2/course/admin.py +++ b/BaCa2/course/admin.py @@ -1,10 +0,0 @@ -from django.contrib import admin -from .models import Task, Round, Test, TestSet, Submit, Result - -# Register your models here. -admin.site.register(Task) -admin.site.register(Round) -admin.site.register(TestSet) -admin.site.register(Submit) -admin.site.register(Result) -admin.site.register(Test) diff --git a/BaCa2/course/apps.py b/BaCa2/course/apps.py index c829e578..14dda07e 100644 --- a/BaCa2/course/apps.py +++ b/BaCa2/course/apps.py @@ -1,6 +1,12 @@ +import logging + from django.apps import AppConfig +from django.conf import settings from django.utils.translation import gettext_lazy as _ +from course.manager import resend_pending_submits + +logger = logging.getLogger(__name__) class CourseConfig(AppConfig): default_auto_field = 'django.db.models.BigAutoField' @@ -11,5 +17,10 @@ def ready(self): """ It migrates all the tables in the database when app is loaded. """ - from BaCa2.db.creator import migrateAll - migrateAll() + + settings.DB_MANAGER.migrate_all() + try: + resend_pending_submits() + except Exception as e: + logger.error(f'Error while resending submits: {e}') + pass diff --git a/BaCa2/course/manager.py b/BaCa2/course/manager.py index a3194aca..c0fbd131 100644 --- a/BaCa2/course/manager.py +++ b/BaCa2/course/manager.py @@ -1,10 +1,11 @@ -from time import sleep - -from BaCa2.db.creator import createDB, migrateDB, deleteDB import logging +from django.conf import settings + +from core.choices import ResultStatus + +logger = logging.getLogger(__name__) -# log = logging. def create_course(course_name: str): """ @@ -13,8 +14,8 @@ def create_course(course_name: str): :param course_name: The name of the course you want to create :type course_name: str """ - createDB(course_name) - migrateDB(course_name) + settings.DB_MANAGER.create_db(course_name) + settings.DB_MANAGER.migrate_db(course_name) def delete_course(course_name: str): @@ -24,4 +25,31 @@ def delete_course(course_name: str): :param course_name: The name of the course you want to delete :type course_name: str """ - deleteDB(course_name) + settings.DB_MANAGER.delete_db(course_name) + + +def resend_pending_submits(): + """ + This function resends all pending submits to broker + """ + from broker_api.models import BrokerSubmit + from main.models import Course + + from .models import Submit + from .routing import InCourse + + courses = Course.objects.all() + resent_submits = 0 + for course in courses: + with InCourse(course): + submits = Submit.objects.filter(submit_status=ResultStatus.PND) + for submit in submits: + if not BrokerSubmit.objects.filter(course=course, + submit_id=submit.pk, + ).exists(): + submit.send() + logger.debug(f'Submit {submit.pk} resent to broker') + resent_submits += 1 + + if resent_submits > 0: + logger.info(f'Resent {resent_submits} submits to broker') diff --git a/BaCa2/course/migrations/0001_initial.py b/BaCa2/course/migrations/0001_initial.py deleted file mode 100644 index 5d91a8eb..00000000 --- a/BaCa2/course/migrations/0001_initial.py +++ /dev/null @@ -1,72 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-14 13:50 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ] - - operations = [ - migrations.CreateModel( - name='Round', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('start_date', models.DateTimeField()), - ('end_date', models.DateTimeField(null=True)), - ('deadline_date', models.DateTimeField()), - ('reveal_date', models.DateTimeField(null=True)), - ], - ), - migrations.CreateModel( - name='Task', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package_instance', models.IntegerField()), - ('task_name', models.CharField(max_length=1023)), - ('judging_mode', models.CharField(choices=[('L', 'Linear'), ('U', 'Unanimous')], default='L', max_length=1)), - ('round', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.round')), - ], - ), - migrations.CreateModel( - name='TestSet', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('short_name', models.CharField(max_length=255)), - ('weight', models.FloatField()), - ], - ), - migrations.CreateModel( - name='Test', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('short_name', models.CharField(max_length=255)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.task')), - ('task_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.testset')), - ], - ), - migrations.CreateModel( - name='Submit', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('submit_date', models.DateTimeField(auto_now_add=True)), - ('source_code', models.FilePathField()), - ('user', models.IntegerField()), - ('final_score', models.FloatField(default=-1)), - ('task', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.test')), - ], - ), - migrations.CreateModel( - name='Result', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('status', models.CharField(choices=[('PND', 'Pending'), ('OK', 'Test accepted'), ('ANS', 'Wrong answer'), ('RTE', 'Runtime error'), ('MEM', 'Memory exceeded'), ('TLE', 'Time limit exceeded'), ('CME', 'Compilation error'), ('EXT', 'Unknown extension'), ('INT', 'Internal error')], default='PND', max_length=3)), - ('submit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.submit')), - ('test', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.test')), - ], - ), - ] diff --git a/BaCa2/course/migrations/0002_alter_task_judging_mode.py b/BaCa2/course/migrations/0002_alter_task_judging_mode.py deleted file mode 100644 index dcfbfaba..00000000 --- a/BaCa2/course/migrations/0002_alter_task_judging_mode.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-14 14:03 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0001_initial'), - ] - - operations = [ - migrations.AlterField( - model_name='task', - name='judging_mode', - field=models.CharField(choices=[('LIN', 'Linear'), ('UNA', 'Unanimous')], default='LIN', max_length=3), - ), - ] diff --git a/BaCa2/course/migrations/0003_rename_user_submit_usr.py b/BaCa2/course/migrations/0003_rename_user_submit_usr.py deleted file mode 100644 index de100932..00000000 --- a/BaCa2/course/migrations/0003_rename_user_submit_usr.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-14 14:09 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0002_alter_task_judging_mode'), - ] - - operations = [ - migrations.RenameField( - model_name='submit', - old_name='user', - new_name='usr', - ), - ] diff --git a/BaCa2/course/migrations/0004_alter_submit_source_code_alter_submit_task.py b/BaCa2/course/migrations/0004_alter_submit_source_code_alter_submit_task.py deleted file mode 100644 index afb7f379..00000000 --- a/BaCa2/course/migrations/0004_alter_submit_source_code_alter_submit_task.py +++ /dev/null @@ -1,24 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-14 14:15 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0003_rename_user_submit_usr'), - ] - - operations = [ - migrations.AlterField( - model_name='submit', - name='source_code', - field=models.FileField(upload_to='./submits'), - ), - migrations.AlterField( - model_name='submit', - name='task', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.task'), - ), - ] diff --git a/BaCa2/course/migrations/0005_rename_task_set_test_test_set_remove_test_task_and_more.py b/BaCa2/course/migrations/0005_rename_task_set_test_test_set_remove_test_task_and_more.py deleted file mode 100644 index cf3abb3e..00000000 --- a/BaCa2/course/migrations/0005_rename_task_set_test_test_set_remove_test_task_and_more.py +++ /dev/null @@ -1,34 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-18 17:59 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0004_alter_submit_source_code_alter_submit_task'), - ] - - operations = [ - migrations.RenameField( - model_name='test', - old_name='task_set', - new_name='test_set', - ), - migrations.RemoveField( - model_name='test', - name='task', - ), - migrations.AddField( - model_name='testset', - name='task', - field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE, to='course.task'), - preserve_default=False, - ), - migrations.AlterField( - model_name='submit', - name='source_code', - field=models.FileField(upload_to='./BaCa2/course/submits'), - ), - ] diff --git a/BaCa2/course/migrations/0006_task_points.py b/BaCa2/course/migrations/0006_task_points.py deleted file mode 100644 index 6fcd98dd..00000000 --- a/BaCa2/course/migrations/0006_task_points.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-18 19:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0005_rename_task_set_test_test_set_remove_test_task_and_more'), - ] - - operations = [ - migrations.AddField( - model_name='task', - name='points', - field=models.FloatField(default=9), - preserve_default=False, - ), - ] diff --git a/BaCa2/course/migrations/0007_alter_submit_source_code.py b/BaCa2/course/migrations/0007_alter_submit_source_code.py deleted file mode 100644 index a186faf5..00000000 --- a/BaCa2/course/migrations/0007_alter_submit_source_code.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.1.3 on 2022-12-19 21:16 - -from django.db import migrations, models -import pathlib - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0006_task_points'), - ] - - operations = [ - migrations.AlterField( - model_name='submit', - name='source_code', - field=models.FileField(upload_to=pathlib.PureWindowsPath('Q:/Dokumenty/!Studia/!Projects/BaCa2/BaCa2/submits')), - ), - ] diff --git a/BaCa2/course/migrations/0008_remove_task_package_instance_and_more.py b/BaCa2/course/migrations/0008_remove_task_package_instance_and_more.py deleted file mode 100644 index 79f4df19..00000000 --- a/BaCa2/course/migrations/0008_remove_task_package_instance_and_more.py +++ /dev/null @@ -1,36 +0,0 @@ -# Generated by Django 4.1.5 on 2023-01-26 22:18 - -from django.db import migrations, models -import main.models -import package.models -import pathlib - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0007_alter_submit_source_code'), - ] - - operations = [ - migrations.RemoveField( - model_name='task', - name='package_instance', - ), - migrations.AddField( - model_name='task', - name='package_instance_id', - field=models.BigIntegerField(default=None, validators=[package.models.PackageInstance.exists]), - preserve_default=False, - ), - migrations.AlterField( - model_name='submit', - name='source_code', - field=models.FileField(upload_to=pathlib.PurePosixPath('/home/zyndram/Dokumenty/BaCa2/BaCa2/submits')), - ), - migrations.AlterField( - model_name='submit', - name='usr', - field=models.BigIntegerField(validators=[main.models.User.exists]), - ), - ] diff --git a/BaCa2/course/migrations/0009_alter_submit_source_code.py b/BaCa2/course/migrations/0009_alter_submit_source_code.py deleted file mode 100644 index cba0f894..00000000 --- a/BaCa2/course/migrations/0009_alter_submit_source_code.py +++ /dev/null @@ -1,19 +0,0 @@ -# Generated by Django 4.1.5 on 2023-01-27 02:14 - -from django.db import migrations, models -import pathlib - - -class Migration(migrations.Migration): - - dependencies = [ - ('course', '0008_remove_task_package_instance_and_more'), - ] - - operations = [ - migrations.AlterField( - model_name='submit', - name='source_code', - field=models.FileField(upload_to=pathlib.PureWindowsPath('Q:/Dokumenty/!Studia/!Projects/BaCa2/BaCa2/submits')), - ), - ] diff --git a/BaCa2/course/models.py b/BaCa2/course/models.py index 2dcbf384..e1037dd0 100644 --- a/BaCa2/course/models.py +++ b/BaCa2/course/models.py @@ -1,27 +1,242 @@ -from typing import List +from __future__ import annotations -from django.db import models -from django.db.models import Count, QuerySet +import inspect +from datetime import datetime +from pathlib import Path +from typing import TYPE_CHECKING, Any, List, Self + +from django.conf import settings from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.db.models import Count +from django.db.models.base import ModelBase +from django.utils.timezone import now +from django.utils.translation import gettext_lazy as _ + +from baca2PackageManager import TestF, TSet +from baca2PackageManager.broker_communication import BrokerToBaca +from baca2PackageManager.tools import bytes_from_str, bytes_to_str +from core.choices import ( + EMPTY_FINAL_STATUSES, + HALF_EMPTY_FINAL_STATUSES, + FallOffPolicy, + ModelAction, + ResultStatus, + ScoreSelectionPolicy, + SubmitType, + TaskJudgingMode +) +from core.exceptions import DataError +from core.tools.falloff import FallOff +from core.tools.misc import str_to_datetime +from course.routing import InCourse, OptionalInCourse +from util.models_registry import ModelsRegistry + +if TYPE_CHECKING: + from broker_api.models import BrokerSubmit + from main.models import Course, User + from package.models import PackageInstance -from BaCa2.choices import TaskJudgingMode, ResultStatus -from BaCa2.exceptions import DataError -from BaCa2.settings import BASE_DIR +__all__ = ['Round', 'Task', 'TestSet', 'Test', 'Submit', 'Result'] -from main.models import User, Course -from package.models import PackageInstance +class ReadCourseMeta(ModelBase): + """ + Metaclass providing automated database routing for all course objects. + If object was acquired from specific database, all operations are performed inside + of this database. + """ + DECORATOR_OFF = [] -SUBMITS_DIR = BASE_DIR / 'submits' + def __new__(cls, name, bases, dct): + """ + Creates new class with the same name, base and dictionary, but wraps all non-static, + non-class methods and properties with :py:meth`read_course_decorator` -__all__ = ['Round', 'Task', 'TestSet', 'Test', 'Submit', 'Result'] + *Special method signature from* ``django.db.models.base.ModelBase`` + """ + result_class = super().__new__(cls, name, bases, dct) + + # Decorate all non-static, non-class methods with the hook method + for attr_name, attr_value in dct.items(): + if all(((callable(attr_value) or isinstance(attr_value, property)), + not attr_name.startswith('_'), + not isinstance(attr_value, classmethod), + not isinstance(attr_value, staticmethod), + not inspect.isclass(attr_value), + attr_name not in cls.DECORATOR_OFF)): + decorated_meth = cls.read_course_decorator(attr_value, + isinstance(attr_value, property)) + decorated_meth.__doc__ = attr_value.__doc__ + setattr(result_class, + attr_name, + decorated_meth) + elif isinstance(attr_value, models.Field): + dest_field = result_class.__dict__.get(attr_name) + dest_field_id = result_class.__dict__.get(f'{attr_name}_id') + setattr(result_class, f'{attr_name}_', cls.field_decorator(dest_field)) + if dest_field_id: + setattr(result_class, f'{attr_name}_id_', cls.field_decorator(dest_field_id)) + return result_class + + @staticmethod + def field_decorator(field): + """ + Decorator used to decode origin database from object. It wraps every operation inside + the object to be performed on meta-read database. + + :param field: Field to be wrapped + :type field: models.Field + + :returns: Wrapped field + """ + @property + def field_property(self): + if InCourse.is_defined(): + return field.__get__(self) + with InCourse(self._state.db): + return field.__get__(self) -class Round(models.Model): + return field_property + + @staticmethod + def read_course_decorator(original_method, prop: bool = False): + """ + Decorator used to decode origin database from object. It wraps every operation inside + the object to be performed on meta-read database. + + :param original_method: Original method to be wrapped + :param prop: Indicates if original method is a property. + :type prop: bool + + :returns: Wrapped method + + """ + + def wrapper_method(self, *args, **kwargs): + if InCourse.is_defined(): + result = original_method(self, *args, **kwargs) + else: + with InCourse(self._state.db): + result = original_method(self, *args, **kwargs) + return result + + def wrapper_property(self): + if InCourse.is_defined(): + result = original_method.fget(self) + else: + with InCourse(self._state.db): + result = original_method.fget(self) + return result + + if prop: + return property(wrapper_property) + return wrapper_method + + +class RoundManager(models.Manager): + + @transaction.atomic + def create_round(self, + start_date: datetime | str, + deadline_date: datetime | str, + name: str = None, + end_date: datetime = None, + reveal_date: datetime = None, + score_selection_policy: ScoreSelectionPolicy = ScoreSelectionPolicy.BEST, + fall_off_policy: FallOffPolicy = FallOffPolicy.SQUARE, + course: str | int | Course = None) -> Round: + """ + It creates a new round object, but validates it first. + + :param start_date: The start date of the round. + :type start_date: datetime + :param deadline_date: The deadline date of the round. + :type deadline_date: datetime + :param name: The name of the round, defaults to ``Round `` (optional) + :type name: str + :param end_date: The end date of the round, defaults to None (optional) + :type end_date: datetime + :param reveal_date: The results reveal date for the round, defaults to None (optional) + :type reveal_date: datetime + :param score_selection_policy: The policy for selecting the task score from user's submits, + defaults to ScoreSelectionPolicy.BEST (optional) + :type score_selection_policy: :class:`core.choices.ScoreSelectionPolicy` + :param fall_off_policy: Points fall-off policy for tasks in the round + (max points until deadline, linear fall-off, square fall-off), defaults to + FallOffPolicy.SQUARE (optional) + :type fall_off_policy: :class:`core.choices.FallOffPolicy` + :param course: The course that the round is in, if None - acquired from external definition + (optional) + :type course: str | int | Course + :return: A new round object. + :rtype: Round + """ + if isinstance(start_date, str): + start_date = str_to_datetime(start_date) + if isinstance(deadline_date, str): + deadline_date = str_to_datetime(deadline_date) + if isinstance(end_date, str): + end_date = str_to_datetime(end_date) + if isinstance(reveal_date, str): + reveal_date = str_to_datetime(reveal_date) + + Round.validate_dates(start_date, deadline_date, end_date) + + if name is None: + name = self.default_round_name + with OptionalInCourse(course): + new_round = self.model(start_date=start_date, + deadline_date=deadline_date, + name=name, + end_date=end_date, + reveal_date=reveal_date, + score_selection_policy=score_selection_policy, + fall_off_policy=fall_off_policy) + new_round.save() + return new_round + + @transaction.atomic + def delete_round(self, round_: int | Round, course: str | int | Course = None) -> None: + """ + It deletes a round object. + + :param round_: The round id that you want to delete. + :type round_: int + :param course: The course that the round is in, defaults to None (optional) + :type course: str | int | Course + """ + with OptionalInCourse(course): + ModelsRegistry.get_round(round_).delete() + + def all_rounds(self) -> List[Round]: + """ + It returns all the rounds. + + :return: A list of all the Round objects. + :rtype: List[Round] + """ + return list(self.all()) + + @property + def default_round_name(self) -> str: + """ + It returns the default round name. + + :return: The default round name. + :rtype: str + """ + return f'Round {self.count() + 1}' + + +class Round(models.Model, metaclass=ReadCourseMeta): """ A round is a period of time in which a tasks can be submitted. """ + #: Name of round + name = models.CharField(max_length=2047, default='') #: The date and time when the round starts. start_date = models.DateTimeField() #: The date and time when ends possibility to gain max points. @@ -30,37 +245,323 @@ class Round(models.Model): deadline_date = models.DateTimeField() #: The date and time when the round results will be visible for everyone. reveal_date = models.DateTimeField(null=True) - - @property - def validate(self): + #: The policy for selecting the task score from user's submits. + score_selection_policy = models.CharField(choices=ScoreSelectionPolicy.choices, + default=ScoreSelectionPolicy.BEST, + max_length=4) + #: Points fall-off policy for tasks in the round + #: (max points until deadline, linear fall-off, square fall-off) + fall_off_policy = models.CharField(choices=FallOffPolicy.choices, + default=FallOffPolicy.NONE, ) + + #: The manager for the Round model. + objects = RoundManager() + + RESCORE_TRIGGERS = { + 'start_date', + 'deadline_date', + 'end_date', + 'score_selection_policy', + 'fall_off_policy' + } + + class BasicAction(ModelAction): + """ + Basic actions for Round model. + """ + ADD = 'add', 'add_round' + DEL = 'delete', 'delete_round' + EDIT = 'edit', 'change_round' + VIEW = 'view', 'view_round' + + @staticmethod + def validate_dates(start_date: datetime, + deadline_date: datetime, + end_date: datetime = None) -> None: """ - If the end date is not None, then the end date must be greater than the start date and the deadline date must be - greater than the end date. If the end date is None, then the deadline date must be greater than the start date. + If the end date is not None, then the end date must be greater than the start date and the + deadline date must be greater than the end date. If the end date is None, then the + deadline date must be greater than the start date. + + :param start_date: The start date of the round. + :type start_date: datetime + :param deadline_date: The deadline date of the round. + :type deadline_date: datetime + :param end_date: The end date of the round, defaults to None (optional) + :type end_date: datetime :raise ValidationError: If validation is not successful. """ - if self.end_date is not None and (self.end_date <= self.start_date or self.deadline_date < self.end_date): - raise ValidationError("Round: End date out of bounds of start date and deadline.") - elif self.deadline_date <= self.start_date: + if end_date is not None and (end_date <= start_date or deadline_date < end_date): + raise ValidationError('Round: End date out of bounds of start date and deadline.') + elif deadline_date <= start_date: raise ValidationError("Round: Start date can't be later then deadline.") + @transaction.atomic + def update(self, **kwargs) -> None: + """ + It updates the round object. + + :param kwargs: The new values for the round object. + :type kwargs: dict + """ + rescore_planned = False + for key, value in kwargs.items(): + if key in ('start_date', 'deadline_date', 'end_date', + 'reveal_date') and isinstance(value, str): + value = str_to_datetime(value) + + if getattr(self, key) != value and key in self.RESCORE_TRIGGERS: + rescore_planned = True + + setattr(self, key, value) + self.validate_dates(self.start_date, self.deadline_date, self.end_date) + self.save() + + if rescore_planned: + self.rescore_tasks() + + @transaction.atomic + def delete(self, using: Any = None, keep_parents: bool = False): + """ + It deletes the round object. + """ + tasks = Task.objects.filter(round=self).all() + for task in tasks: + task.delete() + super().delete(using, keep_parents) + + @property + def is_open(self) -> bool: + """ + It checks if the round is open. + + :return: A boolean value. + :rtype: bool + """ + return self.start_date <= now() <= self.deadline_date + + @property + def is_started(self) -> bool: + """ + :return: True if round is started, False otherwise + :rtype: bool + """ + return self.start_date <= now() + + def get_fall_off(self) -> FallOff: + """ + :return: Fall-off object with get_factor method + :rtype: FallOff + """ + return FallOff[FallOffPolicy[self.fall_off_policy]]( + start=self.start_date, + end=self.end_date, + deadline=self.deadline_date + ) + + @property + def tasks(self) -> List[Task]: + """ + It returns all the tasks that are associated with the round + + :return: A list of all the Task objects that are associated with the Round object. + :rtype: QuerySet + """ + return list(Task.objects.filter(round=self).all()) + + @property + def round_points(self) -> float: + """ + It returns the amount of points that can be gained for completing all the tasks in the + round. + + :return: The amount of points that can be gained for completing all the tasks in the round. + """ + return sum(task.points for task in self.tasks) + + def rescore_tasks(self) -> None: + """ + Rescores all tasks for the round. + """ + for task in self.tasks: + task.rescore_submits() + def __str__(self): - return f"Round {self.pk}" + return f'Round {self.pk}: {self.name}' + + def get_data(self, add_formatted_dates: bool = False) -> dict: + """ + :param add_formatted_dates: If True, formatted dates will be added to the data, defaults to + False (optional) + :type add_formatted_dates: bool + :return: The data of the round. + :rtype: dict + """ + from widgets.navigation import SideNav + res = { + 'id': self.pk, + 'name': self.name, + 'start_date': self.start_date, + 'end_date': self.end_date, + 'deadline_date': self.deadline_date, + 'reveal_date': self.reveal_date, + 'normalized_name': SideNav.normalize_tab_name(self.name), + 'score_selection_policy': self.score_selection_policy, + } + if add_formatted_dates: + res |= { + 'f_start_date': self.start_date.strftime('%Y-%m-%d %H:%M'), + 'f_end_date': self.end_date.strftime('%Y-%m-%d %H:%M') if self.end_date else None, + 'f_deadline_date': self.deadline_date.strftime('%Y-%m-%d %H:%M'), + 'f_reveal_date': self.reveal_date.strftime( + '%Y-%m-%d %H:%M') if self.reveal_date else None + } + return res + + +class TaskManager(models.Manager): + + @transaction.atomic + def create_task(self, + package_instance: int | PackageInstance, + round_: int | Round, + task_name: str, + points: float = None, + judging_mode: str | TaskJudgingMode = TaskJudgingMode.LIN, + initialise_task: bool = True, + course: str | int | Course = None) -> Task: + """ + It creates a new task object, and (if `initialise_task` is True) initialises it using + data from `PackageManager`. + + :param package_instance: The package instance that you want to associate the task with. + :type package_instance: int | PackageInstance + :param round_: The round that you want to associate the task with. + :type round_: int | Round + :param task_name: The name of the task. + :type task_name: str + :param points: The amount of points that you can get for completing the task. + :type points: float + :param judging_mode: The judging mode of the task, defaults to + TaskJudgingMode.LIN (optional) + :type judging_mode: TaskJudgingMode + :param initialise_task: If True, the task will be initialised using data from + PackageManager, otherwise sub-objects (tasks, sets & tests) won't be created + defaults to True (optional) + :type initialise_task: bool + :param course: The course that the task is in, if None - acquired from external definition + (optional) + :type course: str | int | Course + + :return: A new task object. + :rtype: Task + """ + package_instance = ModelsRegistry.get_package_instance(package_instance) + round_ = ModelsRegistry.get_round(round_) + judging_mode = ModelsRegistry.get_task_judging_mode(judging_mode) + + if points is None: + points = package_instance.package['points'] + with OptionalInCourse(course): + new_task = self.model(package_instance_id=package_instance.pk, + task_name=task_name, + round=round_, + judging_mode=judging_mode, + points=points) + new_task.save() + if initialise_task: + new_task.initialise_task() + return new_task + + @transaction.atomic + def update_task(self, + task: int | Task, + new_package_instance: int | PackageInstance, + **kwargs) -> Task: + """ + It creates a new task object from the old one, and initialises it using data from + `PackageManager`. Old task is marked as legacy and new task is returned. + :param task: Old task that will be updated + :type task: int | Task + :param new_package_instance: New package instance that will be used to update the task + :type new_package_instance: int | PackageInstance -class Task(models.Model): + :return: A new task object. + :rtype: Task + """ + + old_task = ModelsRegistry.get_task(task) + new_package_instance = ModelsRegistry.get_package_instance(new_package_instance) + + new_task = self.model( + package_instance_id=new_package_instance.pk, + task_name=kwargs.get('task_name', old_task.task_name), + round=kwargs.get('round', old_task.round), + judging_mode=kwargs.get('judging_mode', old_task.judging_mode), + points=kwargs.get('points', old_task.points) + ) + new_task.save() + new_task.initialise_task() + + old_task.updated_task = new_task + old_task.is_legacy = True + old_task.save() + + return new_task + + @transaction.atomic + def delete_task(self, + task: int | Task, + with_ancestors: bool = True, + course: str | int | Course = None) -> None: + """ + It deletes a task object (with all the test sets and tests that are associated with it). + + :param task: The task id that you want to delete. + :type task: int + :param with_ancestors: If True, all legacy ancestors of the task will be deleted as well, + defaults to True (optional) + :type with_ancestors: bool + :param course: The course that the task is in, if None - acquired from external definition + (optional) + :type course: str | int | Course + """ + task = ModelsRegistry.get_task(task, course) + with OptionalInCourse(course): + task.delete(with_ancestors=with_ancestors) + + @staticmethod + def package_instance_exists_validator(package_instance: int) -> bool: + """ + It checks if a package instance exists. + + :param package_instance: The package instance id that you want to check. + :type package_instance: int | PackageInstance + + :return: A boolean value. + :rtype: bool + """ + from package.models import PackageInstance + return PackageInstance.objects.exists_validator(package_instance) + + +class Task(models.Model, metaclass=ReadCourseMeta): """ - It represents a task that user can submit a solution to. The task is judged and scored automatically + It represents a task that user can submit a solution to. The task is judged and scored + automatically """ #: Pseudo-foreign key to package instance. - package_instance_id = models.BigIntegerField(validators=[PackageInstance.exists]) + package_instance_id = models.BigIntegerField( + validators=[TaskManager.package_instance_exists_validator]) #: Represents displayed task name task_name = models.CharField(max_length=1023) #: Foreign key to round, which task is assigned to. - round = models.ForeignKey(Round, on_delete=models.CASCADE) - #: Judging mode as choice from BaCa2.choices.TaskJudgingMode (enum-type choice) + round = models.ForeignKey(Round, on_delete=models.CASCADE) # noqa: A003 + #: Judging mode as choice from core.choices.TaskJudgingMode (enum-type choice) judging_mode = models.CharField( max_length=3, choices=TaskJudgingMode.choices, @@ -68,87 +569,340 @@ class Task(models.Model): ) #: Maximum amount of points to be earned by completing this task. points = models.FloatField() + #: When task is updated, new instance is created - old submits have to be supported. + #: Submit.update gives access to rejudging with the newest task version + updated_task = models.ForeignKey( + to='course.Task', + on_delete=models.SET_NULL, + null=True, + default=None + ) + #: Indicates if task is legacy task, which is used only to support legacy submits + is_legacy = models.BooleanField(default=False) + + #: The manager for the Task model. + objects = TaskManager() + + RESCORE_TRIGGERS = {'judging_mode', 'points', 'round'} + + class BasicAction(ModelAction): + """ + Basic actions for Task model. + """ + ADD = 'add', 'add_task' + DEL = 'delete', 'delete_task' + EDIT = 'edit', 'change_task' + VIEW = 'view', 'view_task' def __str__(self): - return f"Task {self.pk}: {self.task_name}; Judging mode: {TaskJudgingMode[self.judging_mode].label};" \ - f"Package: {self.package_instance}" + return (f'Task {self.pk}: {self.task_name}; ' + f'Judging mode: {TaskJudgingMode[self.judging_mode].label}; ' + f'Package: {self.package_instance}') - @classmethod - def create_new(cls, **kwargs) -> 'Task': + def get_fall_off(self) -> FallOff: + """ + :return: Fall-off object with get_factor method + :rtype: FallOff + """ + return self.round.get_fall_off() + + @property + def is_open(self): + """ + :return: True if task is open now, False otherwise + :rtype: bool + """ + return self.round.is_open + + @property + def is_started(self): + """ + :return: True if task is started, False otherwise + :rtype: bool + """ + return self.round.is_started + + def can_submit(self, user: str | int | User) -> bool: + """ + Checks if user can submit a solution to the task. + + :param user: The user who wants to submit a solution. + :type user: str | int | User + :return: True if user can submit a solution, False otherwise. + :rtype: bool + """ + from main.models import Course + user = ModelsRegistry.get_user(user) + course = InCourse.get_context_course() + return any(( + user.has_course_permission(Course.CourseAction.ADD_SUBMIT.label, + course) and self.is_open, + user.has_course_permission(Course.CourseAction.ADD_SUBMIT_AFTER_DEADLINE.label, + course) and self.is_started, + user.has_course_permission(Course.CourseAction.ADD_SUBMIT_BEFORE_START.label, + course) and not self.is_started, + user.has_course_permission( + Course.CourseAction.ADD_SUBMIT_BEFORE_START.label, + course + ) and user.has_course_permission( + Course.CourseAction.ADD_SUBMIT_AFTER_DEADLINE.label, + course + ) + )) + + @transaction.atomic + def update_data(self, **kwargs) -> None: + """ + Updates the task object with new values. + + :param kwargs: The new values for the task object. + :type kwargs: dict + """ + rescore_planned = False + for key, value in kwargs.items(): + if getattr(self, key) != value and key in self.RESCORE_TRIGGERS: + rescore_planned = True + + setattr(self, key, value) + + self.save() + + if rescore_planned: + self.rescore_submits() + + def initialise_task(self) -> None: """ - If the user passes in a PackageInstance object, we'll use the object's primary key as the value for the - package_instance_id field. + It initialises the task by creating a new instance of the Task class, and adding all task + sets and tests to db. - :return: A new instance of the class. + :return: None """ - pkg_instance = kwargs.get('package_instance') - if isinstance(pkg_instance, PackageInstance): - kwargs['package_instance_id'] = pkg_instance.pk - del kwargs['package_instance'] - pkg_instance = kwargs.get('package_instance_id') - if isinstance(pkg_instance, PackageInstance): - kwargs['package_instance_id'] = pkg_instance.pk - return cls.objects.create(**kwargs) + pkg = self.package_instance.package + for t_set in pkg.sets(): + TestSet.objects.create_from_package(t_set, self) @property - def sets(self) -> QuerySet: + def legacy_ancestors(self) -> List[Task]: + """ + :return: List of all legacy ancestors of the task + :rtype: List[Task] + """ + result = [] + parents = Task.objects.filter(updated_task=self).all() + for task in parents: + result.append(task) + result.extend(task.legacy_ancestors) + return result + + @transaction.atomic + def delete(self, using=None, keep_parents=False, with_ancestors: bool = True): + """ + It deletes the task object, and all the test sets and tests that are associated with it. + """ + if with_ancestors: + for ancestor in self.legacy_ancestors: + ancestor.delete(with_ancestors=False) + + for t_set in self.sets: + t_set.delete() + super().delete(using, keep_parents) + + @property + def newest_update(self) -> Task: + """ + :return: Newest update of the task + :rtype: Task + """ + result = self + while result.updated_task: + result = result.updated_task + + return result + + @property + def sets(self) -> List[TestSet]: """ It returns all the test sets that are associated with the task :return: A list of all the TestSet objects that are associated with the Task object. """ - return TestSet.objects.filter(task=self).all() + return list(TestSet.objects.filter(task=self).all()) + + @property + def sets_amount(self) -> int: + """ + It returns the amount of test sets that are associated with the task + + :return: The amount of TestSet objects that are associated with the Task object. + """ + return TestSet.objects.filter(task=self).count() + + @property + def tests_amount(self) -> int: + """ + It returns the amount of tests that are associated with the task + + :return: The amount of Test objects that are associated with the Task object. + """ + return Test.objects.filter(test_set__task=self).count() @property def package_instance(self) -> PackageInstance: """ - It returns the package instance associated with the current package instance + It returns the package instance associated with the current package instance. :return: A PackageInstance object. + :rtype: PackageInstance """ - return PackageInstance.objects.get(pk=self.package_instance_id) + return ModelsRegistry.get_package_instance(self.package_instance_id) - def last_submit(self, usr, amount=1) -> 'Submit' | List['Submit']: + @transaction.atomic + def refresh_user_submits(self, user: str | int | User, rejudge: bool = False) -> None: """ - It returns the last submit of a user for a task or a list of 'amount' last submits to that task. + It refreshes the submits' scores for a user for a task. If rejudge is True, the score will + be recalculated even if it was already calculated before. - :param usr: The user who submitted the task + :param user: The user who submitted the task + :type user: str | int | User + :param rejudge: If True, the score will be recalculated even if it was already calculated + before, defaults to False (optional) + :type rejudge: bool + """ + user = ModelsRegistry.get_user(user) + for submit in Submit.objects.filter(task=self, usr=user.pk).all(): + submit.score(rejudge=rejudge) + + def submits(self, + user: str | int | User = None, + add_hidden: bool = False, + add_control: bool = False) -> List[Submit]: + """ + It returns all the submits that are associated with the task. If user is specified, + returns only submits of that user. + + :param user: The user who submitted the task, defaults to None (optional) + :type user: str | int | User + :param add_hidden: If True, hidden submits will be added to the list, defaults to False + (optional) + :type add_hidden: bool + :param add_control: If True, control submits will be added to the list, defaults to False + (optional) + :type add_control: bool + + :return: A list of all the Submit objects that are associated with the Task object. + :rtype: List[Submit] + """ + allowed_submit_types = [SubmitType.STD] + if add_hidden: + allowed_submit_types.append(SubmitType.HID) + if add_control: + allowed_submit_types.append(SubmitType.CTR) + if user is None: + return list( + Submit.objects.filter(task=self, submit_type__in=allowed_submit_types).all()) + user = ModelsRegistry.get_user(user) + return list(Submit.objects.filter(task=self, usr=user.pk).all()) + + def last_submit(self, user: str | int | User, amount=1) -> Submit | List[Submit] | None: + """ + :param user: A user who may submit solutions to the task + :type user: str | int | User :param amount: The amount of submits to return, defaults to 1 (optional) - :return: The last submit of a user for a task. + :type amount: int + :return: The last submit of a user for a task or a list of 'amount' last submits to that + task. If the user has not submitted any solutions, returns None. + :rtype: Submit | List[Submit] | None """ + user = ModelsRegistry.get_user(user) + self.refresh_user_submits(user) + query_set = Submit.objects.filter(task=self, usr=user.pk) + + if not query_set.exists(): + return None + if amount == 1: - return Submit.objects.filter(task=self, usr=usr.pk).order_by('-submit_date').first() - return Submit.objects.filter(task=self, usr=usr.pk).order_by('-submit_date').all()[:amount] + return query_set.order_by('-submit_date').first() - def best_submit(self, usr, amount=1) -> 'Submit' | List['Submit']: - """ - It returns the best submit of a user for a task or list of 'amount' best submits to that task. + return query_set.order_by('-submit_date').all()[:amount] - :param usr: The user who submitted the solution - :param amount: The amount of submits you want to get, defaults to 1 (optional) - :return: The best submit of a user for a task. + def best_submit(self, user: str | int | User, amount=1) -> Submit | List[Submit] | None: + """ + :param user: A user who may submit solutions to the task + :type user: str | int | User + :param amount: The amount of submits to return, defaults to 1 (optional) + :type amount: int + :return: The best submit of a user for a task or a list of 'amount' best submits to that + task. If the user has not submitted any solutions, returns None. + :rtype: Submit | List[Submit] | None """ + user = ModelsRegistry.get_user(user) + self.refresh_user_submits(user) + query_set = Submit.objects.filter(task=self, usr=user.pk) + + if not query_set.exists(): + return None + if amount == 1: - return Submit.objects.filter(task=self, usr=usr.pk).order_by('-final_score').first() - return Submit.objects.filter(task=self, usr=usr.pk).order_by('-final_score').all()[:amount] + return query_set.order_by('-final_score').first() + + return query_set.order_by('-final_score').all()[:amount] + + def user_scored_submit(self, user: str | int | User) -> Submit | None: + """ + :param user: A user who may submit solutions to the task + :type user: str | int | User + :return: The submit from which the user task score is calculated, selected based on the + score selection policy of the round the task belongs to. If the user has not submitted + any solutions, returns None. + :rtype: Submit | None + """ + ssp = self.round.score_selection_policy + submit = None + + if ssp == ScoreSelectionPolicy.BEST: + submit = self.best_submit(user) + elif ssp == ScoreSelectionPolicy.LAST: + submit = self.last_submit(user) + + return submit + + def user_score(self, user: str | int | User) -> float | None: + """ + :param user: The user who submitted the solution + :type user: str | int | User + :return: The score of the user for the task based on the score selection policy of the + round the task belongs to. If the user has not submitted any solutions, returns None. + :rtype: float | None + """ + submit = self.user_scored_submit(user) + + if submit is None: + return None + + return submit.score() @classmethod def check_instance(cls, pkg_instance: PackageInstance, in_every_course: bool = True) -> bool: """ - Check if a package instance exists in every course, optionally checking in context given course + Check if a package instance exists in every course, optionally checking in context given + course :param cls: the class of the model you're checking for :param pkg_instance: The PackageInstance object that you want to check for :type pkg_instance: PackageInstance :param in_every_course: If True, the package instance must be in every course. - If False, it must be in at least one course, defaults to True + If False, it must be in at least one course, defaults to True :type in_every_course: bool (optional) - :return: A boolean value. + + :return: True if package instance exists in every course, False otherwise. + :rtype: bool """ if not in_every_course: return cls.objects.filter(package_instance_id=pkg_instance.pk).exists() # check in every course + from main.models import Course + from .routing import InCourse courses = Course.objects.all() for course in courses: @@ -157,11 +911,144 @@ def check_instance(cls, pkg_instance: PackageInstance, in_every_course: bool = T return True return False + @property + def legacy_submits_amount(self) -> int: + """ + :return: Amount of submits for the task, which are legacy submits + :rtype: int + """ + if self.is_legacy: + submits = Submit.objects.filter(task=self, + submit_type__in=(SubmitType.STD, SubmitType.CTR), + task__is_legacy=True).count() + else: + submits = 0 + for task in Task.objects.filter(updated_task_id=self.pk): + submits += task.legacy_submits_amount + return submits + + def update_submits(self): + """ + Updates all submits for the task. + """ + if self.is_legacy: + for submit in self.submits(add_control=True): + submit.update() + + old_versions = Task.objects.filter(updated_task=self) + for task in old_versions: + task.update_submits() + + def rescore_submits(self) -> None: + """ + Rescores all submits for the task. + """ + for submit in self.submits(add_control=True): + submit.score(rejudge=True) + + def get_data(self, + submitter: int | str | User = None, + add_legacy_submits_amount: bool = False) -> dict: + """ + :param submitter: The user for whom a task score should be calculated + :type submitter: int | str | User + :param add_legacy_submits_amount: If True, the amount of legacy submits will be added to the + data, defaults to False (optional) + :type add_legacy_submits_amount: bool + :return: The data of the task. + :rtype: dict + """ + additional_data = {} + + if submitter: + submit = self.user_scored_submit(submitter) + additional_data['user_score'] = submit.score() if submit else None + additional_data['user_formatted_score'] = submit.summary_score if submit else '---' + + if add_legacy_submits_amount: + submits_amount = self.legacy_submits_amount + additional_data['legacy_submits_amount'] = submits_amount + additional_data['has_legacy_submits'] = submits_amount > 0 + + return { + 'id': self.pk, + 'name': self.task_name, + 'round_name': self.round.name, + 'judging_mode': self.judging_mode, + 'points': self.points, + 'is_legacy': self.is_legacy, + } | additional_data -class TestSet(models.Model): + +class TestSetManager(models.Manager): + + @transaction.atomic + def create_test_set(self, task: int | Task, short_name: str, weight: float) -> TestSet: + """ + It creates a new test set object. + + :param task: The task that you want to associate the test set with. + :type task: int | Task + :param short_name: The short name of the test set. + :type short_name: str + :param weight: The weight of the test set. + :type weight: float + + :return: A new test set object. + :rtype: TestSet + + :raise ValidationError: If test set with given short name already exists under chosen task. + """ + task = ModelsRegistry.get_task(task) + sets = task.sets + if sets.filter(short_name=short_name).exists(): + raise ValidationError(f'TestSet: Test set with short name {short_name} already exists.') + + new_test_set = self.model(short_name=short_name, + weight=weight, + task=task) + new_test_set.save() + return new_test_set + + @transaction.atomic + def delete_test_set(self, test_set: int | TestSet, course: str | int | Course = None) -> None: + """ + It deletes a test set object. + + :param test_set: The test set id that you want to delete. + :type test_set: int + :param course: The course that the test set is in, defaults to None (optional) + :type course: str | int | Course + """ + test_set = ModelsRegistry.get_test_set(test_set, course) + test_set.delete() + + @transaction.atomic + def create_from_package(self, t_set: TSet, task: Task) -> TestSet: + """ + It creates a new TestSet object from a TSet object. + + :param t_set: The TSet object that you want to create a TestSet object from. + :type t_set: TSet + :param task: The task that you want to associate the TestSet object with. + :type task: Task + + :return: A new TestSet object. + :rtype: TestSet + """ + test_set = self.model(short_name=t_set['name'], + weight=t_set['weight'], + task=task) + test_set.save() + for test in t_set.tests(): + Test.objects.create_from_package(test, test_set) + return test_set + + +class TestSet(models.Model, metaclass=ReadCourseMeta): """ - Model groups single tests into a set of tests. Gives them a set name, and weight, used while calculating results for - whole task. + Model groups single tests into a set of tests. Gives them a set name, and weight, used while + calculating results for whole task. """ #: Foreign key to task, with which tests set is associated. @@ -171,62 +1058,379 @@ class TestSet(models.Model): #: Weight of test set in final task result. weight = models.FloatField() + #: The manager for the TestSet model. + objects = TestSetManager() + def __str__(self): - return f"TestSet {self.pk}: Task/set: {self.task.task_name}/{self.short_name} (w: {self.weight})" + return (f'TestSet {self.pk}: Task/set: {self.task.task_name}/{self.short_name} ' + f'(w: {self.weight})') + + @transaction.atomic + def delete(self, using=None, keep_parents=False): + """ + It deletes the TestSet object, and all the tests that are associated with it. + """ + for test in self.tests: + test.delete() + super().delete(using, keep_parents) @property - def tests(self) -> QuerySet: + def tests(self) -> List[Test]: """ It returns all the tests that are associated with the test set :return: A list of all the tests in the test set. """ - return Test.objects.filter(test_set=self).all() + return list(Test.objects.filter(test_set=self).all()) + + @property + def package_test_set(self) -> TSet: + """ + :return: The TSet object that corresponds to the TestSet object. + :rtype: TSet + """ + return self.task.package_instance.package.sets(self.short_name) + + +class TestManager(models.Manager): + + @transaction.atomic + def create_test(self, short_name: str, test_set: TestSet) -> Test: + """ + It creates a new test object. + + :param short_name: The short name of the test. + :type short_name: str + :param test_set: The test set that you want to associate the test with. + :type test_set: TestSet + + :return: A new test object. + :rtype: Test + """ + new_test = self.model(short_name=short_name, + test_set=test_set) + new_test.save() + return new_test + + @transaction.atomic + def delete_test(self, test: int | Test, course: str | int | Course = None) -> None: + """ + It deletes a test object. + + :param test: The test id that you want to delete. + :type test: int + :param course: The course that the test is in, defaults to None (optional) + :type course: str | int | Course + """ + test_to_delete = ModelsRegistry.get_test(test, course) + test_to_delete.delete() + + @transaction.atomic + def create_from_package(self, test: TestF, test_set: TestSet) -> Test: + """ + It creates a new Test object from a TestF object. + + :param test: The TestF object that you want to create a Test object from. + :type test: TestF + :param test_set: The test set that you want to associate the Test object with. + :type test_set: TestSet + + :return: A new Test object. + :rtype: Test + """ + return self.create_test(short_name=test['name'], test_set=test_set) -class Test(models.Model): +class Test(models.Model, metaclass=ReadCourseMeta): """ Single test. Primary object to be connected with students' results. """ - #: Simple description what exactly is tested. Corresponds to :py:class:`package.package_manage.TestF`. + #: Simple description what exactly is tested. + # Corresponds to py:class:`package.package_manage.TestF`. short_name = models.CharField(max_length=255) #: Foreign key to :py:class:`TestSet`. test_set = models.ForeignKey(TestSet, on_delete=models.CASCADE) + #: The manager for the Test model. + objects = TestManager() + def __str__(self): - return f"Test {self.pk}: Test/set/task: " \ - f"{self.short_name}/{self.test_set.short_name}/{self.test_set.task.task_name}" + return f'Test {self.pk}: Test/set/task: ' \ + f'{self.short_name}/{self.test_set.short_name}/{self.test_set.task.task_name}' + + @property + def associated_results(self) -> List[Result]: + """ + Returns all results associated with this test. + + :return: QuerySet of results + :rtype: QuerySet + """ + return list(Result.objects.filter(test=self).all()) + + @transaction.atomic + def delete(self, using=None, keep_parents=False): + """ + It deletes the Test object, and all the results that are associated with it. + """ + for result in self.associated_results: + result.delete() + super().delete(using, keep_parents) + + @property + def package_test(self) -> TestF: + """ + :return: The TestF object that corresponds to the Test object. + :rtype: TestF + """ + return self.test_set.package_test_set.tests(self.short_name) + + +class SubmitManager(models.Manager): + @transaction.atomic + def create_submit(self, + source_code: str | Path, + task: int | Task, + user: str | int | User, + submit_date: datetime = None, + final_score: float = -1, + auto_send: bool = True, + submit_type: SubmitType = SubmitType.STD, + submit_status: ResultStatus = ResultStatus.PND, + error_msg: str = None, + retry: int = 0, + fixed_fall_off_factor: float = None, + **kwargs) -> Submit: + """ + It creates a new submit object. + + :param source_code: The path to the source code file. + :type source_code: str | Path + :param task: The task that you want to associate the submit with. + :type task: int | Task + :param user: The user that you want to associate the submit with. + :type user: str | int | User + :param submit_date: The date and time when the submit was created, defaults to now() + (optional) + :type submit_date: datetime + :param final_score: The final score of the submit, defaults to -1 (optional) + :type final_score: float + :param auto_send: If True, the submit will be sent to the broker, defaults to True + (optional) + :type auto_send: bool + :param submit_type: The type of the submit, defaults to SubmitType.STD (optional) + :type submit_type: SubmitType + :param submit_status: The status of the submit, defaults to None (optional) + :type submit_status: ResultStatus + :param error_msg: The error message of the submit, defaults to None (optional) + :type error_msg: str + :param retry: The number of retry, defaults to 0 (optional) + :type retry: int + :param fixed_fall_off_factor: The fixed fall-off factor of the submit, defaults to None + (optional) + :type fixed_fall_off_factor: float + + :return: A new submit object. + :rtype: Submit + """ + submit_date = submit_date or now() + task = ModelsRegistry.get_task(task) + user = ModelsRegistry.get_user(user) + source_code = ModelsRegistry.get_source_code(source_code) + if fixed_fall_off_factor is None and submit_type == SubmitType.CTR: + fixed_fall_off_factor = 1 + new_submit = self.model(submit_date=submit_date, + source_code=source_code, + task=task, + usr=user.pk, + final_score=final_score, + submit_type=submit_type, + submit_status=submit_status, + error_msg=error_msg, + retries=retry, + fixed_fall_off_factor=fixed_fall_off_factor, + ) + new_submit.save() + if auto_send: + new_submit.send(**kwargs) + return new_submit + + @transaction.atomic + def delete_submit(self, submit: int | Submit, course: str | int | Course = None) -> None: + """ + It deletes a submit object. + + :param submit: The submit id that you want to delete. + :type submit: int + :param course: The course that the submit is in, defaults to None (optional) + :type course: str | int | Course + """ + submit = ModelsRegistry.get_submit(submit, course) + submit.delete() + + @staticmethod + def user_exists_validator(user: int) -> bool: + """ + It checks if a user exists. + + :param user: The user id that you want to check. + :type user: int + + :return: A boolean value. + :rtype: bool + """ + from main.models import User + return User.exists(user) -class Submit(models.Model): +class Submit(models.Model, metaclass=ReadCourseMeta): """ Model containing single submit information. It is assigned to task and user. """ #: Datetime when submit took place. - submit_date = models.DateTimeField(auto_now_add=True) + submit_date = models.DateTimeField() #: Field submitted to the task - source_code = models.FileField(upload_to=SUBMITS_DIR) + source_code = models.FilePathField(path=settings.SUBMITS_DIR, + allow_files=True, + null=True, + max_length=2047) #: :py:class:`Task` model, to which submit is assigned. task = models.ForeignKey(Task, on_delete=models.CASCADE) #: Pseudo-foreign key to :py:class:`main.models.User` model (user), who submitted to the task. - usr = models.BigIntegerField(validators=[User.exists]) - #: Final score (as percent), gained by user's submission. Before solution check score is set to ``-1``. + usr = models.BigIntegerField(validators=[SubmitManager.user_exists_validator]) + #: Solution type + submit_type = models.CharField(max_length=3, + choices=SubmitType.choices, + default=SubmitType.STD) + #: Status of submit (initially PND - pending) + submit_status = models.CharField(max_length=3, + choices=ResultStatus.choices, + default=ResultStatus.PND) + #: Error message, if submit ended with error + error_msg = models.TextField(null=True, blank=True, default=None) + #: Retries count + retries = models.IntegerField(default=0) + #: Final score (as percent), gained by user's submission. Before solution check score is set + #: to ``-1``. final_score = models.FloatField(default=-1) + #: Fall-off factor for the submit, that is recalculated every round change. + fixed_fall_off_factor = models.FloatField(null=True, default=None) + + #: The manager for the Submit model. + objects = SubmitManager() + + class Meta: + permissions = [ + ('view_code', _('Can view submit code')), + ('view_compilation_logs', _('Can view submit compilation logs')), + ('view_checker_logs', _('Can view submit checker logs')), + ('view_student_output', _('Can view output generated by the student solution')), + ('view_benchmark_output', _('Can view benchmark output')), + ('view_inputs', _('Can view test inputs')), + ('view_used_memory', _('Can view used memory')), + ('view_used_time', _('Can view used time')), + ('rejudge_submit', _('Can rejudge submit')), + ] + + class BasicAction(ModelAction): + """ + Basic actions for Submit model. + """ + ADD = 'add', 'add_submit' + DEL = 'delete', 'delete_submit' + EDIT = 'edit', 'change_submit' + VIEW = 'view', 'view_submit' - @classmethod - def create_new(cls, **kwargs) -> 'Submit': + @property + def source_code_path(self) -> Path: + """ + :return: Path to the source code file. + :rtype: Path + """ + return Path(self.source_code) + + @property + def is_legacy(self) -> bool: + """ + :return: True if submit is legacy, False otherwise. + :rtype: bool + """ + return self.task.is_legacy + + def send(self, **kwargs) -> BrokerSubmit | None: + """ + It sends the submit to the broker. If the broker is mocked, it will run the mock broker and + return None. + + :return: A new BrokerSubmit object or None if the broker is mocked. + :rtype: BrokerSubmit | None + """ + from broker_api.models import BrokerSubmit + if settings.MOCK_BROKER: + from broker_api.mock import BrokerMock + mock = BrokerMock(ModelsRegistry.get_course(self._state.db), self, **kwargs) + mock.run() + return None + + return BrokerSubmit.send(ModelsRegistry.get_course(self._state.db), + self.id, + self.task.package_instance) + + def resend(self, limit_retries: int = -1) -> Submit: + """ + It marks this submit as hidden, and creates new submit with the same data. New submit will + be sent to broker. + + :return: A new BrokerSubmit object. + :rtype: BrokerSubmit """ - Creates new Submit, but changes ``usr`` attribute to its primary key, when passed as model. This is because of - simulating Primary Key for user. + if -1 < limit_retries < self.retries: + raise ValidationError(f'Submit ({self}): Limit of retries exceeded') + + sub_type = SubmitType[self.submit_type] + self.submit_type = SubmitType.HID + self.save() + + new_submit = self.objects.create_submit( + submit_date=self.submit_date, + source_code=self.source_code, + task=self.task, + user=self.usr, + submit_type=sub_type, + retry=self.retries + 1 + ) + + return new_submit - :return: New created submit. + def update(self) -> Self | None: + """ + It updates the submit to the newest task update """ - user = kwargs.get('usr') - if isinstance(user, User): - kwargs['usr'] = user.pk - return cls.objects.create(**kwargs) + if not self.is_legacy: + return None + + new_task = self.task.newest_update + + new_submit = Submit.objects.create_submit( + submit_date=self.submit_date, + source_code=self.source_code, + task=new_task, + user=self.usr, + submit_type=self.submit_type, + ) + self.submit_type = SubmitType.HID + self.save() + return new_submit + + def delete(self, using=None, keep_parents=False): + """ + It deletes the submit object, and all the results that are associated with it. + """ + for result in self.results: + result.delete() + super().delete(using, keep_parents) @property def user(self) -> User: @@ -235,31 +1439,86 @@ def user(self) -> User: :return: Returns user model """ + from main.models import User return User.objects.get(pk=self.usr) def __str__(self): - return f"Submit {self.pk}: User: {self.user}; Task: {self.task.task_name}; " \ + return f'Submit {self.pk}: User: {self.user}; Task: {self.task.task_name}; ' \ f"Score: {self.final_score if self.final_score > -1 else 'PENDING'}" + @property + def results(self) -> List[Result]: + """ + Returns all results associated with this submit. + + :return: QuerySet of results + :rtype: QuerySet + """ + return list(Result.objects.filter(submit=self).all()) + + def end_with_error(self, error_type: ResultStatus, error_msg: str) -> None: + """ + It ends the submit with an error. + + :param error_type: The type of the error. + :type error_type: ResultStatus + :param error_msg: The message of the error. + :type error_msg: str + """ + self.submit_status = error_type + self.error_msg = error_msg + + self.final_score = 0 + self.save() + + @property + def fall_off_factor(self) -> float: + """ + :return: Fall-off factor for the submit + :rtype: float + """ + if self.fixed_fall_off_factor is not None: + return self.fixed_fall_off_factor + return self.task.get_fall_off().get_factor(self.submit_date) + + @transaction.atomic def score(self, rejudge: bool = False) -> float: """ It calculates the score of *self* submit. - :param rejudge: If True, the score will be recalculated even if it was already calculated before, - defaults to False (optional) + :param rejudge: If True, the score will be recalculated even if it was already calculated + before, defaults to False (optional) :type rejudge: bool + :return: The score of the submit. + :raise DataError: if there is more results than tests :raise NotImplementedError: if selected judging mode is not implemented """ - + submit_status = ResultStatus[self.submit_status] if rejudge: + if submit_status in EMPTY_FINAL_STATUSES: + self.final_score = 0 + self.save() + return 0 self.final_score = -1 + self.submit_status = ResultStatus.PND - # It's a cache. If the score was already calculated, it will be returned without recalculating. - if self.final_score != -1: + # It's a cache. If the score was already calculated, it will be returned without + # recalculating. + if self.final_score >= 0: return self.final_score + if submit_status == ResultStatus.PND and len(self.results) < self.task.tests_amount: + self.final_score = -1 + self.save() + return -1 + + if submit_status in EMPTY_FINAL_STATUSES: + self.final_score = 0 + self.save() + return 0 + # It counts amount of different statuses, grouped by test sets. results = ( Result.objects @@ -290,9 +1549,10 @@ def score(self, rejudge: bool = False) -> float: results_aggregated[r['test__test_set']]['SUM'] += r['amount'] judging_mode = self.task.judging_mode + worst_status = ResultStatus.PND for test_set, s in results_aggregated.items(): if s['SUM'] > s['MAX']: - raise DataError(f"Submit ({self}): More test results, then test assigned to task") + raise DataError(f'Submit ({self}): More test results, then test assigned to task') if s['SUM'] < s['MAX']: return -1 @@ -302,8 +1562,15 @@ def score(self, rejudge: bool = False) -> float: elif judging_mode == TaskJudgingMode.UNA: results_aggregated[test_set]['score'] = float(s.get('OK', 0) == s['SUM']) else: - raise NotImplementedError(f"Submit ({self}): Task {self.task.pk} has judging mode " + - f"which is not implemented.") + raise NotImplementedError( + f'Submit ({self}): Task {self.task.pk} has judging mode ' + + 'which is not implemented.') + + for status in results_aggregated[test_set].keys(): + if status in ResultStatus.values: + status = ResultStatus[status] + if ResultStatus.compare(status, worst_status) > 0: + worst_status = status # It's calculating the score of a submit, as weighted average of sets scores. final_score = 0 @@ -313,10 +1580,213 @@ def score(self, rejudge: bool = False) -> float: final_score += s['score'] * weight self.final_score = round(final_score / final_weight, 6) + self.final_score *= self.fall_off_factor + self.submit_status = worst_status + self.save() return self.final_score + @staticmethod + def format_score(score: float, + rnd: int = 2, + include_submit_points: bool = False, + task_points: float = None) -> str: + """ + :param score: The score to be formatted. Must be between 0 and 1. + :type score: float + :param rnd: The number of decimal places to round the score to, defaults to 2 (optional) + :type rnd: int + :param include_submit_points: If True, the submit points will be included in the formatted + score, defaults to False (optional) + :type include_submit_points: bool + :param task_points: The amount of points that the task is worth, only required if + include_submit_points is True + :type task_points: float + :return: The formatted score. + :rtype: str + """ + if score == -1: + return '---' + + _score = score + score = round(score * 100, rnd) + f_score = f'{score:.{rnd}f} %' + + if not include_submit_points: + return f_score + if task_points is None: + raise ValueError('task_points must be provided if include_submit_points is True') + + return f'{round(task_points * _score, 2)} ({f_score})' + + @property + def submit_points(self) -> float: + """ + :return: The amount of points that the submit is worth (task points * submit score). + :rtype: float + """ + return round(self.task.points * self.score(), 2) + + @property + def summary_score(self) -> str: + score = self.score() + return self.format_score(score, 1, True, self.task.points) + + @property + def formatted_submit_status(self) -> str: + """ + It returns the submit status in a formatted way. + + :return: The submit status in a formatted way. + :rtype: str + """ + return f'{self.submit_status} ({ResultStatus[self.submit_status].label})' -class Result(models.Model): # TODO: +pola z kolejki + def get_data(self, + show_user: bool = True, + add_round_task_name: bool = False, + add_summary_score: bool = False, + add_falloff_info: bool = False) -> dict: + """ + :return: The data of the submit. + :rtype: dict + """ + score = self.score() + task_score = self.submit_points + res = { + 'id': self.pk, + 'submit_date': self.submit_date, + 'source_code': self.source_code, + 'task_name': self.task.task_name, + 'task_score': task_score if score > -1 else '---', + 'final_score': self.format_score(score), + 'submit_status': self.formatted_submit_status, + 'is_legacy': self.is_legacy, + } + if show_user: + res |= {'user_first_name': self.user.first_name if self.user.first_name else '---', + 'user_last_name': self.user.last_name if self.user.last_name else '---'} + if add_round_task_name: + res |= {'round_task_name': f'{self.task.round.name}: {self.task.task_name}'} + if add_summary_score: + res |= {'summary_score': self.summary_score} + if add_falloff_info: + res |= { + 'fall_off_factor': self.format_score(self.fall_off_factor), + } + return res + + +class ResultManager(models.Manager): + @transaction.atomic + def unpack_results(self, + submit: int | Submit, + results: BrokerToBaca, + auto_score: bool = True) -> List[Result]: + """ + It unpacks the results from the BrokerToBaca object and saves them to the database. + + :param submit: The submit id that you want to unpack the results for. + :type submit: int | Submit + :param results: The BrokerToBaca object that you want to unpack the results from. + :type results: BrokerToBaca + :param auto_score: If True, the score of the submit will be calculated automatically, + defaults to True (optional) + :type auto_score: bool + + :return: None + """ + submit = ModelsRegistry.get_submit(submit) + results_list = [] + + for set_name, set_result in results.results.items(): + test_set = TestSet.objects.get(task=submit.task, short_name=set_name) + for test_name, test_result in set_result.tests.items(): + test = Test.objects.get(test_set=test_set, short_name=test_name) + logs = test_result.logs + compile_log = logs.get('compile_log') + checker_log = logs.get('checker_log') + result = self.model( + test=test, + submit=submit, + status=test_result.status, + time_real=test_result.time_real, + time_cpu=test_result.time_cpu, + runtime_memory=test_result.runtime_memory, + compile_log=compile_log, + checker_log=checker_log + ) + result.save() + results_list.append(result) + if auto_score: + submit.score(rejudge=True) + return results_list + + @transaction.atomic + def create_result(self, + test: int | Test, + submit: int | Submit, + status: str | ResultStatus = ResultStatus.PND, + time_real: float = None, + time_cpu: float = None, + runtime_memory: int = None, + compile_log: str = None, + checker_log: str = None, + answer: str = None) -> Result: + """ + It creates a new result object. + + :param test: The test that you want to associate the result with. + :type test: int | Test + :param submit: The submit that you want to associate the result with. + :type submit: int | Submit + :param status: The status of the result, defaults to ResultStatus.PND (optional) + :type status: str | ResultStatus + :param time_real: The real time of the result, defaults to None (optional) + :type time_real: float + :param time_cpu: The cpu time of the result, defaults to None (optional) + :type time_cpu: float + :param runtime_memory: The runtime memory of the result, defaults to None (optional) + :type runtime_memory: int + :param compile_log: The compile log of the result, defaults to None (optional) + :type compile_log: str + :param checker_log: The checker log of the result, defaults to None (optional) + :type checker_log: str + :param answer: The answer of the result, defaults to None (optional) + :type answer: str + + :return: A new result object. + :rtype: Result + """ + test = ModelsRegistry.get_test(test) + submit = ModelsRegistry.get_submit(submit) + status = ModelsRegistry.get_result_status(status) + new_result = self.model(test=test, + submit=submit, + status=status, + time_real=time_real, + time_cpu=time_cpu, + runtime_memory=runtime_memory, + compile_log=compile_log, + checker_log=checker_log, + answer=answer) + new_result.save() + return new_result + + @transaction.atomic + def delete_result(self, result: int | Result, course: str | int | Course = None) -> None: + """ + It deletes a result object. + + :param result: The result id that you want to delete. + :type result: int + :param course: The course that the result is in, defaults to None (optional) + :type course: str | int | Course + """ + result = ModelsRegistry.get_result(result, course) + result.delete() + + +class Result(models.Model, metaclass=ReadCourseMeta): """ Result of single :py:class:`Test` testing. """ @@ -325,13 +1795,95 @@ class Result(models.Model): # TODO: +pola z kolejki test = models.ForeignKey(Test, on_delete=models.CASCADE) #: :py:class:`Submit` model this test is connected to. submit = models.ForeignKey(Submit, on_delete=models.CASCADE) - #: Status result. Described as one of choices from :py:class:`BaCa2.choices.ResultStatus` + #: Status result. Described as one of choices from :py:class:`core.choices.ResultStatus` status = models.CharField( max_length=3, choices=ResultStatus.choices, default=ResultStatus.PND ) + #: Time of test execution in seconds. + time_real = models.FloatField(null=True, default=None) + #: Time of test execution in seconds, measured by CPU. + time_cpu = models.FloatField(null=True, default=None) + #: Memory used by test in bytes. + runtime_memory = models.IntegerField(null=True, default=None) + #: Compile logs + compile_log = models.TextField(null=True, default=None) + #: Checker logs + checker_log = models.TextField(null=True, default=None) + #: User program's answer + answer = models.TextField(null=True, default=None) + + #: The manager for the Result model. + objects = ResultManager() def __str__(self): - return f"Result {self.pk}: Set[{self.test.test_set.short_name}] Test[{self.test.short_name}]; " \ - f"Stat: {ResultStatus[self.status]}" + return (f'Result {self.pk}: Set[{self.test.test_set.short_name}] ' + f'Test[{self.test.short_name}]; Stat: {ResultStatus[self.status]}') + + def delete(self, using=None, keep_parents=False, rejudge: bool = False): + """ + It deletes the result object. + """ + super().delete(using, keep_parents) + if rejudge: + self.submit.score(rejudge=True) + + def get_data(self, + include_time: bool = False, + include_memory: bool = False, + format_time: bool = True, + format_memory: bool = True, + translate_status: bool = True, + add_limits: bool = True, + add_compile_log: bool = False, + add_checker_log: bool = False, + add_user_answer: bool = False, ) -> dict: + res = { + 'id': self.pk, + 'test_name': self.test.short_name, + 'status': self.status, + } + + if include_time: + res['time_real'] = self.time_real + res['time_cpu'] = self.time_cpu + if include_memory: + res['runtime_memory'] = self.runtime_memory + + if include_time and format_time: + res['f_time_real'] = f'{self.time_real:.3f} s' if self.time_real else None + res['f_time_cpu'] = f'{self.time_cpu:.3f} s' if self.time_cpu else None + if self.status in HALF_EMPTY_FINAL_STATUSES: + res['f_time_real'] = self.status + res['f_time_cpu'] = self.status + if add_limits: + time_limit = round(self.test.package_test['time_limit'], 3) + res['f_time_real'] += f' / {time_limit} s' + res['f_time_cpu'] += f' / {time_limit} s' + if include_memory and format_memory and self.runtime_memory: + res['f_runtime_memory'] = f'{bytes_to_str(self.runtime_memory)}' + if self.status in HALF_EMPTY_FINAL_STATUSES: + res['f_runtime_memory'] = self.status + if add_limits: + memory_limit = self.test.package_test['memory_limit'] + res['f_runtime_memory'] += f' / {bytes_to_str(bytes_from_str(memory_limit))}' + if translate_status: + res['f_status'] = f'{self.status} ({ResultStatus[self.status].label})' + + if add_compile_log: + res['compile_log'] = self.compile_log + else: + res['compile_log'] = '' + + if add_checker_log: + res['checker_log'] = self.checker_log + else: + res['checker_log'] = '' + + if add_user_answer: + res['user_answer'] = self.answer + else: + res['user_answer'] = '' + + return res diff --git a/BaCa2/course/routing.py b/BaCa2/course/routing.py index 8ec9a4bf..59f405bc 100644 --- a/BaCa2/course/routing.py +++ b/BaCa2/course/routing.py @@ -1,6 +1,12 @@ -from django.db import models +from __future__ import annotations + +from typing import TYPE_CHECKING + +from django.conf import settings + +if TYPE_CHECKING: + from main.models import Course -from BaCa2.settings import currentDB class SimpleCourseRouter: """ @@ -9,7 +15,7 @@ class SimpleCourseRouter: @staticmethod def _test_course_db(model, **hints): - if model._meta.app_label == "course": + if model._meta.app_label == 'course': return 'test_course' def db_for_read(self, model, **hints): @@ -20,8 +26,8 @@ def db_for_write(self, model, **hints): def allow_relation(self, obj1, obj2, **hints): """ - If the two objects are in the same database, or if the second object is in the default database, then allow the - relationship + If the two objects are in the same database, or if the second object is in the default + database, then allow the relationship :param obj1: The first object in the relation :param obj2: The object that is being created @@ -33,8 +39,8 @@ def allow_relation(self, obj1, obj2, **hints): def allow_migrate(self, db, app_label, model_name=None, **hints): """ - If the database is 'default' and the app_label is 'course', or if the database is not 'default' and the app_label is - not 'course', then return True + If the database is 'default' and the app_label is 'course', or if the database + is not 'default' and the app_label is not 'course', then return True :param db: The alias of the database that is being used :param app_label: The name of the application that contains the model @@ -50,26 +56,32 @@ class ContextCourseRouter(SimpleCourseRouter): """ Router that uses context given by :py:class:`InCourse' context manager. """ + @staticmethod def _get_context(model, **hints): """ - It returns the name of the database to use for a given model. It gets it from context manager. + It returns the name of the database to use for a given model. It gets it from context + manager. :param model: The model class that is being queried :return: The name of the database to use. """ - if model._meta.app_label != "course": + if model._meta.app_label != 'course': return 'default' - from BaCa2.settings import DATABASES - from BaCa2.exceptions import RoutingError + from core.exceptions import RoutingError try: - db = currentDB.get() + db = settings.CURRENT_DB.get() except LookupError: - raise RoutingError(f"No DB chosen. Remember to use 'with InCourse', while accessing course instance.") - if db not in DATABASES.keys(): - raise RoutingError(f"Can't access course DB {db}. Check the name in 'with InCourse'") + raise RoutingError( + "No DB chosen. Remember to use 'with InCourse', while accessing course instance.") + if db not in settings.DATABASES.keys(): + settings.DB_MANAGER.parse_cache() + if db not in settings.DATABASES.keys(): + raise RoutingError( + f"Can't access course DB {db}. Check the name in 'with InCourse'.\n" + f'Available DBs: {list(settings.DATABASES.keys())}') return db @@ -104,15 +116,93 @@ class InCourse: from course.routing import InCourse using InCourse('course123'): + Submit.objects.create_submit(...) + + This code will create new submission inside of course ``course123``. + """ + + def __init__(self, course: int | str | Course): + from util.models_registry import ModelsRegistry + + # if isinstance(course, str): + # self.db = course + # else: + self.db = ModelsRegistry.get_course(course).short_name + + def __enter__(self): + self.token = settings.CURRENT_DB.set(self.db) + + def __exit__(self, *args): + settings.CURRENT_DB.reset(self.token) + + @staticmethod + def is_defined(): + """ + Checks if there is any context database set. + + :return: True if there is any context database set. + """ + try: + settings.CURRENT_DB.get() + except LookupError: + return False + return True + + @staticmethod + def get_context_course() -> Course | None: + """ + :return: Course instance of the current chosen database. + """ + from util.models_registry import ModelsRegistry + try: + return ModelsRegistry.get_course(settings.CURRENT_DB.get()) + except LookupError: + return None + + +class OptionalInCourse(InCourse): + """ + It allows you to give the context database. Everything called inside this context manager + will be performed on database specified on initialization. If no database is specified, it will + be on already set context database. + + Usage example: + + .. code-block:: python + + from course.routing import OptionalInCourse + + using OptionalInCourse('course123'): Submit.create_new(...) This code will create new submission inside of course ``course123``. + + But this code: + + .. code-block:: python + + from course.routing import OptionalInCourse + + using OptionalInCourse(): + Submit.create_new(...) + + will create new submission inside of course set by :py:class:`InCourse` context manager. If + there is no context database set, it will raise :py:class:`RoutingError`. """ - def __init__(self, db: str): - self.db = db + + def __init__(self, course_or_none: int | str | Course | None): + + #: Indicates if database alias was provided + self.db_provided = course_or_none is not None + if self.db_provided: + super().__init__(course_or_none) + else: + self.db = None def __enter__(self): - self.token = currentDB.set(self.db) + if self.db_provided: + super().__enter__() def __exit__(self, *args): - currentDB.reset(self.token) + if self.db_provided: + super().__exit__(*args) diff --git a/BaCa2/course/tests.py b/BaCa2/course/tests.py index 16f1a33c..2c5e38c0 100644 --- a/BaCa2/course/tests.py +++ b/BaCa2/course/tests.py @@ -1,492 +1,396 @@ -from pathlib import Path -from datetime import timedelta, datetime -from time import sleep -from typing import Iterable, List, Tuple, Dict +import datetime as dt_raw +from datetime import datetime, timedelta from random import choice, randint -from string import ascii_lowercase, ascii_uppercase -from unittest import TestSuite, TextTestRunner -from threading import Thread +from django.core.exceptions import ValidationError from django.test import TestCase from django.utils import timezone -from django.core.files.uploadedfile import SimpleUploadedFile -from BaCa2.choices import TaskJudgingMode, ResultStatus -from BaCa2.tools import random_string -from main.models import User -from package.models import PackageInstance, PackageSource -from .models import * -from .routing import InCourse -from .manager import create_course, delete_course - - -def create_random_task(course_name: str, - round_task: Round, - package_instance: PackageInstance, - task_name: str = None, - judging_mode: TaskJudgingMode = None, - points: float = None, - tests: dict = None, - time_offset: float = 0): - """ - It creates a task with the given name, package, round, judging mode, points, tests and time offset. - Every optional, not provided argument will be randomized. - - :param course_name: the name of the course to create the task in - :type course_name: str - :param round_task: the round in which the task will be created - :type round_task: Round - :param package_instance: package_instance that the task will be created in - :param task_name: the name of the task - :type task_name: str - :param judging_mode: judging mode for the task (selected from choices.TaskJudgingMode) - :type judging_mode: TaskJudgingMode - :param points: amount of points for the task - :type points: float - :param tests: dictionary of sets of test. Every set should contain weight and test list (as test names list). - :type tests: dict - :param time_offset: offset between instructions - used to simulate time changes in multithreading testing. - :type time_offset: float (optional) - :return: A task object - """ - if task_name is None: - task_prefix = random_string(3, ascii_uppercase) - task_name = f"{task_prefix} task {task_prefix}{random_string(15, ascii_lowercase)}" - - if judging_mode is None: - judging_mode = choice(list(TaskJudgingMode)) - - if points is None: - points = randint(1, 20) - - if tests is None: - tests = {} - sets_amount = randint(1, 5) - for i in range(sets_amount): - set_name = f'set{i}' - tests[set_name] = {} - tests[set_name]['weight'] = (randint(1, 100) / 10) - tests[set_name]['test_list'] = [f"t{j}_{random_string(3, ascii_lowercase)}" for j in range(randint(1, 10))] - - with InCourse(course_name): - new_task = Task.create_new( - task_name=task_name, - package_instance=package_instance, - round=round_task, - judging_mode=judging_mode, - points=points - ) - sleep(time_offset) - - for s in tests.keys(): - new_set = TestSet.objects.create( - task=new_task, - short_name=s, - weight=tests[s]['weight'] - ) +from core.choices import ResultStatus +from main.models import Course, User +from package.models import PackageInstance +from parameterized import parameterized - for test_name in tests[s]['test_list']: - Test.objects.create( - short_name=test_name, - test_set=new_set - ) - sleep(time_offset) - return new_task - - -def create_random_submit(course_name: str, - usr, - parent_task: Task, - submit_date: datetime = timezone.now(), - allow_pending_status: bool = False, - pass_chance: float = 0.5): - """ - It creates a random submit for a given task. - Every optional, not provided argument will be randomized. - - :param course_name: the name of the course to create the submit in - :type course_name: str - :param usr: The user who submitted the task - :param parent_task: the task to which the submit will be attached - :type parent_task: Task - :param submit_date: datetime of submit - :type submit_date: datetime - :param allow_pending_status: If True, the submit can have a pending status, defaults to False - :type allow_pending_status: bool (optional) - :param pass_chance: chance to generate passed test (test with status OK; default = 0.5) - :type pass_chance: float - """ - - source_file = SimpleUploadedFile(f"course{course_name}_{parent_task.pk}_" + - f"{submit_date.strftime('%Y_%m_%d_%H%M%S')}.txt", - b"Test simple file upload.") - - allowed_statuses = list(ResultStatus) - allowed_statuses.remove(ResultStatus.OK) - if not allow_pending_status: - allowed_statuses.remove(ResultStatus.PND) - with InCourse(course_name): - new_submit = Submit.create_new( - submit_date=submit_date, - source_code=source_file, - task=parent_task, - usr=usr, - final_score=-1 - ) +from .models import * +from .routing import InCourse, OptionalInCourse - tests = [] - for s in parent_task.sets: - for t in s.tests: - tests.append(t) - for t in tests: - if (randint(0, 100000) / 100000) <= pass_chance: - status = ResultStatus.OK - else: - status = choice(allowed_statuses) - Result.objects.create( - test=t, - submit=new_submit, - status=status +def create_rounds(course, amount): + with InCourse(course): + rounds = [] + for _ in range(amount): + new_round = Round.objects.create_round( + start_date=timezone.now() - timedelta(days=1), + deadline_date=timezone.now() + timedelta(days=2), + end_date=timezone.now() + timedelta(days=1), ) - - -def create_simple_course(course_name: str, - package_instances: Iterable[PackageInstance], - sleep_intervals: float = 0, - time_offset: float = 0, - submits: Iterable[Dict[str, int]] = None, - create_db: bool = True): - """ - It creates a course with a round, a number of tasks, and a number of submits - - :param course_name: the name of the course to create - :type course_name: str - :param package_instances: a list of package ids that will be used to create tasks - :type package_instances: Iterable[PackageInstance] - :param sleep_intervals: The time between each task creation, defaults to 0 - :type sleep_intervals: float (optional) - :param time_offset: The time offset between the creation of the course and the creation of the first round, - defaults to 0 - :type time_offset: float (optional) - :param submits: a list of dictionaries, each dictionary has at least two keys: usr and task. - Optionally can contain pass_chance and allow_pending_status (described in create_random_task function) - :type submits: Iterable[Dict[str, int]] - :param create_db: If you want to create a new course, set this to True. - If you want to fill an existing course, set this to False, defaults to True - :type create_db: bool (optional) - """ - if create_db: - create_course(course_name) - - if submits is None: - usr = None - usr = User.objects.last() - submits = ({'usr': usr, 'task': 1},) - sleep(time_offset) - with InCourse(course_name): - r = Round.objects.create( - start_date=timezone.now(), - end_date=timezone.now() + timedelta(days=10), - deadline_date=timezone.now() + timedelta(days=20), - reveal_date=timezone.now() + new_round.save() + rounds.append(new_round) + return rounds + + +def create_package_task(course, round_, pkg_name, commit, init_task: bool = True): + if not PackageInstance.objects.filter(package_source__name=pkg_name, commit=commit).exists(): + pkg = PackageInstance.objects.create_source_and_instance(pkg_name, commit) + else: + pkg = PackageInstance.objects.get(package_source__name=pkg_name, commit=commit) + with InCourse(course): + task = Task.objects.create_task( + package_instance=pkg, + round_=round_, + task_name='Test task with package', + points=10, + initialise_task=init_task, ) - - sleep(sleep_intervals) - course_tasks = [] - for t in package_instances: - new_task = create_random_task(course_name, r, package_instance=t) - course_tasks.append(new_task) - sleep(sleep_intervals) - - for submit in submits: - create_random_submit( - course_name=course_name, - usr=submit['usr'], - parent_task=course_tasks[submit['task'] - 1], - pass_chance=submit.get('pass_chance', 0.5), - allow_pending_status=submit.get('allow_pending_status', False) - ) - - -def submitter(course_name: str, - usr, - task: Task, - submit_amount: int = 100, - time_interval: float = 0.1, - allow_pending_status: bool = False, - pass_chance: float = 0.5): - for i in range(submit_amount): - create_random_submit(course_name, usr, task, allow_pending_status=allow_pending_status, pass_chance=pass_chance) - sleep(time_interval) + task.save() + return task + + +def create_test_result(submit, test, status, course=None): + with OptionalInCourse(course): + result = Result.objects.create_result( + submit=submit, + test=test, + status=status, + time_real=0.5, + time_cpu=0.3, + runtime_memory=123, + ) + result.save() + return result + + +def create_task_results(course, submit, possible_results=(ResultStatus.OK,)): + unused_results = [r for r in possible_results] + with InCourse(course): + for task_set in submit.task.sets: + for test in task_set.tests: + if unused_results: + result = create_test_result(submit, test, choice(unused_results)) + unused_results.remove(result.status) + else: + create_test_result(submit, test, choice(possible_results)) + + +def create_submit(course, task, user, src_code): + with InCourse(course): + submit = Submit.objects.create_submit( + source_code=src_code, + task=task, + user=user, + auto_send=False, + ) + submit.save() + return submit -# It creates a simple course, adds a random submit to it, and scores it -class ASimpleTestCase(TestCase): - course_name = 'test_simple_course' - u1 = None - inst1 = None +class RoundTest(TestCase): + course = None + pkg = None @classmethod - def setUpClass(cls): - cls.u1 = User.objects.create_user( - 'user6@gmail.com', - 'user6', - 'psswd', - first_name='first name', - last_name='last name' + def setUpTestData(cls): + cls.course = Course.objects.create_course( + name='Test Course', + short_name='TC1', ) - inst_src = PackageSource.objects.create(name=f'course_testA') - cls.inst1 = PackageInstance.objects.create(package_source=inst_src, commit='ints1') - delete_course(cls.course_name) - create_simple_course(cls.course_name, create_db=True, package_instances=(cls.inst1, )) + cls.pkg = PackageInstance.objects.create_source_and_instance('dosko', '1') @classmethod def tearDownClass(cls): - delete_course(cls.course_name) - cls.u1.delete() - cls.inst1.delete() + Course.objects.delete_course(cls.course) + pkg_src = cls.pkg.package_source + cls.pkg.delete() + pkg_src.delete() - def test_create_simple_course(self): - """ - It checks that the course name is in the list of databases - """ - from BaCa2.settings import DATABASES - self.assertIn(self.course_name, DATABASES.keys()) + def tearDown(self): + with InCourse(self.course): + Round.objects.all().delete() - def test_simple_access(self): - """ - Test that we can access the database. - """ - with InCourse(self.course_name): - tasks = Task.objects.all() - self.assertTrue(len(tasks) > 0) - - def test_add_random_submit(self): - """ - It creates a random submit for a given task + def test_01_create_round(self): """ - with InCourse(self.course_name): - t = Task.objects.first() - create_random_submit(self.course_name, usr=self.u1, parent_task=t) - with InCourse(self.course_name): - sub = Submit.objects.last() - self.assertTrue(sub.task == t) - self.assertTrue(sub.final_score == -1) - - def test_score_simple_solution(self): + Test creating a round """ - It creates a random submit for the first task in the course, and then checks that the score of that submit is - between 0 and 1 - """ - with InCourse(self.course_name): - t = Task.objects.first() - create_random_submit(self.course_name, usr=self.u1, parent_task=t) + with InCourse(self.course): + round1 = Round.objects.create_round( + start_date=datetime(2020, 1, 1), + deadline_date=datetime(2020, 1, 2), + name='Test 1' + ) + round2 = Round.objects.create_round( + start_date=datetime(2020, 1, 3, tzinfo=dt_raw.timezone.utc), + deadline_date=datetime(2020, 1, 5, tzinfo=dt_raw.timezone.utc), + end_date=datetime(2020, 1, 4, tzinfo=dt_raw.timezone.utc), + reveal_date=datetime(2020, 1, 6, tzinfo=dt_raw.timezone.utc), + name='Test 2' + ) + round1.save() + round2.save() + + with InCourse(self.course): + self.assertEqual(Round.objects.count(), 2) + round_res = Round.objects.get( + start_date=datetime(2020, 1, 3, tzinfo=dt_raw.timezone.utc)) + self.assertEqual(round_res.end_date, + datetime(2020, 1, 4, tzinfo=dt_raw.timezone.utc)) + self.assertEqual(round_res.reveal_date, + datetime(2020, 1, 6, tzinfo=dt_raw.timezone.utc)) + self.assertEqual(round_res.name, 'Test 2') + + def test_02_validate_round(self): + with InCourse(self.course): + with self.assertRaises(ValidationError): + Round.objects.create_round( + start_date=datetime(2020, 1, 2), + deadline_date=datetime(2020, 1, 1), + ) - with InCourse(self.course_name): - sub = Submit.objects.last() - score = sub.score() - self.assertTrue(0 <= score <= 1) + def test_03_round_delete(self): + round_nb = 0 + with InCourse(self.course): + new_round = Round.objects.create_round( + start_date=timezone.now() - timedelta(days=1), + deadline_date=timezone.now() + timedelta(days=2), + end_date=timezone.now() + timedelta(days=1), + ) + new_round.save() + round_nb = new_round.pk + self.assertEqual(Round.objects.all().first(), new_round) + with InCourse(self.course): + Round.objects.delete_round(round_nb) + self.assertEqual(Round.objects.count(), 0) + + def test_04_round_advanced_delete(self): + with InCourse(self.course): + round1 = Round.objects.create_round( + start_date=datetime(2021, 1, 1), + deadline_date=datetime(2021, 1, 2), + ) + create_package_task(self.course, round1, 'dosko', '1', init_task=False) -# It creates two courses, each with a different set of tasks and submits, -# and then checks if the submits have the correct scores -class BMultiThreadTest(TestCase): - course1 = 'sample_course2' - course2 = 'sample_course3' - u1 = u2 = u3 = u4 = None - i1 = i2 = i3 = i4 = i5 = None + self.assertEqual(Task.objects.count(), 1) + self.assertEqual(Task.objects.first().task_name, 'Test task with package') - @classmethod - def setUpClass(cls): - """ - It creates two courses, - each with a different set of tasks and users, and each with a different - timeline + round1.delete() + self.assertEqual(Task.objects.count(), 0) - :param cls: the class object - """ - cls.u1 = User.objects.create_user( - 'user1@gmail.com', - 'user1', - 'psswd', - first_name='first name', - last_name='last name' - ) - cls.u2 = User.objects.create_user( - 'user2@gmail.com', - 'user2', - 'psswd', - first_name='first name', - last_name='last name' + def test_05_is_open(self): + with InCourse(self.course): + round1 = Round.objects.create_round( + start_date=timezone.now() - timedelta(days=1), + deadline_date=timezone.now() + timedelta(days=1), + ) + self.assertTrue(round1.is_open) + round1.delete() + + def generic_round_with_2_tasks(self, + init_tasks=False) -> Round: + round_ = Round.objects.create_round( + start_date=timezone.now() - timedelta(days=1), + deadline_date=timezone.now() + timedelta(days=1), ) - cls.u3 = User.objects.create_user( - 'user3@gmail.com', - 'user3', - 'psswd', - first_name='first name', - last_name='last name' + Task.objects.create_task( + task_name='Test Task 1', + package_instance=self.pkg, + round_=round_, + points=5, + initialise_task=init_tasks ) - cls.u4 = User.objects.create_user( - 'user4@gmail.com', - 'user4', - 'psswd', - first_name='first name', - last_name='last name' + Task.objects.create_task( + task_name='Test Task 2', + package_instance=self.pkg, + round_=round_, + points=5, + initialise_task=init_tasks ) + return round_ + + def test_06_tasks_listing(self): + with InCourse(self.course): + round1 = self.generic_round_with_2_tasks() + self.assertEqual(len(round1.tasks), 2) + round1.delete() + + def test_07_round_points(self): + with InCourse(self.course): + round1 = self.generic_round_with_2_tasks() + self.assertEqual(round1.round_points, 10) + round1.delete() + + @parameterized.expand([(3,), (10,), (100,)]) + def test_08_round_auto_name(self, amount): + create_rounds(self.course, amount) + with InCourse(self.course): + rounds_res = [r.name for r in Round.objects.all_rounds()] + self.assertEqual(len(rounds_res), amount) + for i, _ in enumerate(rounds_res): + self.assertIn(f'Round {i + 1}', rounds_res) + + +class TaskTestMain(TestCase): + course = None + round1 = None + round2 = None - inst_src = PackageSource.objects.create(name=f'course_testB') - cls.i1 = PackageInstance.objects.create(package_source=inst_src, commit='ints1') - cls.i2 = PackageInstance.objects.create(package_source=inst_src, commit='ints2') - cls.i3 = PackageInstance.objects.create(package_source=inst_src, commit='ints3') - cls.i4 = PackageInstance.objects.create(package_source=inst_src, commit='ints4') - cls.i5 = PackageInstance.objects.create(package_source=inst_src, commit='ints5') - - delete_course(cls.course1) - delete_course(cls.course2) - create1 = Thread(target=create_simple_course, args=(cls.course1,), - kwargs={ - 'time_offset': 2, - 'sleep_intervals': 0.5, - 'package_instances': (cls.i1, cls.i2, cls.i3, cls.i4, cls.i5), - 'submits': [ - {'usr': cls.u1, 'task': 1, 'pass_chance': 1}, - {'usr': cls.u1, 'task': 2}, - {'usr': cls.u2, 'task': 1}, - {'usr': cls.u3, 'task': 3, 'pass_chance': 0}, - {'usr': cls.u3, 'task': 1, 'pass_chance': 1}, - {'usr': cls.u1, 'task': 1, 'pass_chance': 0}, - ] - }) - create2 = Thread(target=create_simple_course, args=(cls.course2,), - kwargs={ - 'time_offset': 1, - 'sleep_intervals': 0.3, - 'package_instances': (cls.i1, cls.i2, cls.i3), - 'submits': [ - {'usr': cls.u3, 'task': 1, 'pass_chance': 1}, - {'usr': cls.u3, 'task': 2}, - {'usr': cls.u1, 'task': 1, 'pass_chance': 0}, - {'usr': cls.u2, 'task': 2, 'pass_chance': 0}, - {'usr': cls.u2, 'task': 1, 'pass_chance': 1}, - {'usr': cls.u3, 'task': 1, 'pass_chance': 0}, - ] - }) - - create1.start() - create2.start() - if create1.join() and create2.join(): - pass + @classmethod + def setUpTestData(cls): + cls.course = Course.objects.create_course( + name='Test Course', + short_name='TC2', + ) + cls.round1 = create_rounds(cls.course, 1)[0] + cls.round2 = create_rounds(cls.course, 1)[0] @classmethod def tearDownClass(cls): - delete_course(cls.course1) - delete_course(cls.course2) - cls.u1.delete() - cls.u2.delete() - cls.u3.delete() - cls.u4.delete() - - cls.i1.delete() - cls.i2.delete() - cls.i3.delete() - cls.i4.delete() - cls.i5.delete() - - def test_tasks_amount(self): - """ - It checks that the number of tasks in the database is correct - """ - with InCourse(self.course1): - self.assertEqual(Task.objects.count(), 5) - with InCourse(self.course2): - self.assertEqual(Task.objects.count(), 3) + Course.objects.delete_course(cls.course) - def test_usr1_submits_amount(self): - """ - It checks that user 1 has submitted 3 times in course 1 and 1 time in course 2 - """ - with InCourse(self.course1): - self.assertEqual(Submit.objects.filter(usr=self.u1.pk).count(), 3) - with InCourse(self.course2): - self.assertEqual(Submit.objects.filter(usr=self.u1.pk).count(), 1) - - def test_no_pending_score(self): - """ - It checks that the score of all the submissions is not -1. - """ - with InCourse(self.course1): - for s in Submit.objects.all(): - self.assertNotEqual(s.score(), -1) - with InCourse(self.course2): - for s in Submit.objects.all(): - self.assertNotEqual(s.score(), -1) - - def test_usr1_score(self): - """ - It tests that the score of the best submission of user 1 for task 1 in course 1 is 1, and that the - score of the best submission of user 1 for task 1 in course 2 is 0 - """ - self.test_no_pending_score() - with InCourse(self.course1): - t = Task.objects.filter(pk=1).first() - self.assertEqual(t.best_submit(usr=self.u1).score(), 1) + def tearDown(self): + with InCourse(self.course): + Task.objects.all().delete() - with InCourse(self.course2): - t = Task.objects.filter(pk=1).first() - self.assertEqual(t.best_submit(usr=self.u1).score(), 0) + def test_01_create_and_delete_task(self): + create_package_task(self.course, self.round1, 'dosko', '1', init_task=False) + with InCourse(self.course): + self.assertEqual(Task.objects.count(), 1) + self.assertEqual(Task.objects.get( + task_name='Test task with package').points, 10) - def test_without_InCourse_call(self): + def test_02_create_package_task_no_init(self): """ - It tests that the - `Task` model is not accessible without calling `InCourse` first + Creates a task with a package and then checks access to package instance """ - from BaCa2.exceptions import RoutingError - with self.assertRaises((LookupError, RoutingError)): - Task.objects.count() + task = create_package_task(self.course, self.round1, 'dosko', '1', init_task=False) + with InCourse(self.course): + self.assertEqual(Task.objects.count(), 1) + self.assertEqual(task.package_instance.package_source.name, 'dosko') + self.assertEqual(task.package_instance.commit, '1') + + def test_03_create_package_task_init(self): + task = create_package_task(self.course, self.round1, 'dosko', '1', init_task=True) + with InCourse(self.course): + pkg = task.package_instance.package + self.assertEqual(pkg['title'], 'Liczby Doskonałe') + self.assertEqual(len(task.sets), 4) + self.assertEqual(pkg.sets('set0')['name'], 'set0') + set0 = list(filter(lambda x: x.short_name == 'set0', task.sets))[0] + self.assertEqual(len(set0.tests), 2) + tests = [t.short_name for t in set0.tests] + self.assertIn('dosko0a', tests) + self.assertIn('dosko0b', tests) + + def test_04_init_task_delete_without_results(self): + try: + self.test_03_create_package_task_init() + except AssertionError: + self.skipTest('Creation of package test is not working properly') + self.tearDown() + + task = create_package_task(self.course, self.round2, 'dosko', '1', init_task=True) + with InCourse(self.course): + Task.objects.delete_task(task) + self.assertEqual(Task.objects.count(), 0, f'Tasks: {Task.objects.all()}') + self.assertEqual(TestSet.objects.count(), 0, f'TestSets: {TestSet.objects.all()}') + self.assertEqual(Test.objects.count(), 0, f'Tests: {Test.objects.all()}') + + +class TestTaskWithResults(TestCase): + course = None + round_ = None + user = None - def two_submiters(self, submits, time_interval): - """ - It creates two threads, one for each course, and each thread will submit a task for a certain amount of times, - with a certain time interval - - :param submits: the amount of submits each submiter will make - :param time_interval: the time interval between two submits - """ - with InCourse(self.course1): - t1 = Task.objects.filter(pk=4).first() - submiter1 = Thread(target=submitter, args=(self.course1, self.u4, t1), kwargs={ - 'submit_amount': submits, - 'pass_chance': 0, - 'time_interval': time_interval - }) - with InCourse(self.course2): - t2 = Task.objects.filter(pk=3).first() - submiter2 = Thread(target=submitter, args=(self.course2, self.u4, t2), kwargs={ - 'submit_amount': submits, - 'pass_chance': 1, - 'time_interval': time_interval - }) - - submiter1.start() - submiter2.start() - - if submiter1.join() and submiter2.join(): - with InCourse(self.course1): - for s in Submit.objects.all(): - self.assertEqual(s.score(), 0) - with InCourse(self.course2): - for s in Submit.objects.all(): - self.assertEqual(s.score(), 1) - - def test_two_submitters_A_small(self): - """ - This function tests the case where two submitters submit a small number of jobs - """ - self.two_submiters(50, 0.01) + @classmethod + def setUpTestData(cls): + cls.course = Course.objects.create_course( + name='Test Course', + short_name='TC3', + ) + cls.round_ = create_rounds(cls.course, 1)[0] + cls.task1 = create_package_task(cls.course, cls.round_, 'dosko', '1', init_task=True) + cls.task2 = create_package_task(cls.course, cls.round_, 'dosko', '1', init_task=True) + cls.user = User.objects.create_user( + email='test@test.com', + password='test', + ) - def test_two_submitters_B_big(self): - """ - This function tests the case where one submitter has a large number of submissions (without time interval) - """ - self.two_submiters(200, 0) + @classmethod + def tearDownClass(cls): + Course.objects.delete_course(cls.course) + cls.user.delete() + + def tearDown(self): + with InCourse(self.course): + Result.objects.all().delete() + Submit.objects.all().delete() + + def test_01_add_result(self): + submit = create_submit(self.course, self.task1, self.user, '1234.cpp') + create_task_results(self.course, submit) + with InCourse(self.course): + self.assertEqual(Test.objects.filter(test_set__task=self.task1).count(), + Result.objects.filter(submit__task=self.task1).count()) + self.assertTrue(all( + [r.status == ResultStatus.OK for r in submit.results] + )) + + @parameterized.expand([ + ('all ok', (ResultStatus.OK,)), + ('ANS and TLE', (ResultStatus.ANS, ResultStatus.TLE,))]) + def test_02_check_scoring(self, name, possible_results): + submit = create_submit(self.course, self.task1, self.user, '1234.cpp') + create_task_results(self.course, submit, possible_results) + with InCourse(self.course): + if name == 'all ok': + self.assertEqual(submit.score(), 1.0) + else: + self.assertEqual(submit.score(), 0) + + @parameterized.expand([ + ('best submit',), + ('last submit',), + ]) + def test_03_check_scoring_with_multiple_submits(self, name): + submit1 = create_submit(self.course, self.task1, self.user, '1234.cpp') + submit2 = create_submit(self.course, self.task1, self.user, '1234.cpp') + create_task_results(self.course, submit1) + create_task_results(self.course, submit2, (ResultStatus.ANS,)) + with InCourse(self.course): + if name == 'best submit': + best_submit = self.task1.best_submit(self.user) + self.assertEqual(best_submit.score(), 1.0) + self.assertEqual(best_submit, submit1) + if name == 'last submit': + last_submit = self.task1.last_submit(self.user) + self.assertEqual(last_submit.score(), 0) + self.assertEqual(last_submit, submit2) + + @parameterized.expand([ + ('best submit', 10), + ('last submit', 10), + ('best submit', 25), + ('last submit', 25), + ('best submit', 50), + ('last submit', 50), + ('best submit', 100), + ('last submit', 100), + # ('best submit', 500), + # ('last submit', 500), + ]) + def test_04_multiple_submits(self, name, submits_amount): + best_s = randint(0, submits_amount - 2) + for i in range(submits_amount): + submit = create_submit(self.course, self.task1, self.user, '1234.cpp') + if i == best_s: + create_task_results(self.course, submit) + else: + create_task_results(self.course, submit, + (ResultStatus.OK, ResultStatus.ANS, ResultStatus.TLE)) + with InCourse(self.course): + if name == 'best submit': + best_submit = self.task1.best_submit(self.user) + self.assertEqual(best_submit.score(), 1.0) + if name == 'last submit': + last_submit = self.task1.last_submit(self.user) + self.assertLess(last_submit.score(), 1) + self.assertGreater(last_submit.score(), 0) diff --git a/BaCa2/course/urls.py b/BaCa2/course/urls.py new file mode 100644 index 00000000..511beeee --- /dev/null +++ b/BaCa2/course/urls.py @@ -0,0 +1,28 @@ +from django.urls import path + +from .views import ( + CourseTask, + CourseView, + ResultModelView, + RoundEditView, + RoundModelView, + SubmitModelView, + SubmitSummaryView, + TaskEditView, + TaskModelView +) + +app_name = 'course' + +urlpatterns = [ + path('', CourseView.as_view(), name='course-view'), + path('round-edit/', RoundEditView.as_view(), name='round-edit-view'), + path('task//', CourseTask.as_view(), name='task-view'), + path('task//edit/', TaskEditView.as_view(), name='task-edit-view'), + path('submit//', SubmitSummaryView.as_view(), name='submit-summary-view'), + + path('models/round/', RoundModelView.as_view(), name='round-model-view'), + path('models/task/', TaskModelView.as_view(), name='task-model-view'), + path('models/submit/', SubmitModelView.as_view(), name='submit-model-view'), + path('models/result/', ResultModelView.as_view(), name='result-model-view') +] diff --git a/BaCa2/course/views.py b/BaCa2/course/views.py index 91ea44a2..86be53be 100644 --- a/BaCa2/course/views.py +++ b/BaCa2/course/views.py @@ -1,3 +1,1225 @@ -from django.shortcuts import render +import inspect +from abc import ABC, ABCMeta +from typing import Any, Callable, Dict, List, Union -# Create your views here. +from django.contrib.auth.mixins import UserPassesTestMixin +from django.http import JsonResponse +from django.template.response import TemplateResponse +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from core.choices import EMPTY_FINAL_STATUSES, BasicModelAction, ResultStatus, SubmitType +from course.models import Result, Round, Submit, Task +from course.routing import InCourse +from main.models import Course, User +from main.views import CourseModelView as CourseModelManagerView +from main.views import RoleModelView, UserModelView +from util.models_registry import ModelsRegistry +from util.responses import BaCa2JsonResponse +from util.views import BaCa2LoggedInView, BaCa2ModelView +from widgets.brief_result_summary import BriefResultSummary +from widgets.code_block import CodeBlock +from widgets.forms.course import ( + AddMemberFormWidget, + AddMembersFromCSVFormWidget, + AddRoleFormWidget, + CreateRoundForm, + CreateRoundFormWidget, + CreateSubmitForm, + CreateSubmitFormWidget, + CreateTaskForm, + CreateTaskFormWidget, + DeleteRoleForm, + DeleteRoundForm, + DeleteTaskForm, + EditRoundForm, + EditRoundFormWidget, + EditTaskForm, + EditTaskFormWidget, + RejudgeSubmitForm, + RejudgeTaskForm, + RejudgeTaskFormWidget, + RemoveMembersFormWidget, + ReuploadTaskForm, + ReuploadTaskFormWidget, + SimpleEditTaskForm, + SimpleEditTaskFormWidget +) +from widgets.listing import TableWidget, TableWidgetPaging +from widgets.listing.col_defs import RejudgeSubmitColumn +from widgets.listing.columns import DatetimeColumn, FormSubmitColumn, TextColumn +from widgets.navigation import SideNav +from widgets.text_display import TextDisplayer + +# ----------------------------------- Course views abstraction ---------------------------------- # + +class ReadCourseViewMeta(ABCMeta): + """ + Metaclass providing automated database routing for all course Views + """ + + DECORATOR_OFF = ['MODEL'] + + def __new__(cls, name, bases, dct): + """ + Creates new class with the same name, base and dictionary, but wraps all non-static, + non-class methods and properties with :py:meth`read_course_decorator` + + *Special method signature from* ``django.db.models.base.ModelBase`` + """ + new_class = super().__new__(cls, name, bases, dct) + + for base in bases: + new_class = cls.decorate_class(result_class=new_class, + attr_donor=base, + decorator=cls.read_course_decorator) + new_class = cls.decorate_class(result_class=new_class, + attr_donor=dct, + decorator=cls.read_course_decorator) + return new_class + + @staticmethod + def decorate_class(result_class, + attr_donor: type | dict, + decorator: Callable[[Callable, bool], Union[Callable, property]]) -> type: + """ + Decorates all non-static, non-class methods and properties of donor class with decorator + and adds them to result class. + + :param result_class: Class to which decorated methods will be added + :type result_class: type + :param attr_donor: Class from which methods will be taken, or dictionary of methods + :type attr_donor: type | dict + :param decorator: Decorator to be applied to methods + :type decorator: Callable[[Callable, bool], Union[Callable, property]] + + :returns: Result class with decorated methods + :rtype: type + + """ + if isinstance(attr_donor, type): + attr_donor = attr_donor.__dict__ + # Decorate all non-static, non-class methods with the hook method + for attr_name, attr_value in attr_donor.items(): + if all(((callable(attr_value) or isinstance(attr_value, property)), + not attr_name.startswith('_'), + not isinstance(attr_value, classmethod), + not isinstance(attr_value, staticmethod), + not inspect.isclass(attr_value), + attr_name not in ReadCourseViewMeta.DECORATOR_OFF)): + decorated_meth = decorator(attr_value, + isinstance(attr_value, property)) + decorated_meth.__doc__ = attr_value.__doc__ + setattr(result_class, + attr_name, + decorated_meth) + return result_class + + @staticmethod + def read_course_decorator(original_method, prop: bool = False): + """ + Decorator used to decode origin database from object. It wraps every operation inside + the object to be performed on meta-read database. + + :param original_method: Original method to be wrapped + :param prop: Indicates if original method is a property. + :type prop: bool + + :returns: Wrapped method + """ + + def wrapper_method(self, *args, **kwargs): + if InCourse.is_defined(): + result = original_method(self, *args, **kwargs) + else: + course_id = self.kwargs.get('course_id') + if not course_id: + raise CourseModelView.MissingCourseId('Course not defined in URL params') + with InCourse(course_id): + result = original_method(self, *args, **kwargs) + return result + + def wrapper_property(self): + if InCourse.is_defined(): + result = original_method.fget(self) + else: + course_id = self.kwargs.get('course_id') + if not course_id: + raise CourseModelView.MissingCourseId('Course not defined in URL params') + with InCourse(course_id): + result = original_method.fget(self) + return result + + if prop: + return property(wrapper_property) + return wrapper_method + + +class CourseModelView(BaCa2ModelView, ABC, metaclass=ReadCourseViewMeta): + """ + Base class for all views used to manage course db models and retrieve their data from the + front-end. GET requests directed at this view are used to retrieve serialized model data + while POST requests are handled in accordance with the particular course model form from which + they originate. + + See also: + - :py:class:`BaCa2ModelView` + - :py:class:`ReadCourseViewMeta` + """ + + class MissingCourseId(Exception): + """ + Raised when an attempt is made to construct a course model view URL without a course id. + """ + pass + + @classmethod + def _url(cls, **kwargs) -> str: + """ + :param kwargs: Keyword arguments to be used to construct the URL. Should contain a + `course_id` parameter. + :type kwargs: dict + :return: URL to the view + :rtype: str + """ + course_id = kwargs.get('course_id') + if not course_id: + raise cls.MissingCourseId('Course id required to construct URL') + + return f'/course/{course_id}/models/{cls.MODEL._meta.model_name}/' + + def check_get_all_permission(self, request, serialize_kwargs, **kwargs) -> bool: + """ + :param request: HTTP GET request object received by the view + :type request: HttpRequest + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :return: `True` if the user has permission to view all instances of the model assigned to + them in the course, `False` otherwise + :rtype: bool + """ + user = getattr(request, 'user') + course_id = self.kwargs.get('course_id') + return user.has_basic_course_model_permissions(model=self.MODEL, + course=course_id, + permissions=BasicModelAction.VIEW) + + +# ----------------------------------------- Model views ----------------------------------------- # + +class RoundModelView(CourseModelView): + """ + View used to retrieve serialized round model data to be displayed in the front-end and to + interface between POST requests and course model forms used to manage round instances. + """ + + MODEL = Round + + def post(self, request, **kwargs) -> BaCa2JsonResponse: + """ + Delegates the handling of the POST request to the appropriate form based on the `form_name` + parameter received in the request. + + :param request: HTTP POST request object received by the view + :type request: HttpRequest + :return: JSON response to the POST request containing information about the success or + failure of the request + :rtype: :py:class:`BaCa2JsonResponse` + """ + form_name = request.POST.get('form_name') + + if form_name == f'{Course.CourseAction.ADD_ROUND.label}_form': + return CreateRoundForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.EDIT_ROUND.label}_form': + return EditRoundForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.DEL_ROUND.label}_form': + return DeleteRoundForm.handle_post_request(request) + + return self.handle_unknown_form(request, **kwargs) + + +class TaskModelView(CourseModelView): + """ + View used to retrieve serialized task model data to be displayed in the front-end and to + interface between POST requests and course model forms used to manage task instances. + """ + + MODEL = Task + + def post(self, request, **kwargs) -> JsonResponse: + """ + Delegates the handling of the POST request to the appropriate form based on the `form_name` + parameter received in the request. + + :param request: HTTP POST request object received by the view + :type request: HttpRequest + :return: JSON response to the POST request containing information about the success or + failure of the request + :rtype: :py:class:`JsonResponse` + """ + form_name = request.POST.get('form_name') + + if form_name == f'{Course.CourseAction.ADD_TASK.label}_form': + return CreateTaskForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.REUPLOAD_TASK.label}_form': + return ReuploadTaskForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.EDIT_TASK.label}_form': + return SimpleEditTaskForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.DEL_TASK.label}_form': + return DeleteTaskForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.REJUDGE_TASK.label}_form': + return RejudgeTaskForm.handle_post_request(request) + + return self.handle_unknown_form(request, **kwargs) + + +class SubmitModelView(CourseModelView): + """ + View used to retrieve serialized submit model data to be displayed in the front-end and to + interface between POST requests and course model forms used to manage submit instances. + """ + + MODEL = Submit + + def post(self, request, **kwargs) -> JsonResponse: + """ + Delegates the handling of the POST request to the appropriate form based on the `form_name` + parameter received in the request. + + :param request: HTTP POST request object received by the view + :type request: HttpRequest + :return: JSON response to the POST request containing information about the success or + failure of the request + :rtype: :py:class:`JsonResponse` + """ + form_name = request.POST.get('form_name') + + if form_name == f'{Course.CourseAction.ADD_SUBMIT.label}_form': + return CreateSubmitForm.handle_post_request(request) + if form_name == f'{Course.CourseAction.REJUDGE_SUBMIT.label}_form': + return RejudgeSubmitForm.handle_post_request(request) + + return self.handle_unknown_form(request, **kwargs) + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[Submit], + request, + **kwargs) -> bool: + """ + :param filter_params: Parameters used to filter the query result + :type filter_params: dict + :param exclude_params: Parameters used to exclude the query result + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query result retrieved by the view + :type query_result: List[:class:`Submit`] + :param request: HTTP GET request object received by the view + :type request: HttpRequest + :return: `True` if the user has the view_own_submit permission and all the retrieved + submit instances are owned by the user, `False` otherwise + :rtype: bool + """ + user = getattr(request, 'user') + course = ModelsRegistry.get_course(self.kwargs.get('course_id')) + + if not user.has_course_permission(Course.CourseAction.VIEW_OWN_SUBMIT.label, course): + return False + + return all(submit.usr == user.pk for submit in query_result) + + +class ResultModelView(CourseModelView): + """ + View used to retrieve serialized result model data to be displayed in the front-end and to + interface between POST requests and course model forms used to manage result instances. + """ + + MODEL = Result + + def check_get_all_permission(self, request, serialize_kwargs, **kwargs) -> bool: + """ + :param request: HTTP GET request object received by the view + :type request: HttpRequest + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :return: Checks if the user has permission to view all results in the course. If + `serialize_kwargs` contains `include_time` and/or `include_memory` set to `True`, the + user also needs to have the view_used_time and/or view_used_memory permissions in the + course. + """ + user = getattr(request, 'user') + course = ModelsRegistry.get_course(self.kwargs.get('course_id')) + + if not user.has_course_permission(Course.CourseAction.VIEW_RESULT.label, course): + return False + + if serialize_kwargs.get('include_time') and not user.has_course_permission( + Course.CourseAction.VIEW_USED_TIME.label, course + ): + return False + + if serialize_kwargs.get('include_memory') and not user.has_course_permission( + Course.CourseAction.VIEW_USED_MEMORY.label, course + ): + return False + + return True + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[Result], + request, + **kwargs) -> bool: + """ + :param filter_params: Parameters used to filter the query result + :type filter_params: dict + :param exclude_params: Parameters used to exclude the query result + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query result retrieved by the view + :type query_result: List[:class:`Result`] + :param request: HTTP GET request object received by the view + :type request: HttpRequest + :return: `True` if the user has the view_own_result permission and all the retrieved + result instances are owned by the user. If `serialize_kwargs` contains + `include_time` and/or `include_memory` set to `True`, the user also needs to have + the view_used_time and/or view_used_memory permissions in the course. + :rtype: bool + """ + user = getattr(request, 'user') + course = ModelsRegistry.get_course(self.kwargs.get('course_id')) + + if not user.has_course_permission(Course.CourseAction.VIEW_OWN_RESULT.label, course): + return False + + if not all(result.submit.usr == user.pk for result in query_result): + return False + + if serialize_kwargs.get('include_time') and not user.has_course_permission( + Course.CourseAction.VIEW_USED_TIME.label, course + ): + return False + + if serialize_kwargs.get('include_memory') and not user.has_course_permission( + Course.CourseAction.VIEW_USED_MEMORY.label, course + ): + return False + + return True + + +# ------------------------------------- course member mixin ------------------------------------ # + +class CourseMemberMixin(UserPassesTestMixin): + """ + Mixin for views which require the user to be a member of a specific course referenced in the + URL of the view being accessed (as a `course_id` parameter). The mixin also allows superusers + to access the view. + + The mixin also allows for a specific role to be required for the user to access the view If the + `REQUIRED_ROLE` class attribute is set. + """ + + #: The required role for the user to access the view. If `None`, the user only needs to be a + #: member of the course. If not `None`, the user needs to have the specified role in the course. + #: Should be a string with the name of the role. + REQUIRED_ROLE = None + + def test_func(self) -> bool: + """ + :return: `True` if the user is a member of the course or a superuser, `False` otherwise. If + the `REQUIRED_ROLE` attribute is set, non-superusers also need to have the specified + role in the course to pass the test. + :rtype: bool + """ + kwargs = getattr(self, 'kwargs') + request = getattr(self, 'request') + + if not kwargs or not request: + raise TypeError('CourseMemberMixin requires "kwargs" and "request" attrs to be set') + + course_id = kwargs.get('course_id') + + if not course_id: + raise TypeError('CourseMemberMixin should only be used with views that have a ' + '"course_id" parameter in their URL') + + course = ModelsRegistry.get_course(course_id) + + if request.user.is_superuser: + return True + if not self.REQUIRED_ROLE: + return course.user_is_member(request.user) + if self.REQUIRED_ROLE == 'admin': + return course.user_is_admin(request.user) + + return course.user_has_role(request.user, self.REQUIRED_ROLE) + + +class CourseTemplateView(BaCa2LoggedInView, CourseMemberMixin): + def get(self, request, *args, **kwargs) -> TemplateResponse: + course_id = kwargs.get('course_id') + self.request.course_id = course_id + return super().get(request, *args, **kwargs) + + +# ----------------------------------------- User views ----------------------------------------- # + +class CourseView(CourseTemplateView): + template_name = 'course_admin.html' + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + user = getattr(self.request, 'user') + course_id = self.kwargs.get('course_id') + course = ModelsRegistry.get_course(course_id) + context['page_title'] = course.name + sidenav_tabs = ['Members', 'Roles', 'Rounds', 'Tasks', 'Results'] + sidenav_sub_tabs = {tab: [] for tab in sidenav_tabs} + + # members -------------------------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.VIEW_MEMBER.label, course): + sidenav_sub_tabs.get('Members').append('View members') + context['view_members_tab'] = 'view-members-tab' + + members_table = TableWidget( + name='members_table_widget', + title=_('Course members'), + request=self.request, + data_source=UserModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'roles__course': course_id}, + serialize_kwargs={'course': course_id}, + ), + cols=[TextColumn(name='first_name', header=_('First name')), + TextColumn(name='last_name', header=_('Last name')), + TextColumn(name='email', header=_('Email address')), + TextColumn(name='user_role', header=_('Role'))], + refresh_button=True, + ) + self.add_widget(context, members_table) + + if user.has_course_permission(Course.CourseAction.ADD_MEMBER.label, course): + sidenav_sub_tabs.get('Members').append('Add member') + context['add_member_tab'] = 'add-member-tab' + + add_member_form = AddMemberFormWidget(request=self.request, course_id=course_id) + self.add_widget(context, add_member_form) + + if user.has_course_permission(Course.CourseAction.ADD_MEMBERS_CSV.label, course): + sidenav_sub_tabs.get('Members').append('Add members from CSV') + context['add_members_csv_tab'] = 'add-members-from-csv-tab' + + add_members_csv_form = AddMembersFromCSVFormWidget(request=self.request, + course_id=course_id) + self.add_widget(context, add_members_csv_form) + + if user.has_course_permission(Course.CourseAction.DEL_MEMBER.label, course): + sidenav_sub_tabs.get('Members').append('Remove members') + context['remove_members_tab'] = 'remove-members-tab' + + remove_members_form = RemoveMembersFormWidget(request=self.request, course_id=course_id) + self.add_widget(context, remove_members_form) + + # roles ---------------------------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.VIEW_ROLE.label, course): + sidenav_sub_tabs.get('Roles').append('View roles') + context['view_roles_tab'] = 'view-roles-tab' + + roles_table_kwargs = { + 'name': 'roles_table_widget', + 'title': _('Roles'), + 'request': self.request, + 'data_source': RoleModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'course': course_id}, + ), + 'cols': [TextColumn(name='name', header=_('Role name')), + TextColumn(name='description', header=_('Description'))], + 'refresh_button': True, + 'link_format_string': '/main/role/[[id]]/' + } + + if user.has_course_permission(Course.CourseAction.DEL_ROLE.label, course): + roles_table_kwargs = roles_table_kwargs | { + 'allow_select': True, + 'allow_delete': True, + 'delete_form': DeleteRoleForm(), + 'data_post_url': CourseModelManagerView.post_url(**{'course_id': course_id}), + } + + self.add_widget(context, TableWidget(**roles_table_kwargs)) + + if user.has_course_permission(Course.CourseAction.ADD_ROLE.label, course): + sidenav_sub_tabs.get('Roles').append('Add role') + context['add_role_tab'] = 'add-role-tab' + + add_role_form = AddRoleFormWidget(request=self.request, course_id=course_id) + self.add_widget(context, add_role_form) + + # rounds --------------------------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.VIEW_ROUND.label, course): + sidenav_sub_tabs.get('Rounds').append('View rounds') + context['view_rounds_tab'] = 'view-rounds-tab' + + rounds_table_kwargs = { + 'name': 'rounds_table_widget', + 'title': _('Rounds'), + 'request': self.request, + 'data_source': RoundModelView.get_url(**{'course_id': course_id}), + 'cols': [TextColumn(name='name', header=_('Round name')), + DatetimeColumn(name='start_date', header=_('Start date')), + DatetimeColumn(name='end_date', header=_('End date')), + DatetimeColumn(name='deadline_date', header=_('Deadline date')), + DatetimeColumn(name='reveal_date', header=_('Reveal date')), + TextColumn(name='score_selection_policy', + header=_('Score selection policy'))], + 'refresh_button': True, + 'default_order_col': 'start_date', + 'default_order_asc': False, + } + + if user.has_course_permission(Course.CourseAction.EDIT_ROUND.label, course): + rounds_table_kwargs['link_format_string'] = (f'/course/{course_id}/round-edit/' + f'?tab=[[normalized_name]]-tab#') + + if user.has_course_permission(Course.CourseAction.DEL_ROUND.label, course): + rounds_table_kwargs = rounds_table_kwargs | { + 'allow_select': True, + 'allow_delete': True, + 'delete_form': DeleteRoundForm(), + 'data_post_url': RoundModelView.post_url(**{'course_id': course_id}), + } + + self.add_widget(context, TableWidget(**rounds_table_kwargs)) + + if user.has_course_permission(Course.CourseAction.ADD_ROUND.label, course): + sidenav_sub_tabs.get('Rounds').append('Add round') + context['add_round_tab'] = 'add-round-tab' + + add_round_form = CreateRoundFormWidget(request=self.request, course_id=course_id) + self.add_widget(context, add_round_form) + + # tasks ---------------------------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.VIEW_TASK.label, course): + sidenav_sub_tabs.get('Tasks').append('View tasks') + context['view_tasks_tab'] = 'view-tasks-tab' + + tasks_table_kwargs = { + 'name': 'tasks_table_widget', + 'title': _('Tasks'), + 'request': self.request, + 'data_source': TaskModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + exclude_params={'is_legacy': True}, + serialize_kwargs={'submitter': user, 'add_legacy_submits_amount': True}, + **{'course_id': course_id} + ), + 'cols': [TextColumn(name='name', header=_('Task name')), + TextColumn(name='round_name', header=_('Round')), + TextColumn(name='judging_mode', header=_('Judging mode')), + TextColumn(name='points', header=_('Max points')), + TextColumn(name='user_formatted_score', header=_('My score'))], + 'refresh_button': True, + 'default_order_col': 'round_name', + 'link_format_string': f'/course/{course_id}/task/[[id]]', + } + + if user.has_course_permission(Course.CourseAction.DEL_TASK.label, course): + tasks_table_kwargs = tasks_table_kwargs | { + 'allow_select': True, + 'allow_delete': True, + 'delete_form': DeleteTaskForm(), + 'data_post_url': TaskModelView.post_url(**{'course_id': course_id}), + } + + if user.has_course_permission(Course.CourseAction.REJUDGE_TASK.label, course): + tasks_table_kwargs['cols'] += [ + FormSubmitColumn( + name='rejudge_task', + form_widget=RejudgeTaskFormWidget(request=self.request, + course_id=course_id), + mappings={'task_id': 'id'}, + btn_icon='exclamation-triangle', + btn_text='[[legacy_submits_amount]]', + header_icon='clock-history', + condition_key='has_legacy_submits', + condition_value='true', + disabled_appearance=FormSubmitColumn.DisabledAppearance.ICON, + disabled_content='check-lg' + ) + ] + + self.add_widget(context, TableWidget(**tasks_table_kwargs)) + + if user.has_course_permission(Course.CourseAction.ADD_TASK.label, course): + sidenav_sub_tabs.get('Tasks').append('Add task') + context['add_task_tab'] = 'add-task-tab' + + add_task_form = CreateTaskFormWidget(request=self.request, course_id=course_id) + self.add_widget(context, add_task_form) + + # results -------------------------------------------------------------------------------- + + view_all_submits = user.has_course_permission(Course.CourseAction.VIEW_SUBMIT.label, + course) + view_own_submits = user.has_course_permission(Course.CourseAction.VIEW_OWN_SUBMIT.label, + course) + view_all_results = user.has_course_permission(Course.CourseAction.VIEW_RESULT.label, + course) + view_own_results = user.has_course_permission(Course.CourseAction.VIEW_OWN_RESULT.label, + course) + + if view_all_submits or view_own_submits: + sidenav_sub_tabs.get('Results').append('View results') + context['results_tab'] = 'results-tab' + + results_table_kwargs = { + 'name': 'results_table_widget', + 'title': _('Results'), + 'request': self.request, + 'cols': [TextColumn(name='task_name', header=_('Task name')), + DatetimeColumn(name='submit_date', header=_('Submit time')), + TextColumn(name='submit_status', header=_('Submit status')), + TextColumn(name='summary_score', header=_('Score'))], + 'refresh_button': True, + 'paging': TableWidgetPaging(page_length=50, + allow_length_change=True, + length_change_options=[10, 25, 50, 100]), + 'default_order_col': 'submit_date', + 'default_order_asc': False, + } + + if view_all_submits: + results_table_kwargs['data_source'] = SubmitModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + exclude_params={'submit_type': SubmitType.HID}, + serialize_kwargs={'add_round_task_name': True, + 'add_summary_score': True, }, + course_id=course_id + ) + results_table_kwargs['cols'].extend([ + TextColumn(name='user_first_name', header=_('Submitter first name')), + TextColumn(name='user_last_name', header=_('Submitter last name')) + ]) + else: + results_table_kwargs['data_source'] = SubmitModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'usr': user.id, + 'submit_type': SubmitType.STD}, + serialize_kwargs={'add_round_task_name': True, + 'add_summary_score': True, + 'add_falloff_info': True, }, + course_id=course_id + ) + results_table_kwargs['cols'].extend([ + TextColumn(name='fall_off_factor', header='Fall-off factor') + ]) + + if user.has_course_permission(Course.CourseAction.REJUDGE_SUBMIT.label, course): + results_table_kwargs['cols'].append(RejudgeSubmitColumn( + course_id=course_id, + request=self.request + )) + + if view_all_results or view_own_results: + results_table_kwargs['link_format_string'] = f'/course/{course_id}/submit/[[id]]/' + + self.add_widget(context, TableWidget(**results_table_kwargs)) + + # side nav ------------------------------------------------------------------------------- + + sidenav_tabs = [tab for tab in sidenav_tabs if sidenav_sub_tabs.get(tab)] + sidenav_sub_tabs = {tab: sub_tabs for tab, sub_tabs in sidenav_sub_tabs.items() + if len(sub_tabs) > 1} + + if len(sidenav_sub_tabs) > 1: + toggle_button = True + else: + toggle_button = False + + sidenav = SideNav(request=self.request, + collapsed=False, + toggle_button=toggle_button, + tabs=sidenav_tabs, + sub_tabs=sidenav_sub_tabs) + self.add_widget(context, sidenav) + + if context.get('view_members_tab') and 'Members' not in sidenav_sub_tabs: + context['view_members_tab'] = 'members-tab' + if context.get('add_members_tab') and 'Members' not in sidenav_sub_tabs: + context['add_members_tab'] = 'members-tab' + if context.get('remove_members_tab') and 'Members' not in sidenav_sub_tabs: + context['remove_members_tab'] = 'members-tab' + + if context.get('view_roles_tab') and 'Roles' not in sidenav_sub_tabs: + context['view_roles_tab'] = 'roles-tab' + if context.get('add_role_tab') and 'Roles' not in sidenav_sub_tabs: + context['add_role_tab'] = 'roles-tab' + + if context.get('view_rounds_tab') and 'Rounds' not in sidenav_sub_tabs: + context['view_rounds_tab'] = 'rounds-tab' + if context.get('add_round_tab') and 'Rounds' not in sidenav_sub_tabs: + context['add_round_tab'] = 'rounds-tab' + + if context.get('view_tasks_tab') and 'Tasks' not in sidenav_sub_tabs: + context['view_tasks_tab'] = 'tasks-tab' + if context.get('add_task_tab') and 'Tasks' not in sidenav_sub_tabs: + context['add_task_tab'] = 'tasks-tab' + + return context + + +class CourseTask(CourseTemplateView): + template_name = 'course_task.html' + + def test_func(self) -> bool: + if not super().test_func(): + return False + + user = getattr(self.request, 'user') + course_id = self.kwargs.get('course_id') + + return user.has_course_permission(Course.CourseAction.VIEW_TASK.label, course_id) + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + user = getattr(self.request, 'user') + course_id = self.kwargs.get('course_id') + course = ModelsRegistry.get_course(course_id) + task_id = self.kwargs.get('task_id') + task = ModelsRegistry.get_task(task_id, course_id) + context['page_title'] = task.task_name + sidenav_tabs = ['Description'] + + # description ---------------------------------------------------------------------------- + package = task.package_instance.package + + description_extension = package.doc_extension() + description_file = package.doc_path(description_extension) + kwargs = {} + + if package.doc_has_extension('pdf'): + pdf_path = task.package_instance.pdf_docs_path + + if description_extension == 'pdf': + description_file = pdf_path + else: + kwargs['pdf_download'] = pdf_path + + description_displayer = TextDisplayer(name='description', + file_path=description_file, + **kwargs) + self.add_widget(context, description_displayer) + + # edit task ------------------------------------------------------------------------------ + can_reupload = user.has_course_permission(Course.CourseAction.REUPLOAD_TASK.label, course) + can_edit = user.has_course_permission(Course.CourseAction.EDIT_TASK.label, course) + + if can_edit: + simple_edit_task_form = SimpleEditTaskFormWidget( + request=self.request, + course_id=course_id, + task_id=task_id, + ) + self.add_widget(context, simple_edit_task_form) + sidenav_tabs.append('Edit') + context['edit_tab'] = 'edit-tab' + if can_reupload: + reupload_package_form = ReuploadTaskFormWidget( + request=self.request, + course_id=course_id, + task_id=task_id, + ) + self.add_widget(context, reupload_package_form) + sidenav_tabs.append('Reupload') + context['reupload_tab'] = 'reupload-tab' + + # submit form ---------------------------------------------------------------------------- + + if task.can_submit(user): + sidenav_tabs.append('Submit') + context['submit_tab'] = 'submit-tab' + submit_form = CreateSubmitFormWidget(request=self.request, + course_id=course_id, + task_id=task_id) + self.add_widget(context, submit_form) + + # results list --------------------------------------------------------------------------- + + view_all_submits = user.has_course_permission(Course.CourseAction.VIEW_SUBMIT.label, + course) + view_own_submits = user.has_course_permission(Course.CourseAction.VIEW_OWN_SUBMIT.label, + course) + view_all_results = user.has_course_permission(Course.CourseAction.VIEW_RESULT.label, + course) + view_own_results = user.has_course_permission(Course.CourseAction.VIEW_OWN_RESULT.label, + course) + + if view_all_submits or view_own_submits: + sidenav_tabs.append('Results') + context['results_tab'] = 'results-tab' + + results_table_kwargs = { + 'name': 'results_table_widget', + 'title': _('Results'), + 'request': self.request, + 'cols': [DatetimeColumn(name='submit_date', header=_('Submit time')), + TextColumn(name='submit_status', header=_('Submit status')), + TextColumn(name='summary_score', header=_('Score')), + TextColumn(name='fall_off_factor', header='Fall-off factor')], + 'refresh_button': True, + 'paging': TableWidgetPaging(page_length=50, + allow_length_change=True, + length_change_options=[10, 25, 50, 100]), + 'default_order_col': 'submit_date', + 'default_order_asc': False, + } + + if view_all_submits: + results_table_kwargs['data_source'] = SubmitModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'task': task_id}, + serialize_kwargs={'add_round_task_name': True, + 'add_summary_score': True, + 'add_falloff_info': True}, + course_id=course_id + ) + results_table_kwargs['cols'].insert(0, TextColumn(name='user_first_name', + header=_('Name'))) + results_table_kwargs['cols'].insert(1, TextColumn(name='user_last_name', + header=_('Surname'))) + + else: + results_table_kwargs['data_source'] = SubmitModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'usr': user.id, + 'submit_type': SubmitType.STD, + 'task': task_id}, + serialize_kwargs={'add_round_task_name': True, + 'add_summary_score': True, + 'add_falloff_info': True}, + course_id=course_id + ) + + if view_all_results or view_own_results: + results_table_kwargs['link_format_string'] = f'/course/{course_id}/submit/[[id]]/' + + if user.has_course_permission(Course.CourseAction.REJUDGE_SUBMIT.label, course): + results_table_kwargs['cols'].append(RejudgeSubmitColumn( + course_id=course_id, + request=self.request + )) + + self.add_widget(context, TableWidget(**results_table_kwargs)) + + # side nav ------------------------------------------------------------------------------- + + sidenav = SideNav(request=self.request, + collapsed=True, + tabs=sidenav_tabs) + self.add_widget(context, sidenav) + + return context + + +class TaskEditView(CourseTemplateView): + template_name = 'task_edit.html' + + def test_func(self) -> bool: + if not super().test_func(): + return False + + user = getattr(self.request, 'user') + course_id = self.kwargs.get('course_id') + + return user.has_course_permission(Course.CourseAction.EDIT_TASK.label, course_id) + + def get_sidenav(self, task_form: EditTaskForm) -> SideNav: + sidenav = SideNav(request=self.request, + collapsed=True, + tabs=['General settings'], ) + for test_set in task_form.set_groups: + sidenav.add_tab( + tab_name=test_set['name'], + sub_tabs=[f'{test_set["name"]} settings'] + [ + test_set_test['name'] + for test_set_test in + test_set['test_groups'] + ] + ) + return sidenav + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + course_id = self.kwargs.get('course_id') + task_id = self.kwargs.get('task_id') + task = ModelsRegistry.get_task(task_id, course_id) + context['page_title'] = task.task_name + _(' - edit') + task_form = EditTaskFormWidget(request=self.request, course_id=course_id, task_id=task_id) + self.add_widget(context, task_form) + self.add_widget(context, task_form.get_sidenav(self.request)) + + return context + + +class RoundEditView(CourseTemplateView): + template_name = 'course_edit_round.html' + REQUIRED_ROLE = 'admin' + + def get_context_data(self, **kwargs) -> dict: + context = super().get_context_data(**kwargs) + + course_id = self.kwargs.get('course_id') + course = ModelsRegistry.get_course(course_id) + context['page_title'] = course.name + _(' - rounds') + rounds = course.rounds() + rounds = sorted(rounds, key=lambda x: x.name) + round_names = [r.name for r in rounds] + sidenav = SideNav(request=self.request, + collapsed=True, + tabs=round_names, ) + self.add_widget(context, sidenav) + + rounds_context = [] + form_instance_id = 0 + + for r in rounds: + round_edit_form = EditRoundFormWidget( + request=self.request, + course_id=course_id, + round_=r, + form_instance_id=form_instance_id, + ) + rounds_context.append({ + 'tab_name': SideNav.normalize_tab_name(r.name), + 'round_name': r.name, + 'round_edit_form': round_edit_form, + }) + form_instance_id += 1 + + context['rounds'] = rounds_context + + return context + + +class SubmitSummaryView(CourseTemplateView): + template_name = 'course_submit_summary.html' + + @staticmethod + def _display_test_summaries(user: User, course: Course) -> bool: + return any([ + user.has_course_permission(Course.CourseAction.VIEW_COMPILE_LOG.label, course), + user.has_course_permission(Course.CourseAction.VIEW_CHECKER_LOG.label, course), + user.has_course_permission(Course.CourseAction.VIEW_STUDENT_OUTPUT.label, course), + user.has_course_permission(Course.CourseAction.VIEW_BENCHMARK_OUTPUT.label, course), + ]) + + def test_func(self) -> bool: + if not super().test_func(): + return False + + course_id = self.kwargs.get('course_id') + course = ModelsRegistry.get_course(course_id) + user = getattr(self.request, 'user') + + if user.has_course_permission(Course.CourseAction.VIEW_RESULT.label, course): + return True + + submit_id = self.kwargs.get('submit_id') + submit = ModelsRegistry.get_submit(submit_id, course_id) + + if not submit.user == user: + return False + + return user.has_course_permission(Course.CourseAction.VIEW_OWN_RESULT.label, course) + + def get_context_data(self, **kwargs) -> Dict[str, Any]: + context = super().get_context_data(**kwargs) + user = getattr(self.request, 'user') + course_id = self.kwargs.get('course_id') + submit_id = self.kwargs.get('submit_id') + submit = ModelsRegistry.get_submit(submit_id, course_id) + + with InCourse(course_id): + task = submit.task + + course = ModelsRegistry.get_course(course_id) + context['page_title'] = f'{task.task_name} - submit #{submit.pk}' + + sidenav = SideNav(request=self.request, + collapsed=True, + tabs=['Summary']) + + context['summary_tab'] = 'summary-tab' + + # summary table -------------------------------------------------------------------------- + + submit_summary = [ + {'title': _('Course'), 'value': course.name}, + {'title': _('Round'), 'value': task.round_.name}, + {'title': _('Task'), 'value': task.task_name}, + {'title': _('User'), 'value': submit.user.get_full_name()}, + {'title': _('Submit time'), + 'value': submit.submit_date.astimezone(timezone.get_current_timezone()).strftime( + '%Y-%m-%d %H:%M:%S')}, + {'title': _('Submit status'), 'value': submit.formatted_submit_status}, + ] + if submit.submit_status != ResultStatus.PND: + submit_summary.extend([ + {'title': _('Score'), 'value': submit.summary_score}, + {'title': _('Fall-off factor'), + 'value': Submit.format_score(submit.fall_off_factor)}, + ]) + + summary_table = TableWidget( + name='summary_table_widget', + request=self.request, + cols=[ + TextColumn(name='title', sortable=False), + TextColumn(name='value', sortable=False) + ], + data_source=submit_summary, + title=_('Summary') + f' - {_("submit")} #{submit.pk}', + allow_global_search=False, + hide_col_headers=True, + default_sorting=False, + ) + self.add_widget(context, summary_table) + + # solution code -------------------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.VIEW_CODE.label, course): + sidenav.add_tab(_('Code')) + context['code_tab'] = 'code-tab' + source_code = CodeBlock( + name='source_code_block', + title=_('Source code'), + code=submit.source_code_path, + ) + self.add_widget(context, source_code) + + # pending status ------------------------------------------------------------------------- + + if submit.submit_status in EMPTY_FINAL_STATUSES + [ResultStatus.PND]: + context['sets'] = [] + self.add_widget(context, sidenav) + return context + + # sets ----------------------------------------------------------------------------------- + + sets = task.sets + sets = sorted(sets, key=lambda x: x.short_name) + sets_list = [] + + results_to_parse = sorted(submit.results, key=lambda x: x.test_.short_name) + results = {} + + for res in results_to_parse: + test_set_id = res.test_.test_set_id + if test_set_id not in results: + results[test_set_id] = {} + results[test_set_id][res.test_id] = res + + view_used_time = user.has_course_permission(Course.CourseAction.VIEW_USED_TIME.label, + course) + view_used_memory = user.has_course_permission(Course.CourseAction.VIEW_USED_MEMORY.label, + course) + display_test_summaries = self._display_test_summaries(user, course) + + for s in sets: + set_context = { + 'set_name': s.short_name, + 'set_id': s.pk, + 'tests': [], + } + + if display_test_summaries: + sidenav.add_tab(tab_name=s.short_name) + + serialize_kwargs = {'include_time': False, 'include_memory': False} + + if view_used_time: + serialize_kwargs['include_time'] = True + if view_used_memory: + serialize_kwargs['include_memory'] = True + + cols = [ + TextColumn(name='test_name', header=_('Test')), + TextColumn(name='f_status', header=_('Status')), + ] + + if view_used_time: + cols.append(TextColumn(name='f_time_real', + header=_('Time'), + searchable=False)) + if view_used_memory: + cols.append(TextColumn(name='f_runtime_memory', + header=_('Memory'), + searchable=False)) + + set_summary = TableWidget( + name=f'set_{s.pk}_summary_table_widget', + request=self.request, + data_source=ResultModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'submit': submit_id, 'test__test_set_id': s.pk}, + serialize_kwargs=serialize_kwargs, + course_id=course_id, + ), + cols=cols, + title=f'{_("Set")} {s.short_name} - {_("weight:")} {s.weight}', + default_order_col='test_name', + ) + set_context['table_widget'] = set_summary.get_context() + + # test results ----------------------------------------------------------------------- + + tests = sorted(s.tests, key=lambda x: x.short_name) + + show_compile_log = user.has_course_permission( + Course.CourseAction.VIEW_COMPILE_LOG.label, + course + ) + show_checker_log = user.has_course_permission( + Course.CourseAction.VIEW_CHECKER_LOG.label, + course + ) + + for test in tests: + brief_result_summary = BriefResultSummary( + set_name=s.short_name, + test_name=test.short_name, + result=results[s.pk][test.pk], + include_time=view_used_time, + include_memory=view_used_memory, + show_compile_log=show_compile_log, + show_checker_log=show_checker_log, + ) + set_context['tests'].append(brief_result_summary.get_context()) + + sets_list.append(set_context) + + context['display_test_summaries'] = display_test_summaries + context['sets'] = sets_list + + if len(sidenav.tabs) > 1: + context['display_sidenav'] = True + self.add_widget(context, sidenav) + + return context diff --git a/BaCa2/docs/source/_static/gh_icon.svg b/BaCa2/docs/source/_static/gh_icon.svg new file mode 100644 index 00000000..72a3da35 --- /dev/null +++ b/BaCa2/docs/source/_static/gh_icon.svg @@ -0,0 +1 @@ + diff --git a/BaCa2/docs/source/conf.py b/BaCa2/docs/source/conf.py index 392dcc1b..81217fad 100644 --- a/BaCa2/docs/source/conf.py +++ b/BaCa2/docs/source/conf.py @@ -1,10 +1,10 @@ -import sys import os +import sys + import django -import sphinx_rtd_theme sys.path.insert(0, os.path.abspath('../..')) -os.environ['DJANGO_SETTINGS_MODULE'] = 'BaCa2.settings' +os.environ['DJANGO_SETTINGS_MODULE'] = 'core.settings' django.setup() # Configuration file for the Sphinx documentation builder. @@ -16,22 +16,13 @@ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information project = 'BaCa2' -copyright = '2023, Bartosz Deptuła, Małgorzata Drąg, Izabela Golec, Krzysztof Kalita; supervisor: PhD Tomasz Kapela' -author = 'Bartosz Deptuła, Małgorzata Drąg, Izabela Golec, Krzysztof Kalita; supervisor: PhD Tomasz Kapela' -release = '0.1.2' +copyright = '2023, Bartosz Deptuła, Mateusz Kadula, Krzysztof Kalita' # noqa: A001 +author = 'Bartosz Deptuła, Mateusz Kadula, Krzysztof Kalita' # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [ - 'sphinx.ext.githubpages', - 'sphinx.ext.duration', - 'sphinx.ext.doctest', - 'sphinx.ext.autodoc', - - 'sphinx_rtd_theme', - # 'sphinx.ext.autosummary', -] +extensions = ['sphinx.ext.todo', 'sphinx.ext.viewcode', 'sphinx.ext.autodoc'] templates_path = ['_templates'] exclude_patterns = [] @@ -41,4 +32,5 @@ html_theme = 'sphinx_rtd_theme' html_static_path = ['_static'] + autodoc_member_order = 'bysource' diff --git a/BaCa2/docs/source/index.rst b/BaCa2/docs/source/index.rst index f731d3fb..b082d79c 100644 --- a/BaCa2/docs/source/index.rst +++ b/BaCa2/docs/source/index.rst @@ -1,27 +1,33 @@ -BaCa2 technical documentation -============================= - -**BaCa2** is automated checking system for programming tasks. Firstly -developed for Jagiellonian University in Cracow, Poland. Main advantage of this system is -either simplicity of usage and complexity of possible to be checked tasks. - -.. important:: - This project is under active development. Documentation may be defective. +Welcome to BaCa2's documentation! +================================= .. toctree:: :maxdepth: 2 + :caption: Overview: - modules/description - usage + modules/overview/install + modules/overview/structure -.. toctree:: - :maxdepth: 2 - :caption: Apps: - modules/main - modules/package - modules/course +.. toctree:: + :maxdepth: 2 + :caption: Apps: + + modules/apps/main/index + modules/apps/course/index + modules/apps/package/index + modules/apps/util/index + modules/apps/broker_api/index + +.. toctree:: + :maxdepth: 2 + :caption: Widgets: + modules/widgets/base + modules/widgets/navigation + modules/widgets/forms/index + modules/widgets/listing/index + modules/widgets/popups/index Indices and tables diff --git a/BaCa2/docs/source/modules/apps/broker_api/index.rst b/BaCa2/docs/source/modules/apps/broker_api/index.rst new file mode 100644 index 00000000..6d025d3e --- /dev/null +++ b/BaCa2/docs/source/modules/apps/broker_api/index.rst @@ -0,0 +1,9 @@ +Broker API +========== + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + models + views diff --git a/BaCa2/docs/source/modules/apps/broker_api/models.rst b/BaCa2/docs/source/modules/apps/broker_api/models.rst new file mode 100644 index 00000000..7f38bb38 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/broker_api/models.rst @@ -0,0 +1,11 @@ +.. module:: broker_api.models + +BrokerSubmit model +------------------ + +Only model of Broker API app is ``BrokerSubmit``. It is used to store data about submits sent to broker, +and helps handling responses. + +.. autoclass:: BrokerSubmit + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/apps/broker_api/views.rst b/BaCa2/docs/source/modules/apps/broker_api/views.rst new file mode 100644 index 00000000..9e8aa9e1 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/broker_api/views.rst @@ -0,0 +1,5 @@ +Views module +------------ + +.. automodule:: broker_api.views + :members: diff --git a/BaCa2/docs/source/modules/apps/course/index.rst b/BaCa2/docs/source/modules/apps/course/index.rst new file mode 100644 index 00000000..fa2f8d83 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/course/index.rst @@ -0,0 +1,20 @@ +Course app +========== + +Course app is a template-like app, used to create and manage courses. Course is set up as a separated database, +so Course models have to use ``InCourse`` or ``OptionalInCourse`` decorators, to be accessed properly. + +Course app is divided into 3 parts: + +1. Manager - provides methods to create and delete courses. +2. Routing - defines ``ContextCourseRouter`` used to route requests to proper course database. Also defines ``InCourse`` and ``OptionalInCourse`` decorators, used to access course models. +3. Models - defines ``Course`` models, which are used to store information about courses. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + manager + routing + models + views diff --git a/BaCa2/docs/source/modules/apps/course/manager.rst b/BaCa2/docs/source/modules/apps/course/manager.rst new file mode 100644 index 00000000..4f07c291 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/course/manager.rst @@ -0,0 +1,5 @@ +Manager +""""""" + +.. automodule:: course.manager + :members: diff --git a/BaCa2/docs/source/modules/apps/course/models.rst b/BaCa2/docs/source/modules/apps/course/models.rst new file mode 100644 index 00000000..0b56335a --- /dev/null +++ b/BaCa2/docs/source/modules/apps/course/models.rst @@ -0,0 +1,56 @@ +.. module:: course.models + +Models +------ + +.. autoclass:: ReadCourseMeta + :members: + :special-members: __new__ + +.. autoclass:: RoundManager + :members: + :private-members: + +.. autoclass:: Round + :members: + :private-members: + +.. autoclass:: TaskManager + :members: + :private-members: + +.. autoclass:: Task + :members: + :private-members: + +.. autoclass:: TestSetManager + :members: + :private-members: + +.. autoclass:: TestSet + :members: + :private-members: + +.. autoclass:: TestManager + :members: + :private-members: + +.. autoclass:: Test + :members: + :private-members: + +.. autoclass:: SubmitManager + :members: + :private-members: + +.. autoclass:: Submit + :members: + :private-members: + +.. autoclass:: ResultManager + :members: + :private-members: + +.. autoclass:: Result + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/apps/course/routing.rst b/BaCa2/docs/source/modules/apps/course/routing.rst new file mode 100644 index 00000000..de24516c --- /dev/null +++ b/BaCa2/docs/source/modules/apps/course/routing.rst @@ -0,0 +1,15 @@ +.. module:: course.routing + +Routing +""""""" + +.. autoclass:: ContextCourseRouter + :members: + :private-members: _get_context + :inherited-members: + +.. autoclass:: InCourse + :members: + +.. autoclass:: OptionalInCourse + :members: diff --git a/BaCa2/docs/source/modules/apps/course/views.rst b/BaCa2/docs/source/modules/apps/course/views.rst new file mode 100644 index 00000000..4e70b3ad --- /dev/null +++ b/BaCa2/docs/source/modules/apps/course/views.rst @@ -0,0 +1,10 @@ +.. module:: course.views + +Views +----- + +Course views are defined as dependant on the ``InCourse`` context. These views can work only with specified course. + +.. autoclass:: CourseView + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/apps/main/index.rst b/BaCa2/docs/source/modules/apps/main/index.rst new file mode 100644 index 00000000..6342c68c --- /dev/null +++ b/BaCa2/docs/source/modules/apps/main/index.rst @@ -0,0 +1,15 @@ +Main app +======== + +Application responsible for the main functionality of the project. Mainly: + +* User-related management like registration, login, password reset, etc. +* Permission management (permissions for application management, controlling access to courses, etc.) +* Course management (creating, editing, deleting courses, etc.) + +.. toctree:: + :maxdepth: 3 + :caption: Components: + + models + views diff --git a/BaCa2/docs/source/modules/apps/main/models.rst b/BaCa2/docs/source/modules/apps/main/models.rst new file mode 100644 index 00000000..18faac74 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/main/models.rst @@ -0,0 +1,60 @@ +.. module:: main.models + +Models +====== + +Main models are divided into 3 groups: + +1. User related models: ``UserManager``, ``User``, ``Settings`` +2. Permission management: ``RoleManager``, ``Role``, ``RolePresetManager``, ``RolePreset``, ``RolePresetUser`` +3. Course control models: ``CourseManager``, ``Course`` + +User related models +""""""""""""""""""" + +.. autoclass:: UserManager + :members: + :private-members: + +.. autoclass:: User + :members: + :private-members: + +.. autoclass:: Settings + :members: + :private-members: + +Permission management +""""""""""""""""""""" + +.. autoclass:: RoleManager + :members: + :private-members: + +.. autoclass:: Role + :members: + :private-members: + +.. autoclass:: RolePresetManager + :members: + :private-members: + +.. autoclass:: RolePreset + :members: + :private-members: + +.. autoclass:: RolePresetUser + :members: + :private-members: + +Course control models +""""""""""""""""""""" + +.. autoclass:: CourseManager + :members: + :private-members: + +.. autoclass:: Course + :members: + :exclude-members: Round, Task, Submit + :private-members: diff --git a/BaCa2/docs/source/modules/apps/main/views.rst b/BaCa2/docs/source/modules/apps/main/views.rst new file mode 100644 index 00000000..165849e6 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/main/views.rst @@ -0,0 +1,52 @@ +.. module:: main.views + +Views +----- + +The Views module of ``main`` app contains abstract definition of project View (``BaCa2ModelView``) and definitions +for other main views, related with models from ``main`` app. Also login and dashboard views are defined here. + +Model-related views +""""""""""""""""""" + +.. autoclass:: UserModelView + :members: + :private-members: + +.. autoclass:: CourseModelView + :members: + :private-members: + +Authentication views +"""""""""""""""""""" + +.. autoclass:: BaCa2LoginView + :members: + :private-members: + +.. autoclass:: BaCa2LogoutView + :members: + :private-members: + +Admin views +""""""""""" + +.. autoclass:: AdminView + :members: + :private-members: + +Application views +""""""""""""""""" + +.. autoclass:: DashboardView + :members: + :private-members: + +.. autoclass:: CoursesView + :members: + :private-members: + +Management functions +"""""""""""""""""""" + +.. autofunction:: change_theme diff --git a/BaCa2/docs/source/modules/apps/package/index.rst b/BaCa2/docs/source/modules/apps/package/index.rst new file mode 100644 index 00000000..0550407a --- /dev/null +++ b/BaCa2/docs/source/modules/apps/package/index.rst @@ -0,0 +1,17 @@ +Package app +=========== + +Package app is used to store package instances and manage them, without having +to worry about the underlying files. + +.. note:: + + Structure is prepared to save every package instance as GIT commit, and every + package source as GIT repo. For now however, every instance is saved as a copy. + +.. toctree:: + :maxdepth: 3 + :caption: Components: + + models + views diff --git a/BaCa2/docs/source/modules/apps/package/models.rst b/BaCa2/docs/source/modules/apps/package/models.rst new file mode 100644 index 00000000..f2e397d7 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/package/models.rst @@ -0,0 +1,41 @@ +.. module:: package.models + +Models +------ + +Are divided into 2 categories: + +1. Package management - models that are used to manage packages +2. Package access - models that are used to setup and control who can access packages + + +Package Management +"""""""""""""""""" + +.. autoclass:: PackageSourceManager + :members: + :private-members: + +.. autoclass:: PackageSource + :members: + :private-members: + +.. autoclass:: PackageInstanceManager + :members: + :private-members: + +.. autoclass:: PackageInstance + :members: + :private-members: + + +Package Access +"""""""""""""" + +.. autoclass:: PackageInstanceUserManager + :members: + :private-members: + +.. autoclass:: PackageInstanceUser + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/apps/package/views.rst b/BaCa2/docs/source/modules/apps/package/views.rst new file mode 100644 index 00000000..7fc2cff1 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/package/views.rst @@ -0,0 +1,7 @@ +views +------------ + +.. automodule:: package.views + :members: + :undoc-members: + :show-inheritance: diff --git a/BaCa2/docs/source/modules/apps/util/index.rst b/BaCa2/docs/source/modules/apps/util/index.rst new file mode 100644 index 00000000..04ea13a7 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/util/index.rst @@ -0,0 +1,14 @@ +Util app +======== + +Util is an app used to store common functionalities, view classes and mixins used by other apps. +It does not contain any model classes or views directly accessible by the user, and is not intended to be used as a standalone app. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + models + views + models_registry + other diff --git a/BaCa2/docs/source/modules/apps/util/models.rst b/BaCa2/docs/source/modules/apps/util/models.rst new file mode 100644 index 00000000..1630cf13 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/util/models.rst @@ -0,0 +1,13 @@ +.. module:: util.models + +Models +------ + +Util app does not posses any model classes. Its model module is used to store model-related utility functions used across the application. + +.. autofunction:: util.models.get_all_permissions_for_model +.. autofunction:: util.models.get_all_models_from_app +.. autofunction:: util.models.get_model_permission_by_label +.. autofunction:: util.models.get_model_permissions +.. autofunction:: util.models.delete_populated_group +.. autofunction:: util.models.delete_populated_groups diff --git a/BaCa2/docs/source/modules/apps/util/models_registry.rst b/BaCa2/docs/source/modules/apps/util/models_registry.rst new file mode 100644 index 00000000..d0628261 --- /dev/null +++ b/BaCa2/docs/source/modules/apps/util/models_registry.rst @@ -0,0 +1,10 @@ +.. module:: util.models_registry + +Models registry +--------------- + +Models registry contains a single central interface for retrieving model instances based on their identifiers. + +.. autoclass:: ModelsRegistry + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/apps/util/other.rst b/BaCa2/docs/source/modules/apps/util/other.rst new file mode 100644 index 00000000..6e603cce --- /dev/null +++ b/BaCa2/docs/source/modules/apps/util/other.rst @@ -0,0 +1,10 @@ +.. module:: util.other + +Other +----- + +A small module containing miscellaneous utility functions. + +.. autofunction:: add_kwargs_to_url +.. autofunction:: normalize_string_to_python +.. autofunction:: replace_special_symbols diff --git a/BaCa2/docs/source/modules/apps/util/views.rst b/BaCa2/docs/source/modules/apps/util/views.rst new file mode 100644 index 00000000..574e9abf --- /dev/null +++ b/BaCa2/docs/source/modules/apps/util/views.rst @@ -0,0 +1,22 @@ +.. module:: util.views + +Views +----- + +Util app does not posses any views directly accessible by the user. Instead, its view module stores commonly used abstract base classes for other apps to use in their class-based views. It also stores the ``BaCa2ContextMixin`` class which all template views across the project inherit from. + +.. autoclass:: BaCa2ContextMixin + :members: + :private-members: + +.. autoclass:: BaCa2ModelView + :members: + :private-members: + +.. autoclass:: BaCa2LoggedInView + :members: + :private-members: + +.. autoclass:: FieldValidationView + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/course.rst b/BaCa2/docs/source/modules/course.rst deleted file mode 100644 index 63514f3e..00000000 --- a/BaCa2/docs/source/modules/course.rst +++ /dev/null @@ -1,75 +0,0 @@ -Course -====== - -Course structure is based on 3 main parts: - -1. Django models - -2. Django router and context manager - -3. Dynamic database creator - - -Models ------- - -.. autoclass:: course.models.Round - :members: start_date, end_date, deadline_date, reveal_date, validate - -.. autoclass:: course.models.Task - :members: package_instance_id, task_name, round, judging_mode, points, create_new, sets, package_instance, - last_submit, best_submit, check_instance - -.. autoclass:: course.models.TestSet - :members: task, short_name, weight, tests - -.. autoclass:: course.models.Submit - :members: submit_date, source_code, task, usr, final_score, create_new, user, score - -.. autoclass:: course.models.Result - :members: test, submit, status - -DB Router ---------- - -.. autoclass:: course.routing.SimpleCourseRouter - :members: - -.. autoclass:: course.routing.ContextCourseRouter - :members: - :private-members: _get_context - -.. autoclass:: course.routing.InCourse - :members: - - -Dynamic DB creator ------------------- - -Course manager -.............. - -To simplify usage there is a module :py:mod:`course.manager`. It gives away 2 functions described below. - -.. automodule:: course.manager - :members: - -DB creator -.......... - -Course manager module uses functions from :py:mod:`BaCa2.db.creator`. - -.. important:: - Managing actions are in critical section. Multithread access may crush the application. That's why following lock - is used: - - - .. automodule:: BaCa2.db.creator - :private-members: _db_root_access - - Only 2 functions need to acquire that lock. - -Finally these functions are given out by creator module: - -.. automodule:: BaCa2.db.creator - :members: diff --git a/BaCa2/docs/source/modules/description.rst b/BaCa2/docs/source/modules/description.rst deleted file mode 100644 index 2cb1c6a6..00000000 --- a/BaCa2/docs/source/modules/description.rst +++ /dev/null @@ -1,16 +0,0 @@ -Structure description -===================== - -Apps ----- - -There are 3 django apps: - -#. ``main`` - main application, overwrites basic User model and generates permissions network - for connection with course. -#. ``package`` - connects database with files-stored packages. -#. ``course`` - implements course interactions. Every course has own databases, dynamically created. - -And one non-django app named ``baca2-broker`` which translates submits to be understood by -`KOLEJKA system `_. - diff --git a/BaCa2/docs/source/modules/main.rst b/BaCa2/docs/source/modules/main.rst deleted file mode 100644 index abe937b8..00000000 --- a/BaCa2/docs/source/modules/main.rst +++ /dev/null @@ -1,27 +0,0 @@ -Main app -======== - -.. autoclass:: main.models.UserManager - :members: create_user, create_superuser - :private-members: _create_user - - -.. autoclass:: main.models.Course - :members: name, short_name, db_name, add_user - :special-members: __str__ - - -.. autoclass:: main.models.User - :members: - email, username, is_staff, is_superuser, first_name, last_name, date_joined, - USERNAME_FIELD, EMAIL_FIELD, REQUIRED_FIELDS, objects, - exists, can_access_course, check_general_permissions, check_course_permissions - - -.. autoclass:: main.models.GroupCourse - :members: group, course - - -.. autoclass:: main.models.UserCourse - :members: user, course, group_course - diff --git a/BaCa2/docs/source/modules/overview/install.rst b/BaCa2/docs/source/modules/overview/install.rst new file mode 100644 index 00000000..4254a668 --- /dev/null +++ b/BaCa2/docs/source/modules/overview/install.rst @@ -0,0 +1,86 @@ +Installation guide +================== + +.. note:: + This application is prepared to work with some kind of automated checker backend + (e.g. `KOLEJKA `_) and broker to translate communication between them. + + Our app provides fully functional broker to work with KOLEJKA backend. + +Development installation +------------------------ +To install main django web app locally (for development purposes) follow this process: + +1. Install prerequisites: + + a. Python 3.11 or newer & pip (`link to Python downloads `_) + b. PostgreSQL 16 (`link to PostgreSQL downloads `_) + +2. Install poetry (`about installing poetry `_) + + .. code-block:: console + + pip install poetry + +3. Install project dependencies - inside project directory run: + + .. code-block:: console + + python3 -m poetry install + +4. Prepare postgres server: + + a. Create new database: ```baca2db``` + b. Create new user ```root```. This db user will be used to managed auto-created databases. + + .. code-block:: postgresql + + CREATE ROLE root WITH + LOGIN + SUPERUSER + CREATEDB + CREATEROLE + INHERIT + REPLICATION + CONNECTION LIMIT -1 + PASSWORD 'BaCa2root'; + + GRANT postgres TO root WITH ADMIN OPTION; + COMMENT ON ROLE root IS 'root db user for db management purposes'; + + c. Create new user ```baca2```. This user will be used for most of app operations. + + .. code-block:: postgresql + + CREATE ROLE baca2 + SUPERUSER + CREATEDB + CREATEROLE + REPLICATION + PASSWORD 'zaqwsxcde'; + + GRANT postgres TO baca2 WITH ADMIN OPTION; + +5. *(\*)* Make migration files. + + In current version it is needed to manually make migrations file. In future migrations will be given with project. + + .. code-block:: console + + python3 -m poetry run python3 ./BaCa2/manage.py makemigrations + +6. Migrate database + + .. code-block:: console + + python3 -m poetry run python3 ./BaCa2/manage.py migrate + +7. Create superuser for django app + + .. code-block:: console + + python3 -m poetry run python3 ./BaCa2/manage.py createsuperuser + + And follow interactive user creation process. + +8. All done! Your BaCa2 instance should be working properly. diff --git a/BaCa2/docs/source/modules/overview/structure.rst b/BaCa2/docs/source/modules/overview/structure.rst new file mode 100644 index 00000000..e01fdcae --- /dev/null +++ b/BaCa2/docs/source/modules/overview/structure.rst @@ -0,0 +1,48 @@ +Application structure +===================== + +The BaCa2 application structure is divided into three main parts: + + 1. Django web application + 2. BaCa2-broker (communication backend) + 3. Package manager + +While Django app and Package manager are crucial for proper operation of the app, +Broker is open to be changed. BaCa2-broker is fully functional app, providing communication +between BaCa2 web app and `KOLEJKA `_ task checking service. + +Django web application +---------------------- + +.. image:: ../../_static/gh_icon.svg + :target: https://github.com/BaCa2-project/BaCa2 + +This Django-based web app is designed to provide an online platform for creating and managing programming tasks, +as well as submitting solutions for automatic evaluation. The system revolves around the concept of courses which are +used to organize groups of users into roles and provide them with access to a curated list of tasks designed +by course leaders. + +Currently developed for the `Institute of Computer Science and Mathematics `_ at +the Jagiellonian University + +Inner structure +''''''''''''''' + +The Django project is organized into three main apps: + +1. ``main`` - responsible for authentication, user data and settings, management of courses and their members +2. ``course`` - responsible for management of tasks, submissions and other data and functionalities internal to any given course +3. ``package`` - used to communicate with BaCa2-package-manager and represent its packages within the web app + +Additionally Django project provides an API for Broker communication (represented by ``broker_api`` app). + +Main advantage over other services is unification of users database, along with remaining efficient with database +resources usage. + +BaCa2-broker +------------ + +.. image:: ../../_static/gh_icon.svg + :target: https://github.com/BaCa2-project/BaCa2-broker + +BaCa2-broker is web server enabling communication between BaCa2 web application and KOLEJKA task scheduling platform. diff --git a/BaCa2/docs/source/modules/package.rst b/BaCa2/docs/source/modules/package.rst deleted file mode 100644 index 5c0ef463..00000000 --- a/BaCa2/docs/source/modules/package.rst +++ /dev/null @@ -1,40 +0,0 @@ -Package -======= - - -Models ------- - -.. autoclass:: package.models.PackageSource - :members: main_source, name, path - -.. autoclass:: package.models.PackageInstance - :members: package_source, commit, exists, key, package, path, create_from_me, delete_instance, share - -.. autoclass:: package.models.PackageInstanceUser - :members: user, package_instance - -Package Manager ---------------- - -.. autofunction:: package.package_manage.merge_settings - -.. autoclass:: package.package_manage.PackageManager - :members: - :special-members: __init__, __getitem__, __setitem__ - -.. autoclass:: package.package_manage.Package - :members: - -.. autoclass:: package.package_manage.TSet - :members: - -.. autoclass:: package.package_manage.TestF - :members: - -Validators ----------- - -.. automodule:: package.validators - :members: - diff --git a/BaCa2/docs/source/modules/widgets/base.rst b/BaCa2/docs/source/modules/widgets/base.rst new file mode 100644 index 00000000..c1561059 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/base.rst @@ -0,0 +1,10 @@ +.. module:: widgets.base + +Base +---- + +Base module containing the abstract ``Widget`` class all other widgets inherit from. + +.. autoclass:: Widget + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/base.rst b/BaCa2/docs/source/modules/widgets/forms/base.rst new file mode 100644 index 00000000..0de920bc --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/base.rst @@ -0,0 +1,43 @@ +.. module:: widgets.forms.base + +Base +==== + +Crucial module for the ``widgets.forms`` package containing base classes all other app-specific forms inherit from, as well as the base ``FormWidget`` class used to render forms. + +Base form classes +""""""""""""""""" + +.. autoclass:: BaCa2Form + :members: + :private-members: + +.. autoclass:: BaCa2ModelForm + :members: + :private-members: + +Form widget +""""""""""" + +.. autoclass:: FormWidget + :members: + :private-members: + +.. autoclass:: FormElementGroup + :members: + :private-members: + +Form post targets +""""""""""""""""" + +.. autoclass:: FormPostTarget + :members: + :private-members: + +.. autoclass:: ModelFormPostTarget + :members: + :private-members: + +.. autoclass:: CourseModelFormPostTarget + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/course.rst b/BaCa2/docs/source/modules/widgets/forms/course.rst new file mode 100644 index 00000000..23c291f1 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/course.rst @@ -0,0 +1,40 @@ +.. module:: widgets.forms.course + +Course +====== + +Module containing forms and form widgets related to management of courses. + +Course creation +""""""""""""""" + +.. autoclass:: CreateCourseForm + :members: + :private-members: + +.. autoclass:: CreateCourseFormWidget + :members: + :private-members: + + +Course deletion +""""""""""""""" + +.. autoclass:: DeleteCourseForm + :members: + :private-members: + +.. autoclass:: DeleteCourseFormWidget + :members: + :private-members: + +Adding members +"""""""""""""" + +.. autoclass:: AddMembersForm + :members: + :private-members: + +.. autoclass:: AddMembersFormWidget + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/fields/base.rst b/BaCa2/docs/source/modules/widgets/forms/fields/base.rst new file mode 100644 index 00000000..11e78bf2 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/fields/base.rst @@ -0,0 +1,40 @@ +.. module:: widgets.forms.fields.base + +Base +==== + +Module containing custom field classes not related to models of any specific app. + +Restricted char fields +"""""""""""""""""""""" + +.. autoclass:: RestrictedCharField + :members: + :private-members: + +.. autoclass:: AlphanumericField + :members: + :private-members: + +.. autoclass:: AlphanumericStringField + :members: + :private-members: + +Array fields +"""""""""""" + +.. autoclass:: CharArrayField + :members: + :private-members: + +.. autoclass:: IntegerArrayField + :members: + :private-members: + +.. autoclass:: ModelArrayField + :members: + :private-members: + +.. autoclass:: CourseModelArrayField + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/fields/course.rst b/BaCa2/docs/source/modules/widgets/forms/fields/course.rst new file mode 100644 index 00000000..b2c985ca --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/fields/course.rst @@ -0,0 +1,18 @@ +.. module:: widgets.forms.fields.course + +Course +------ + +Module containing field classes used by course-related forms. + +.. autoclass:: CourseName + :members: + :private-members: + +.. autoclass:: CourseShortName + :members: + :private-members: + +.. autoclass:: USOSCode + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/fields/index.rst b/BaCa2/docs/source/modules/widgets/forms/fields/index.rst new file mode 100644 index 00000000..fd94aeeb --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/fields/index.rst @@ -0,0 +1,13 @@ +Fields +====== + +Package containing custom field classes extending the functionality of basic Django form fields. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + base + course + table_select + validation diff --git a/BaCa2/docs/source/modules/widgets/forms/fields/table_select.rst b/BaCa2/docs/source/modules/widgets/forms/fields/table_select.rst new file mode 100644 index 00000000..941d584a --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/fields/table_select.rst @@ -0,0 +1,8 @@ +.. module:: widgets.forms.fields.table_select + +Table select +------------ + +.. autoclass:: TableSelectField + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/forms/fields/validation.rst b/BaCa2/docs/source/modules/widgets/forms/fields/validation.rst new file mode 100644 index 00000000..293bbf40 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/fields/validation.rst @@ -0,0 +1,8 @@ +.. module:: widgets.forms.fields.validation + +Validation +---------- + +Module containing functionalities related to live validation of form fields with AJAX. + +.. autofunction:: get_field_validation_status diff --git a/BaCa2/docs/source/modules/widgets/forms/index.rst b/BaCa2/docs/source/modules/widgets/forms/index.rst new file mode 100644 index 00000000..aaa2a6c2 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/forms/index.rst @@ -0,0 +1,12 @@ +Forms +===== + +Package containing all form and form widget classes, as well as any related components. Forms are classes inheriting from ``django.forms.Form`` used to generate and validate HTML forms. Form widgets are classes inheriting from ``widgets.base.Widget`` used to wrap form instances and dictate how they are rendered. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + base + course + fields/index diff --git a/BaCa2/docs/source/modules/widgets/listing/base.rst b/BaCa2/docs/source/modules/widgets/listing/base.rst new file mode 100644 index 00000000..651d3dea --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/listing/base.rst @@ -0,0 +1,16 @@ +.. module:: widgets.listing.base + +Base +---- + +.. autoclass:: TableWidget + :members: + :private-members: + +.. autoclass:: TableWidgetPaging + :members: + :private-members: + +.. autoclass:: DeleteRecordFormWidget + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/listing/columns.rst b/BaCa2/docs/source/modules/widgets/listing/columns.rst new file mode 100644 index 00000000..73642f02 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/listing/columns.rst @@ -0,0 +1,22 @@ +.. module:: widgets.listing.columns + +Column +------ + +Module containing classes used to define the properties and functionalities of columns in a ``TableWidget``. + +.. autoclass:: Column + :members: + :private-members: + +.. autoclass:: TextColumn + :members: + :private-members: + +.. autoclass:: SelectColumn + :members: + :private-members: + +.. autoclass:: DeleteColumn + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/listing/data_sources.rst b/BaCa2/docs/source/modules/widgets/listing/data_sources.rst new file mode 100644 index 00000000..5cb65482 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/listing/data_sources.rst @@ -0,0 +1,18 @@ +.. module:: widgets.listing.data_sources + +Data sources +------------ + +Module containing classes used to generate data source urls for table widgets (which use AJAX to load data). + +.. autoclass:: TableDataSource + :members: + :private-members: + +.. autoclass:: ModelDataSource + :members: + :private-members: + +.. autoclass:: CourseModelDataSource + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/listing/index.rst b/BaCa2/docs/source/modules/widgets/listing/index.rst new file mode 100644 index 00000000..1228d925 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/listing/index.rst @@ -0,0 +1,12 @@ +Listing +======= + +Package for all widget classes and components used to list and display data from the backend. The most important class of this package is ``base.TableWidget`` which is used to render data retrieved from a given source in table format. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + base + columns + data_sources diff --git a/BaCa2/docs/source/modules/widgets/navigation.rst b/BaCa2/docs/source/modules/widgets/navigation.rst new file mode 100644 index 00000000..e813f854 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/navigation.rst @@ -0,0 +1,14 @@ +.. module:: widgets.navigation + +Navigation +---------- + +Module containing widgets used for navigating the web application. + +.. autoclass:: NavBar + :members: + :private-members: + +.. autoclass:: SideNav + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/popups/base.rst b/BaCa2/docs/source/modules/widgets/popups/base.rst new file mode 100644 index 00000000..bcf86d16 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/popups/base.rst @@ -0,0 +1,8 @@ +.. module:: widgets.popups.base + +Base +---- + +.. autoclass:: PopupWidget + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/popups/forms.rst b/BaCa2/docs/source/modules/widgets/popups/forms.rst new file mode 100644 index 00000000..613a1bb4 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/popups/forms.rst @@ -0,0 +1,16 @@ +.. module:: widgets.popups.forms + +Forms +===== + +.. autoclass:: SubmitConfirmationPopup + :members: + :private-members: + +.. autoclass:: SubmitSuccessPopup + :members: + :private-members: + +.. autoclass:: SubmitFailurePopup + :members: + :private-members: diff --git a/BaCa2/docs/source/modules/widgets/popups/index.rst b/BaCa2/docs/source/modules/widgets/popups/index.rst new file mode 100644 index 00000000..be5044d0 --- /dev/null +++ b/BaCa2/docs/source/modules/widgets/popups/index.rst @@ -0,0 +1,11 @@ +Popups +====== + +Package for all widget classes and components that are used to create popups. + +.. toctree:: + :maxdepth: 2 + :caption: Components: + + base + forms diff --git a/BaCa2/docs/source/usage.rst b/BaCa2/docs/source/usage.rst deleted file mode 100644 index b21f015e..00000000 --- a/BaCa2/docs/source/usage.rst +++ /dev/null @@ -1,12 +0,0 @@ -Usage -===== - -Installation ------------- -To install project, for now, it is needed to clone project from GitHub. -Then install requirements running: - -.. code-block:: console - - (venv) $ poetry install - diff --git a/BaCa2/gunicorn_config.py b/BaCa2/gunicorn_config.py new file mode 100644 index 00000000..2bba0c7f --- /dev/null +++ b/BaCa2/gunicorn_config.py @@ -0,0 +1,3 @@ +bind = '127.0.0.1:8000' +workers = 8 +preload_app = True diff --git a/BaCa2/locale/pl/LC_MESSAGES/django.po b/BaCa2/locale/pl/LC_MESSAGES/django.po new file mode 100644 index 00000000..5432f5ea --- /dev/null +++ b/BaCa2/locale/pl/LC_MESSAGES/django.po @@ -0,0 +1,1437 @@ +# Copyright (C) BaCa2 Project 2024 +# This file is distributed under the same license as the BaCa2 package. +# Bartosz Deptuła , 2024. +msgid "" +msgstr "" +"Project-Id-Version: BaCa2 1.0-beta\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2024-04-04 12:24+0200\n" +"PO-Revision-Date: 2024-04-04 12:00+0200\n" +"Last-Translator: Bartosz Deptuła bartosz.deptula@student.uj.edu.pl\n" +"Language-Team: BaCa2 Team \n" +"Language: Polish\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n" +"%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n" +"%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n" + +#: .\BaCa2\core\choices.py:8 +msgid "Unanimous" +msgstr "Jednogłośne" + +#: .\BaCa2\core\choices.py:9 +msgid "Linear" +msgstr "Liniowe" + +#: .\BaCa2\core\choices.py:13 +msgid "Pending" +msgstr "Oczekuje" + +#: .\BaCa2\core\choices.py:14 +msgid "Accepted" +msgstr "Zaakceptowany" + +#: .\BaCa2\core\choices.py:15 +msgid "Wrong answer" +msgstr "Błąd" + +#: .\BaCa2\core\choices.py:16 +msgid "Time limit exceeded" +msgstr "Przekroczono limit czasu" + +#: .\BaCa2\core\choices.py:17 +msgid "Runtime error" +msgstr "Błąd wykonania" + +#: .\BaCa2\core\choices.py:18 +msgid "Memory exceeded" +msgstr "Przekroczono limit pamięci" + +#: .\BaCa2\core\choices.py:19 +msgid "Compilation error" +msgstr "Błąd kompilacji" + +#: .\BaCa2\core\choices.py:20 +msgid "Rule violation" +msgstr "Naruszenie zasad" + +#: .\BaCa2\core\choices.py:21 +msgid "Unknown extension" +msgstr "Nieznane rozszerzenie" + +#: .\BaCa2\core\choices.py:22 +msgid "Internal timeout" +msgstr "Wewnętrznym limit czasu" + +#: .\BaCa2\core\choices.py:23 +msgid "Internal error" +msgstr "Błąd wewnętrzny" + +#: .\BaCa2\core\choices.py:36 +msgid "Individual Permissions" +msgstr "Uprawnienia użytkowników" + +#: .\BaCa2\core\choices.py:37 +msgid "Group-level Permissions" +msgstr "Uprawnienia grup" + +#: .\BaCa2\core\choices.py:38 +msgid "All Permissions" +msgstr "Wszystkie uprawnienia" + +#: .\BaCa2\core\choices.py:53 +msgid "Student" +msgstr "Student" + +#: .\BaCa2\core\choices.py:54 +msgid "Doctoral" +msgstr "Doktorant" + +#: .\BaCa2\core\choices.py:55 +msgid "Employee" +msgstr "Pracownik" + +#: .\BaCa2\core\choices.py:56 .\BaCa2\main\views.py:415 +#: .\BaCa2\widgets\navigation.py:37 +msgid "Admin" +msgstr "Admin" + +#: .\BaCa2\core\choices.py:60 +msgid "PDF" +msgstr "PDF" + +#: .\BaCa2\core\choices.py:61 +msgid "Markdown" +msgstr "Markdown" + +#: .\BaCa2\core\choices.py:62 +msgid "HTML" +msgstr "HTML" + +#: .\BaCa2\core\choices.py:63 +msgid "Plain text" +msgstr "Czysty tekst" + +#: .\BaCa2\core\choices.py:67 +msgid "Standard" +msgstr "Standardowe" + +#: .\BaCa2\core\choices.py:68 +msgid "Hidden" +msgstr "Ukryte" + +#: .\BaCa2\core\choices.py:69 +msgid "Control" +msgstr "Kontrolne" + +#: .\BaCa2\core\choices.py:73 +msgid "Best submit" +msgstr "Najlepsze zgłoszenie" + +#: .\BaCa2\core\choices.py:74 +msgid "Last submit" +msgstr "Ostatnie zgłoszenie" + +#: .\BaCa2\core\choices.py:78 +msgid "No fall-off (max points until deadline)" +msgstr "Brak spadku (maksymalne punkty do zakończenia)" + +#: .\BaCa2\core\choices.py:79 +msgid "Linear fall-off" +msgstr "Liniowy spadek" + +#: .\BaCa2\core\choices.py:80 +msgid "Square fall-off" +msgstr "Kwadratowy spadek" + +#: .\BaCa2\core\settings\localization.py:13 +msgid "Polish" +msgstr "Polski" + +#: .\BaCa2\core\settings\localization.py:14 +msgid "English" +msgstr "Angielski" + +#: .\BaCa2\core\tools\mailer.py:76 +msgid "Email sent automatically by BaCa2. Please do not reply." +msgstr "Email wysłany automatycznie przez BaCa2. Proszę nie odpowiadać." + +#: .\BaCa2\course\apps.py:14 .\BaCa2\course\views.py:1074 +msgid "Course" +msgstr "Kurs" + +#: .\BaCa2\course\models.py:1326 +msgid "Can view submit code" +msgstr "Może zobaczyć kod zgłoszenia" + +#: .\BaCa2\course\models.py:1327 +msgid "Can view submit compilation logs" +msgstr "Może zobaczyć logi kompilacji zgłoszenia" + +#: .\BaCa2\course\models.py:1328 +msgid "Can view submit checker logs" +msgstr "Może zobaczyć logi sprawdzania zgłoszenia" + +#: .\BaCa2\course\models.py:1329 +msgid "Can view output generated by the student solution" +msgstr "Może zobaczyć wynik generowany przez rozwiązanie studenta" + +#: .\BaCa2\course\models.py:1330 +msgid "Can view benchmark output" +msgstr "Może zobaczyć wynik wzorcowy" + +#: .\BaCa2\course\models.py:1331 +msgid "Can view test inputs" +msgstr "Może zobaczyć dane wejściowe testu" + +#: .\BaCa2\course\models.py:1332 +msgid "Can view used memory" +msgstr "Może zobaczyć zużytą pamięć" + +#: .\BaCa2\course\models.py:1333 +msgid "Can view used time" +msgstr "Może zobaczyć zużyty czas" + +#: .\BaCa2\course\models.py:1334 +msgid "Can rejudge submit" +msgstr "Może ponownie ocenić zgłoszenie" + +#: .\BaCa2\course\views.py:499 +msgid "Course members" +msgstr "Uczestnicy kursu" + +#: .\BaCa2\course\views.py:506 .\BaCa2\main\views.py:463 +#: .\BaCa2\main\views.py:579 .\BaCa2\main\views.py:641 +#: .\BaCa2\widgets\forms\course.py:627 .\BaCa2\widgets\forms\main.py:23 +msgid "First name" +msgstr "Imię" + +#: .\BaCa2\course\views.py:507 .\BaCa2\main\views.py:464 +#: .\BaCa2\main\views.py:580 .\BaCa2\main\views.py:642 +#: .\BaCa2\widgets\forms\course.py:628 .\BaCa2\widgets\forms\main.py:24 +msgid "Last name" +msgstr "Nazwisko" + +#: .\BaCa2\course\views.py:508 .\BaCa2\widgets\forms\main.py:20 +msgid "Email address" +msgstr "Adres email" + +#: .\BaCa2\course\views.py:509 .\BaCa2\widgets\forms\course.py:338 +#: .\BaCa2\widgets\forms\course.py:475 .\BaCa2\widgets\forms\course.py:629 +msgid "Role" +msgstr "Rola" + +#: .\BaCa2\course\views.py:544 +msgid "Roles" +msgstr "Role" + +#: .\BaCa2\course\views.py:550 .\BaCa2\widgets\forms\course.py:739 +msgid "Role name" +msgstr "Nazwa roli" + +#: .\BaCa2\course\views.py:551 .\BaCa2\widgets\forms\course.py:745 +#: .\BaCa2\widgets\forms\course.py:754 .\BaCa2\widgets\forms\course.py:874 +#: .\BaCa2\widgets\forms\course.py:932 +msgid "Description" +msgstr "Opis" + +#: .\BaCa2\course\views.py:581 +msgid "Rounds" +msgstr "Rundy" + +#: .\BaCa2\course\views.py:584 .\BaCa2\widgets\forms\course.py:986 +#: .\BaCa2\widgets\forms\course.py:1107 +msgid "Round name" +msgstr "Nazwa rundy" + +#: .\BaCa2\course\views.py:585 .\BaCa2\widgets\forms\course.py:998 +#: .\BaCa2\widgets\forms\course.py:1118 +msgid "Start date" +msgstr "Data rozpoczęcia" + +#: .\BaCa2\course\views.py:586 .\BaCa2\widgets\forms\course.py:1000 +#: .\BaCa2\widgets\forms\course.py:1120 +msgid "End date" +msgstr "Data terminu" + +#: .\BaCa2\course\views.py:587 .\BaCa2\widgets\forms\course.py:1002 +#: .\BaCa2\widgets\forms\course.py:1122 +msgid "Deadline date" +msgstr "Data zakończenia" + +#: .\BaCa2\course\views.py:588 .\BaCa2\widgets\forms\course.py:1004 +#: .\BaCa2\widgets\forms\course.py:1124 +msgid "Reveal date" +msgstr "Data odsłonięcia" + +#: .\BaCa2\course\views.py:590 .\BaCa2\widgets\forms\course.py:988 +#: .\BaCa2\widgets\forms\course.py:1109 +msgid "Score selection policy" +msgstr "Polityka wyboru wyniku" + +#: .\BaCa2\course\views.py:625 +msgid "Tasks" +msgstr "Zadania" + +#: .\BaCa2\course\views.py:633 .\BaCa2\course\views.py:696 +#: .\BaCa2\widgets\forms\course.py:1320 .\BaCa2\widgets\forms\course.py:1474 +msgid "Task name" +msgstr "Nazwa zadania" + +#: .\BaCa2\course\views.py:634 .\BaCa2\course\views.py:1075 +#: .\BaCa2\widgets\forms\course.py:1329 .\BaCa2\widgets\forms\course.py:1482 +#: .\BaCa2\widgets\forms\course.py:1769 +msgid "Round" +msgstr "Runda" + +#: .\BaCa2\course\views.py:635 +msgid "Judging mode" +msgstr "Sposób oceniania" + +#: .\BaCa2\course\views.py:636 +msgid "Max points" +msgstr "Max punktów" + +#: .\BaCa2\course\views.py:637 +msgid "My score" +msgstr "Mój wynik" + +#: .\BaCa2\course\views.py:694 .\BaCa2\course\views.py:880 +msgid "Results" +msgstr "Wyniki" + +#: .\BaCa2\course\views.py:697 .\BaCa2\course\views.py:882 +#: .\BaCa2\course\views.py:1078 +msgid "Submit time" +msgstr "Czas zgłoszenia" + +#: .\BaCa2\course\views.py:698 .\BaCa2\course\views.py:883 +#: .\BaCa2\course\views.py:1081 +msgid "Submit status" +msgstr "Status zgłoszenia" + +#: .\BaCa2\course\views.py:699 .\BaCa2\course\views.py:884 +#: .\BaCa2\course\views.py:1085 +msgid "Score" +msgstr "Wynik" + +#: .\BaCa2\course\views.py:717 +msgid "Submitter first name" +msgstr "Imię zgłaszającego" + +#: .\BaCa2\course\views.py:718 +msgid "Submitter last name" +msgstr "Nazwisko zgłaszającego" + +#: .\BaCa2\course\views.py:904 .\BaCa2\main\views.py:562 +msgid "Name" +msgstr "Imię" + +#: .\BaCa2\course\views.py:906 +msgid "Surname" +msgstr "Nazwisko" + +#: .\BaCa2\course\views.py:973 +msgid " - edit" +msgstr " - edytuj" + +#: .\BaCa2\course\views.py:990 +msgid " - rounds" +msgstr " - rundy" + +#: .\BaCa2\course\views.py:1076 +msgid "Task" +msgstr "Zadanie" + +#: .\BaCa2\course\views.py:1077 +msgid "User" +msgstr "Użytkownik" + +#: .\BaCa2\course\views.py:1086 +msgid "Fall-off factor" +msgstr "Współczynnik spadku" + +#: .\BaCa2\course\views.py:1098 +msgid "Summary" +msgstr "Podsumowanie" + +#: .\BaCa2\course\views.py:1108 +msgid "Code" +msgstr "Kod" + +#: .\BaCa2\course\views.py:1112 .\BaCa2\widgets\forms\course.py:2199 +msgid "Source code" +msgstr "Kod źródłowy" + +#: .\BaCa2\course\views.py:1163 +msgid "Test" +msgstr "Test" + +#: .\BaCa2\course\views.py:1164 +msgid "Status" +msgstr "Status" + +#: .\BaCa2\course\views.py:1169 +msgid "Time" +msgstr "Czas" + +#: .\BaCa2\course\views.py:1173 +msgid "Memory" +msgstr "Pamięć" + +#: .\BaCa2\main\models.py:177 +msgid "" +"Email is not in internal domain. To add external users contact your " +"administrator" +msgstr "" +"Adres email nie jest w domenie wewnętrznej. Aby dodać użytkowników " +"zewnętrznych, skontaktuj się z administratorem" + +#: .\BaCa2\main\models.py:292 +msgid "admin" +msgstr "admin" + +#: .\BaCa2\main\models.py:293 +msgid "Admin role for course leaders" +msgstr "Rola administratora dla liderów kursu" + +#: .\BaCa2\main\models.py:421 +msgid "course name" +msgstr "nazwa kursu" + +#: .\BaCa2\main\models.py:428 +msgid "course short name" +msgstr "krótka nazwa kursu" + +#: .\BaCa2\main\models.py:436 +msgid "Subject code" +msgstr "Nazwa przedmiotu" + +#: .\BaCa2\main\models.py:444 +msgid "Term code" +msgstr "Kod semestru" + +#: .\BaCa2\main\models.py:454 +msgid "default role" +msgstr "rola domyślna" + +#: .\BaCa2\main\models.py:464 +msgid "admin role" +msgstr "rola administratora" + +#: .\BaCa2\main\models.py:477 +msgid "Can view course members" +msgstr "Może zobaczyć uczestników kursu" + +#: .\BaCa2\main\models.py:478 +msgid "Can add course members" +msgstr "Może dodać uczestników kursu" + +#: .\BaCa2\main\models.py:479 +msgid "Can remove course members" +msgstr "Może usunąć uczestników kursu" + +#: .\BaCa2\main\models.py:480 +msgid "Can change course member's role" +msgstr "Może zmienić rolę uczestnika kursu" + +#: .\BaCa2\main\models.py:481 +msgid "Can add course admins" +msgstr "Może dodać administratorów kursu" + +#: .\BaCa2\main\models.py:482 +msgid "Can add course members from CSV" +msgstr "Może dodać uczestników kursu z pliku CSV" + +#: .\BaCa2\main\models.py:485 +msgid "Can view course role" +msgstr "Może zobaczyć role w kursie" + +#: .\BaCa2\main\models.py:486 +msgid "Can edit course role" +msgstr "Może edytować role w kursie" + +#: .\BaCa2\main\models.py:487 +msgid "Can add course role" +msgstr "Może dodawać role w kursie" + +#: .\BaCa2\main\models.py:488 +msgid "Can delete course role" +msgstr "Może usuwać role w kursie" + +#: .\BaCa2\main\models.py:491 +msgid "Can view own submits" +msgstr "Może zobaczyć własne zgłoszenia" + +#: .\BaCa2\main\models.py:492 +msgid "Can add submit after round deadline" +msgstr "Może dodać zgłoszenie po zakończeniu rundy" + +#: .\BaCa2\main\models.py:493 +msgid "Can add submit before round start" +msgstr "Może dodać zgłoszenie przed rozpoczęciem rundy" + +#: .\BaCa2\main\models.py:494 +msgid "Can view own results" +msgstr "Może zobaczyć własne wyniki" + +#: .\BaCa2\main\models.py:497 +msgid "Can reupload task" +msgstr "Może ponownie wgrać paczkę z zadaniem" + +#: .\BaCa2\main\models.py:1256 +msgid "UI theme" +msgstr "Skórka" + +#: .\BaCa2\main\models.py:1280 +msgid "email address" +msgstr "adres email" + +#: .\BaCa2\main\models.py:1286 +msgid "first name" +msgstr "imię" + +#: .\BaCa2\main\models.py:1292 +msgid "last name" +msgstr "nazwisko" + +#: .\BaCa2\main\models.py:1298 +msgid "date joined" +msgstr "data dołączenia" + +#: .\BaCa2\main\models.py:1303 +msgid "user settings" +msgstr "ustawienia użytkownika" + +#: .\BaCa2\main\models.py:1317 +msgid "user job" +msgstr "praca użytkownika" + +#: .\BaCa2\main\models.py:1329 +msgid "superuser status" +msgstr "status superusera" + +#: .\BaCa2\main\models.py:1332 +msgid "" +"Designates that this user has all permissions without explicitly assigning " +"them." +msgstr "" +"Oznacza, że ten użytkownik ma wszystkie uprawnienia bez ich wyraźnego " +"przypisywania." + +#: .\BaCa2\main\models.py:1339 +msgid "groups" +msgstr "Grupy" + +#: .\BaCa2\main\models.py:1342 +msgid "" +"The groups this user belongs to. A user will get all permissions granted to " +"each of their groups." +msgstr "" +"Grupy, do których należy ten użytkownik. Użytkownik otrzyma wszystkie " +"uprawnienia przyznane każdej z grup." + +#: .\BaCa2\main\models.py:1351 +msgid "roles" +msgstr "Role" + +#: .\BaCa2\main\models.py:1353 +msgid "The course roles this user has been assigned to." +msgstr "Role w kursie, do których został przypisany ten użytkownik." + +#: .\BaCa2\main\models.py:1360 +msgid "user permissions" +msgstr "uprawnienia użytkownika" + +#: .\BaCa2\main\models.py:1362 +msgid "Specific permissions for this user." +msgstr "Dodatkowe uprawnienia przypisane do tego użytkownika." + +#: .\BaCa2\main\models.py:1367 +msgid "nickname" +msgstr "pseudonim" + +#: .\BaCa2\main\models.py:1424 +msgid "YES" +msgstr "TAK" + +#: .\BaCa2\main\models.py:1424 +msgid "NO" +msgstr "NIE" + +#: .\BaCa2\main\models.py:1947 +msgid "role name" +msgstr "nazwa roli" + +#: .\BaCa2\main\models.py:1954 +msgid "role description" +msgstr "opis roli" + +#: .\BaCa2\main\models.py:1961 +msgid "role permissions" +msgstr "uprawnienia roli" + +#: .\BaCa2\main\models.py:1968 +msgid "course" +msgstr "kurs" + +#: .\BaCa2\main\models.py:2275 +msgid "preset name" +msgstr "nazwa szablonu" + +#: .\BaCa2\main\models.py:2283 +msgid "preset permissions" +msgstr "uprawnienia szablonu" + +#: .\BaCa2\main\models.py:2289 +msgid "preset is public" +msgstr "szablon jest publiczny" + +#: .\BaCa2\main\models.py:2291 +msgid "" +"Indicates whether the preset is public. Public presets can be used by all " +"users, private presets can only be used by their creator or other users " +"given access." +msgstr "" +"Określa, czy szablon jest publiczny. Publiczne szablony mogą być używane " +"przez wszystkich użytkowników, prywatne szablony mogą być używane tylko " +"przez ich twórcę lub innych użytkowników, którzy otrzymali dostęp." + +#: .\BaCa2\main\models.py:2299 +msgid "preset creator" +msgstr "twórca szablonu" + +#: .\BaCa2\main\models.py:2394 +msgid "user" +msgstr "użytkownik" + +#: .\BaCa2\main\models.py:2401 +msgid "preset" +msgstr "szablon" + +#: .\BaCa2\main\views.py:335 +msgid "Login" +msgstr "Logowanie" + +#: .\BaCa2\main\views.py:341 +msgid "Log in" +msgstr "" + +#: .\BaCa2\main\views.py:462 .\BaCa2\main\views.py:578 +#: .\BaCa2\main\views.py:640 .\BaCa2\widgets\forms\course.py:331 +#: .\BaCa2\widgets\forms\course.py:626 +msgid "Email" +msgstr "Email" + +#: .\BaCa2\main\views.py:465 .\BaCa2\main\views.py:643 +msgid "Superuser" +msgstr "Superuser" + +#: .\BaCa2\main\views.py:507 .\BaCa2\widgets\navigation.py:28 +msgid "Courses" +msgstr "Kursy" + +#: .\BaCa2\main\views.py:555 +msgid "Permissions" +msgstr "Uprawnienia" + +#: .\BaCa2\main\views.py:561 .\BaCa2\widgets\forms\course.py:753 +#: .\BaCa2\widgets\forms\course.py:873 .\BaCa2\widgets\forms\course.py:931 +msgid "Codename" +msgstr "Nazwa kodowa" + +#: .\BaCa2\main\views.py:572 +msgid "Members" +msgstr "Uczestnicy" + +#: .\BaCa2\main\views.py:622 +msgid "Profile" +msgstr "Profil" + +#: .\BaCa2\main\views.py:658 .\BaCa2\main\views.py:663 +msgid "Change password" +msgstr "Zmień hasło" + +#: .\BaCa2\main\views.py:700 +msgid "Password changed." +msgstr "Hasło zmienione." + +#: .\BaCa2\main\views.py:704 +msgid "Password not changed." +msgstr "Hasło nie zmienione." + +#: .\BaCa2\templates\course_task.html:11 +msgid "Attachments" +msgstr "Załączniki" + +#: .\BaCa2\templates\widget_templates\brief_result_summary.html:13 +msgid "Status:" +msgstr "Status:" + +#: .\BaCa2\templates\widget_templates\brief_result_summary.html:18 +msgid "Time used:" +msgstr "Użyty czas:" + +#: .\BaCa2\templates\widget_templates\brief_result_summary.html:25 +msgid "Memory used:" +msgstr "Użyta pamięć" + +#: .\BaCa2\templates\widget_templates\brief_result_summary.html:44 +msgid "Compile logs" +msgstr "Logi kompilacji" + +#: .\BaCa2\templates\widget_templates\brief_result_summary.html:58 +msgid "Checker logs" +msgstr "Logi sprawdzania" + +#: .\BaCa2\templates\widget_templates\listing\table.html:108 +msgid "resize" +msgstr "zmień rozmiar" + +#: .\BaCa2\templates\widget_templates\markup_displayer.html:22 +msgid "Download PDF" +msgstr "Pobierz PDF" + +#: .\BaCa2\templates\widget_templates\pdf_displayer.html:10 +msgid "Download" +msgstr "Pobierz" + +#: .\BaCa2\templates\widget_templates\pdf_displayer.html:27 +msgid "Page" +msgstr "Strona" + +#: .\BaCa2\templates\widget_templates\pdf_displayer.html:33 +msgid "of" +msgstr "z" + +#: .\BaCa2\util\views.py:353 +msgid "Failed to retrieve data for model instances due to missing mode param." +msgstr "" +"Nie udało się pobrać danych dla instancji modelu z powodu braku parametru " +"trybu." + +#: .\BaCa2\util\views.py:375 +msgid "Get all retrieval mode does not accept filter or exclude parameters." +msgstr "" +"Tryb pobierania wszystkich nie akceptuje parametrów filtrujących lub " +"wykluczających." + +#: .\BaCa2\util\views.py:390 +msgid "" +"Failed to retrieve data for model instances due to invalid get mode " +"parameter." +msgstr "" +"Nie udało się pobrać danych dla instancji modelu z powodu nieprawidłowego " +"parametru trybu." + +#: .\BaCa2\util\views.py:413 .\BaCa2\util\views.py:469 +msgid "Permission denied." +msgstr "Brak uprawnień." + +#: .\BaCa2\util\views.py:419 +msgid "Successfully retrieved data for all model instances" +msgstr "Pomyślnie pobrano dane dla wszystkich instancji modelu" + +#: .\BaCa2\util\views.py:426 +msgid "An error occurred while retrieving data for all model instances." +msgstr "" +"Podczas pobierania danych dla wszystkich instancji modelu wystąpił błąd." + +#: .\BaCa2\util\views.py:475 +msgid "" +"Successfully retrieved data for model instances matching the specified " +"filter parameters." +msgstr "" +"Pomyślnie pobrano dane dla instancji modelu spełniających określone " +"parametry filtru." + +#: .\BaCa2\util\views.py:483 +msgid "" +"An error occurred while retrieving data for model instances matching the " +"specified filter parameters." +msgstr "" +"Podczas pobierania danych dla instancji modelu spełniających określone " +"parametry filtru wystąpił błąd." + +#: .\BaCa2\util\views.py:554 +msgid "This view does not handle post requests." +msgstr "Ten widok nie obsługuje żądań POST." + +#: .\BaCa2\util\views.py:568 +msgid "Unknown form: {request.POST.get(\"form_name\")}" +msgstr "Nieznany formularz: {request.POST.get(\"form_name\")}" + +#: .\BaCa2\widgets\brief_result_summary.py:40 +msgid "Compile log" +msgstr "Log kompilacji" + +#: .\BaCa2\widgets\brief_result_summary.py:52 +msgid "Checker log" +msgstr "Log sprawdzania" + +#: .\BaCa2\widgets\code_block.py:26 +msgid "Code block" +msgstr "Blok kodu" + +#: .\BaCa2\widgets\code_block.py:38 +msgid "" +"ERROR: Failed to read file.\n" +" Make sure you have sent valid file as solution." +msgstr "" +"BŁĄD: Nie udało się odczytać pliku.\n" +" Upewnij się, że wysłałeś poprawny plik jako rozwiązanie." + +#: .\BaCa2\widgets\forms\base.py:184 +msgid "Form name" +msgstr "Nazwa formularza" + +#: .\BaCa2\widgets\forms\base.py:191 +msgid "Form instance" +msgstr "Instancja formularza" + +#: .\BaCa2\widgets\forms\base.py:196 +msgid "Action" +msgstr "Akcja" + +#: .\BaCa2\widgets\forms\base.py:365 +msgid "Invalid form data. Please correct the following errors:" +msgstr "Nieprawidłowe dane. Proszę poprawić następujące błędy:" + +#: .\BaCa2\widgets\forms\base.py:378 +msgid "Request failed due to insufficient permissions." +msgstr "Żądanie nie powiodło się z powodu niewystarczających uprawnień." + +#: .\BaCa2\widgets\forms\base.py:395 +msgid "Following error occurred while processing the request:" +msgstr "Podczas przetwarzania żądania wystąpił następujący błąd:" + +#: .\BaCa2\widgets\forms\base.py:506 +msgid "Submit" +msgstr "Zgłoś" + +#: .\BaCa2\widgets\forms\base.py:600 .\BaCa2\widgets\forms\base.py:722 +msgid "Generate automatically" +msgstr "Wygeneruj automatycznie" + +#: .\BaCa2\widgets\forms\base.py:602 .\BaCa2\widgets\forms\base.py:724 +msgid "Enter manually" +msgstr "Wprwoadź ręcznie" + +#: .\BaCa2\widgets\forms\base.py:787 +msgid "Summary of changes" +msgstr "Podsumowanie zmian" + +#: .\BaCa2\widgets\forms\base.py:791 +msgid "No changes made" +msgstr "Brak zmian" + +#: .\BaCa2\widgets\forms\course.py:152 +msgid "Course name" +msgstr "Nazwa kursu" + +#: .\BaCa2\widgets\forms\course.py:159 +msgid "USOS course code" +msgstr "Kod kursu na USOS" + +#: .\BaCa2\widgets\forms\course.py:166 +msgid "USOS term code" +msgstr "Kod semestru na USOS" + +#: .\BaCa2\widgets\forms\course.py:188 +msgid "Course " +msgstr "Kurs " + +#: .\BaCa2\widgets\forms\course.py:188 .\BaCa2\widgets\forms\course.py:785 +#: .\BaCa2\widgets\forms\course.py:1040 .\BaCa2\widgets\forms\course.py:1424 +msgid " created successfully" +msgstr " utworzony pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:220 +msgid "Add course" +msgstr "Dodaj kurs" + +#: .\BaCa2\widgets\forms\course.py:226 +msgid "Add USOS data" +msgstr "Dodaj dane USOS" + +#: .\BaCa2\widgets\forms\course.py:227 +msgid "Create without USOS data" +msgstr "Utwórz bez danych USOS" + +#: .\BaCa2\widgets\forms\course.py:232 +msgid "Confirm course creation" +msgstr "Potwierdź utworzenie kursu" + +#: .\BaCa2\widgets\forms\course.py:234 +msgid "Are you sure you want to create a new course with the following data?" +msgstr "Czy na pewno chcesz utworzyć nowy kurs z następującymi danymi?" + +#: .\BaCa2\widgets\forms\course.py:236 +msgid "Create course" +msgstr "Utwórz kurs" + +#: .\BaCa2\widgets\forms\course.py:259 +msgid "Course ID" +msgstr "ID kursu" + +#: .\BaCa2\widgets\forms\course.py:275 +msgid "Course deleted successfully" +msgstr "Kurs usunięty pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:306 .\BaCa2\widgets\forms\course.py:312 +msgid "Delete course" +msgstr "Usuń kurs" + +#: .\BaCa2\widgets\forms\course.py:308 +msgid "Confirm course deletion" +msgstr "Potwierdź usunięcie kursu" + +#: .\BaCa2\widgets\forms\course.py:310 +msgid "" +"Are you sure you want to delete this course? This action cannot be undone." +msgstr "Czy na pewno chcesz usunąć ten kurs? Ta akcja nie może być cofnięta." + +#: .\BaCa2\widgets\forms\course.py:333 +msgid "Email from UJ domain, or already registered in the system." +msgstr "Adres email z domeny UJ, lub już zarejestrowany w systemie." + +#: .\BaCa2\widgets\forms\course.py:396 .\BaCa2\widgets\forms\course.py:546 +msgid "You have been added to a course" +msgstr "Zostałeś dodany do kursu" + +#: .\BaCa2\widgets\forms\course.py:402 +msgid "Member added successfully" +msgstr "Uczestnik dodany pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:456 +msgid "Add new member" +msgstr "Dodaj nowego uczestnika" + +#: .\BaCa2\widgets\forms\course.py:469 +msgid "Members CSV file" +msgstr "Plik CSV z uczestnikami" + +#: .\BaCa2\widgets\forms\course.py:552 +msgid "Members added successfully" +msgstr "Uczestnicy dodani pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:606 +msgid "Add members from CSV" +msgstr "Dodaj uczestników z pliku CSV" + +#: .\BaCa2\widgets\forms\course.py:623 +msgid "Choose members to remove" +msgstr "Wybierz uczestników do usunięcia" + +#: .\BaCa2\widgets\forms\course.py:681 +msgid "You cannot remove an admin from the course" +msgstr "Nie możesz usunąć administratora z kursu" + +#: .\BaCa2\widgets\forms\course.py:683 +msgid "You cannot remove yourself from the course" +msgstr "Nie możesz usunąć siebie z kursu" + +#: .\BaCa2\widgets\forms\course.py:686 +msgid "Members removed successfully" +msgstr "Uczestnicy usunięci pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:722 +msgid "Remove members" +msgstr "Usuń uczestników" + +#: .\BaCa2\widgets\forms\course.py:750 +msgid "Choose role permissions" +msgstr "Wybierz uprawnienia roli" + +#: .\BaCa2\widgets\forms\course.py:785 .\BaCa2\widgets\forms\course.py:856 +msgid "Role " +msgstr "Rola " + +#: .\BaCa2\widgets\forms\course.py:821 +msgid "Add role" +msgstr "Dodaj rolę" + +#: .\BaCa2\widgets\forms\course.py:837 +msgid "Role ID" +msgstr "ID roli" + +#: .\BaCa2\widgets\forms\course.py:856 +msgid " deleted successfully" +msgstr " usunięte pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:864 .\BaCa2\widgets\forms\course.py:923 +#: .\BaCa2\widgets\forms\course.py:1497 .\BaCa2\widgets\forms\course.py:1604 +#: .\BaCa2\widgets\forms\course.py:1688 .\BaCa2\widgets\forms\course.py:2104 +#: .\BaCa2\widgets\forms\course.py:2201 +msgid "Task ID" +msgstr "ID zadania" + +#: .\BaCa2\widgets\forms\course.py:870 +msgid "Choose permissions to add" +msgstr "Wybierz uprawnienia do dodania" + +#: .\BaCa2\widgets\forms\course.py:883 +msgid "Permissions added successfully" +msgstr "Uprawnienia dodane pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:913 +msgid "Add permissions" +msgstr "Dodaj uprawnienia" + +#: .\BaCa2\widgets\forms\course.py:928 +msgid "Choose permissions to remove" +msgstr "Wybierz uprawnienia do usunięcia" + +#: .\BaCa2\widgets\forms\course.py:941 +msgid "Permissions removed successfully" +msgstr "Upraawnienia usunięte pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:968 +msgid "Remove permissions" +msgstr "Usuń uprawnienia" + +#: .\BaCa2\widgets\forms\course.py:992 .\BaCa2\widgets\forms\course.py:1113 +msgid "Fall-off policy" +msgstr "Polityka spadku punktów" + +#: .\BaCa2\widgets\forms\course.py:1040 .\BaCa2\widgets\forms\course.py:1200 +msgid "Round " +msgstr "Runda " + +#: .\BaCa2\widgets\forms\course.py:1077 +msgid "Add round" +msgstr "Dodaj rundę" + +#: .\BaCa2\widgets\forms\course.py:1200 +msgid " edited successfully" +msgstr " edytowana pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:1287 +msgid "Round ID" +msgstr "ID rundy" + +#: .\BaCa2\widgets\forms\course.py:1303 +msgid "Round deleted successfully" +msgstr "Runa usunięta pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:1321 +msgid "If not provided - task name will be taken from package." +msgstr "Jeśli nie podano - nazwa zadania zostanie pobrana z danych paczki." + +#: .\BaCa2\widgets\forms\course.py:1334 .\BaCa2\widgets\forms\course.py:1487 +msgid "Points" +msgstr "Punkty" + +#: .\BaCa2\widgets\forms\course.py:1337 +msgid "If not provided - points will be taken from package." +msgstr "Jeśli nie podano - punkty zostaną pobrane z danych paczki." + +#: .\BaCa2\widgets\forms\course.py:1341 .\BaCa2\widgets\forms\course.py:1610 +msgid "Task package" +msgstr "Paczka z zadaniem" + +#: .\BaCa2\widgets\forms\course.py:1344 .\BaCa2\widgets\forms\course.py:1613 +msgid "Only .zip files are allowed" +msgstr "Tylko pliki .zip są dozwolone" + +#: .\BaCa2\widgets\forms\course.py:1347 .\BaCa2\widgets\forms\course.py:1492 +msgid "Judge mode" +msgstr "Tryb oceniania" + +#: .\BaCa2\widgets\forms\course.py:1424 +msgid "Task " +msgstr "Zadanie " + +#: .\BaCa2\widgets\forms\course.py:1460 +msgid "Add task" +msgstr "Dodaj zadanie" + +#: .\BaCa2\widgets\forms\course.py:1589 .\BaCa2\widgets\forms\course.py:1963 +msgid "Edit task" +msgstr "Edytuj zadanie" + +#: .\BaCa2\widgets\forms\course.py:1650 +msgid "Task re-uploaded successfully" +msgstr "Ponowne wgranie zadania zakończyło się sukcesem" + +#: .\BaCa2\widgets\forms\course.py:1670 +msgid "Re-upload task" +msgstr "Wgraj zadanie ponownie" + +#: .\BaCa2\widgets\forms\course.py:1704 +msgid "Task deleted successfully" +msgstr "Zadanie usunięte pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:1743 .\BaCa2\widgets\forms\course.py:1749 +msgid "Delete task" +msgstr "Usuń zadanie" + +#: .\BaCa2\widgets\forms\course.py:1745 +msgid "Confirm task deletion" +msgstr "Potwierdź usunięcie zadania" + +#: .\BaCa2\widgets\forms\course.py:1747 +msgid "" +"Are you sure you want to delete this task? This action cannot be undone." +msgstr "Czy na pewno chcesz usunąć to zadanie? Ta akcja nie może być cofnięta." + +#: .\BaCa2\widgets\forms\course.py:1766 +msgid "Task title" +msgstr "Nazwa zadania" + +#: .\BaCa2\widgets\forms\course.py:1772 +msgid "Task judge mode" +msgstr "Tryb oceniania zadania" + +#: .\BaCa2\widgets\forms\course.py:1777 +msgid "Task points" +msgstr "Punkty za zadanie" + +#: .\BaCa2\widgets\forms\course.py:1778 .\BaCa2\widgets\forms\course.py:1843 +#: .\BaCa2\widgets\forms\course.py:1877 +msgid "Memory limit" +msgstr "Limit pamięci" + +#: .\BaCa2\widgets\forms\course.py:1780 .\BaCa2\widgets\forms\course.py:1851 +#: .\BaCa2\widgets\forms\course.py:1885 +msgid "Time limit [s]" +msgstr "Limit czasu [s]" + +#: .\BaCa2\widgets\forms\course.py:1783 +msgid "Allowed extensions" +msgstr "Dozwolone rozszerzenia" + +#: .\BaCa2\widgets\forms\course.py:1784 +msgid "CPUs" +msgstr "Rdzenie CPU" + +#: .\BaCa2\widgets\forms\course.py:1828 +msgid "Test set name" +msgstr "Nazwa zestawu testów" + +#: .\BaCa2\widgets\forms\course.py:1835 +msgid "Test set weight" +msgstr "Waga zestawu testów" + +#: .\BaCa2\widgets\forms\course.py:1868 +msgid "Test name" +msgstr "Nazwa testu" + +#: .\BaCa2\widgets\forms\course.py:1893 +msgid "Change input file" +msgstr "Zmień plik wejściowy" + +#: .\BaCa2\widgets\forms\course.py:1901 +msgid "Change output file" +msgstr "Zmień plik wyjściowy" + +#: .\BaCa2\widgets\forms\course.py:1992 +msgid "General settings" +msgstr "Ustawienia ogólne" + +#: .\BaCa2\widgets\forms\course.py:2125 +#, python-brace-format +msgid "Task rejudged successfully - {submits_to_rejudge} submits affected." +msgstr "" +"Zadanie zostało ocenione ponownie - przetwarzanie {submits_to_rejudge} " +"zgłoszeń." + +#: .\BaCa2\widgets\forms\course.py:2166 .\BaCa2\widgets\forms\course.py:2171 +msgid "Rejudge task" +msgstr "Oceń zadanie ponownie" + +#: .\BaCa2\widgets\forms\course.py:2168 +msgid "Confirm task rejudging" +msgstr "Potwierdź ponowne ocenienie zadania" + +#: .\BaCa2\widgets\forms\course.py:2169 +msgid "" +"Are you sure you want to rejudge this task and all its submits? This may " +"take a while." +msgstr "" +"Czy na pewno chcesz ocenić ponownie to zadanie i wszystkie jego zgłoszenia? " +"Może to chwilę potrwać." + +#: .\BaCa2\widgets\forms\course.py:2174 +msgid "Task rejudged" +msgstr "Zadanie ocenione ponownie" + +#: .\BaCa2\widgets\forms\course.py:2175 +msgid "Task rejudged successfully." +msgstr "Ponowne ocenianie zadania zakończyło się sukcesem." + +#: .\BaCa2\widgets\forms\course.py:2178 +#, fuzzy +#| msgid "Task rejudging failed." +msgid "Task rejudging failed" +msgstr "Ponowne ocenianie zadania nie powiodło się" + +#: .\BaCa2\widgets\forms\course.py:2179 +msgid "Task rejudging failed." +msgstr "Ponowne ocenianie zadania nie powiodło się" + +#: .\BaCa2\widgets\forms\course.py:2235 +msgid "Mocking broker is enabled. No submissions will be sent to the broker." +msgstr "" +"Zaślepka brokera jest włączona. Żadne zgłoszenia nie zostaną wysłane do " +"brokera." + +#: .\BaCa2\widgets\forms\course.py:2303 +msgid "Submit created successfully" +msgstr "Zgłoszenie utworzone pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:2348 +msgid "New submission" +msgstr "Nowe zgłoszenie" + +#: .\BaCa2\widgets\forms\course.py:2358 +msgid "Submit ID" +msgstr "ID zgłoszenia" + +#: .\BaCa2\widgets\forms\course.py:2368 +msgid "Submit rejudged successfully" +msgstr "Ponowna ocena zgłoszenia została zainicjowana pomyślnie" + +#: .\BaCa2\widgets\forms\course.py:2387 .\BaCa2\widgets\forms\course.py:2391 +msgid "Rejudge submit" +msgstr "Oceń zgłoszenie ponownie" + +#: .\BaCa2\widgets\forms\course.py:2389 +msgid "Confirm submit rejudging" +msgstr "Potwierdź ponowną ocenę zgłoszenia" + +#: .\BaCa2\widgets\forms\course.py:2390 +msgid "Are you sure you want to rejudge this submit?" +msgstr "Czy na pewno chcesz ocenić ponownie to zgłoszenie?" + +#: .\BaCa2\widgets\forms\fields\base.py:62 +msgid "This field can only contain the following characters: " +msgstr "To pole może zawierać tylko następujące znaki: " + +#: .\BaCa2\widgets\forms\fields\base.py:95 +msgid "This field can only contain alphanumeric characters." +msgstr "To pole może zawierać tylko znaki alfanumeryczne." + +#: .\BaCa2\widgets\forms\fields\base.py:125 +msgid "This field cannot contain trailing whitespaces." +msgstr "To pole nie może zawierać końcowych białych znaków." + +#: .\BaCa2\widgets\forms\fields\base.py:137 +msgid "This field cannot contain double spaces." +msgstr "To pole nie może zawierać podwójnych spacji." + +#: .\BaCa2\widgets\forms\fields\base.py:147 +msgid "This field can only contain alphanumeric characters and spaces." +msgstr "To pole może zawierać tylko znaki alfanumeryczne i spacje." + +#: .\BaCa2\widgets\forms\fields\base.py:197 +msgid "This field must be a comma-separated list of strings." +msgstr "To pole musi być listą ciągów znaków oddzielonych przecinkami." + +#: .\BaCa2\widgets\forms\fields\base.py:216 +msgid "This field cannot contain empty strings." +msgstr "To pole nie może zawierać pustych ciągów znaków." + +#: .\BaCa2\widgets\forms\fields\base.py:229 +msgid "" +"This field must be a comma-separated list of strings from the following " +"list: " +msgstr "" +"To pole musi być listą ciągów znaków oddzielonych przecinkami z następującej " +"listy: " + +#: .\BaCa2\widgets\forms\fields\base.py:274 +msgid "This field must be a comma-separated list of integers." +msgstr "To pole musi być listą liczb całkowitych oddzielonych przecinkami." + +#: .\BaCa2\widgets\forms\fields\base.py:284 +msgid "" +"This field must be a comma-separated list of integers from the following " +"list: " +msgstr "" +"To pole musi być listą liczb całkowitych oddzielonych przecinkami z " +"następującej listy: " + +#: .\BaCa2\widgets\forms\fields\base.py:329 +#, python-brace-format +msgid "This field must be a comma-separated list of {self.model.__name__} IDs" +msgstr "" +"To pole musi być listą ID {self.model.__name__} oddzielonych przecinkami" + +#: .\BaCa2\widgets\forms\fields\base.py:547 +msgid "Loading..." +msgstr "Ładowanie..." + +#: .\BaCa2\widgets\forms\fields\base.py:574 +#: .\BaCa2\widgets\forms\fields\validation.py:81 +msgid "This field is required." +msgstr "To pole jest wymagane." + +#: .\BaCa2\widgets\forms\fields\course.py:29 +msgid "" +"Course name can only contain alphanumeric characters and the following " +"special characters: " +msgstr "" +"Nazwa kursu może zawierać tylko znaki alfanumeryczne i następujące znaki " +"specjalne: " + +#: .\BaCa2\widgets\forms\fields\course.py:41 +msgid "Course code" +msgstr "Kod kursu" + +#: .\BaCa2\widgets\forms\fields\course.py:60 +msgid "Course with this code already exists." +msgstr "Kurs o tym kodzie już istnieje." + +#: .\BaCa2\widgets\forms\fields\course.py:74 +msgid "Course code can only contain alphanumeric characters andunderscores." +msgstr "Kurs może zawierać tylko znaki alfanumeryczne i podkreślenia." + +#: .\BaCa2\widgets\forms\fields\course.py:101 +msgid "" +"USOS code can only contain alphanumeric characters and the following special " +"characters: - + . /" +msgstr "" +"Kod USOS może zawierać tylko znaki alfanumeryczne i następujące znaki " +"specjalne: - + . /" + +#: .\BaCa2\widgets\forms\fields\validation.py:60 +msgid "This field cannot be empty." +msgstr "To pole nie może być puste." + +#: .\BaCa2\widgets\forms\fields\validation.py:66 +msgid "This field must contain at least " +msgstr "To pole musi zawierać co najmniej " + +#: .\BaCa2\widgets\forms\fields\validation.py:97 +msgid "This file type is not allowed. Supported file types: " +msgstr "Ten typ pliku nie jest dozwolony. Obsługiwane typy plików: " + +#: .\BaCa2\widgets\forms\main.py:21 +msgid "We will send an email with login and password to this address." +msgstr "Wyślemy e-mail z loginem i hasłem na ten adres." + +#: .\BaCa2\widgets\forms\main.py:37 +msgid "User with this email already exists." +msgstr "Użytkownik z tym adresem e-mail już istnieje." + +#: .\BaCa2\widgets\forms\main.py:49 +msgid "Your new BaCa2 account" +msgstr "Twoje nowe konto BaCa2" + +#: .\BaCa2\widgets\forms\main.py:58 +msgid "User created and email sent." +msgstr "Użytkownik utworzony i e-mail wysłany." + +#: .\BaCa2\widgets\forms\main.py:76 +msgid "Add new user" +msgstr "Dodaj nowego użytkownika" + +#: .\BaCa2\widgets\forms\main.py:91 +msgid "Nickname" +msgstr "Pseudonim" + +#: .\BaCa2\widgets\forms\main.py:101 .\BaCa2\widgets\forms\main.py:114 +msgid "Personal data changed." +msgstr "Dane osobowe zmienione." + +#: .\BaCa2\widgets\forms\main.py:103 +msgid "No changes to apply." +msgstr "Brak zmian do zastosowania." + +#: .\BaCa2\widgets\forms\main.py:108 +msgid "Nickname is too long." +msgstr "Pseudonim jest za długi." + +#: .\BaCa2\widgets\forms\main.py:110 +msgid "Nickname is too short." +msgstr "Pseudonim jest za krótki." + +#: .\BaCa2\widgets\forms\main.py:135 +msgid "Save changes" +msgstr "Zapisz zmiany" + +#: .\BaCa2\widgets\listing\base.py:412 +msgid "Confirm record deletion" +msgstr "Potwierdź usunięcie rekordu" + +#: .\BaCa2\widgets\listing\base.py:413 +msgid "Are you sure you want to delete this record" +msgstr "Czy na pewno chcesz usunąć ten rekord" + +#: .\BaCa2\widgets\navigation.py:27 +msgid "Dashboard" +msgstr "Pulpit" + +#: .\BaCa2\widgets\navigation.py:88 +msgid "Expand" +msgstr "Rozwiń" + +#: .\BaCa2\widgets\navigation.py:89 +msgid "Collapse" +msgstr "Zwiń" + +#: .\BaCa2\widgets\popups\forms.py:52 +msgid "Confirm" +msgstr "Potwierdź" + +#: .\BaCa2\widgets\popups\forms.py:54 +msgid "Cancel" +msgstr "Anuluj" + +#: .\BaCa2\widgets\popups\forms.py:113 +msgid "Success" +msgstr "Sukces" + +#: .\BaCa2\widgets\popups\forms.py:115 .\BaCa2\widgets\popups\forms.py:165 +msgid "OK" +msgstr "OK" + +#: .\BaCa2\widgets\popups\forms.py:163 +msgid "Failure" +msgstr "Błąd" + +#: .\BaCa2\widgets\text_display.py:31 .\BaCa2\widgets\text_display.py:58 +msgid "No file to display" +msgstr "Brak pliku do wyświetlenia" + +#~ msgid "Add members" +#~ msgstr "Dodaj uczestników" + +#~ msgid "Choose users to add" +#~ msgstr "Wybierz użytkowników do dodania" + +#~ msgid "Account Inactive" +#~ msgstr "Konto nieaktywne" + +#~ msgid "This account is inactive." +#~ msgstr "To konto jest nieaktywne." + +#~ msgid "E-mail Addresses" +#~ msgstr "Adresy e-mail" + +#~ msgid "The following e-mail addresses are associated with your account:" +#~ msgstr "Następujące adresy e-mail są powiązane z twoim kontem:" + +#~ msgid "Verified" +#~ msgstr "Zweryfikowany" + +#~ msgid "Unverified" +#~ msgstr "Niezweryfikowany" + +#~ msgid "Primary" +#~ msgstr "Podstawowy" + +#~ msgid "Make Primary" +#~ msgstr "Ustaw jako podstawowy" + +#~ msgid "Re-send Verification" +#~ msgstr "Ponownie wyślij weryfikację" + +#~ msgid "Remove" +#~ msgstr "Usuń" + +#~ msgid "Warning:" +#~ msgstr "Ostrzeżenie:" diff --git a/BaCa2/main/admin.py b/BaCa2/main/admin.py index 1d485528..89ca85d9 100644 --- a/BaCa2/main/admin.py +++ b/BaCa2/main/admin.py @@ -1,6 +1,11 @@ from django.contrib import admin -from .models import User +from .models import Course, User -# Register your models here. -admin.site.register(User) + +class UserAdmin(admin.ModelAdmin): + list_display = ['email', 'is_superuser', 'is_active', 'date_joined', 'last_login'] + + +admin.site.register(User, UserAdmin) +admin.site.register(Course) diff --git a/BaCa2/main/migrations/0001_initial.py b/BaCa2/main/migrations/0001_initial.py deleted file mode 100644 index 9a9619c3..00000000 --- a/BaCa2/main/migrations/0001_initial.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-05 20:39 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('password', models.CharField(max_length=128, verbose_name='password')), - ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), - ('email', models.EmailField(max_length=255, unique=True, verbose_name='email address')), - ('username', models.CharField(max_length=255, unique=True, verbose_name='username')), - ('is_staff', models.BooleanField(default=False)), - ('is_superuser', models.BooleanField(default=False)), - ('first_name', models.CharField(blank=True, max_length=255, verbose_name='first name')), - ('last_name', models.CharField(blank=True, max_length=255, verbose_name='last name')), - ('date_joined', models.DateField(auto_now_add=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/BaCa2/main/migrations/0002_group.py b/BaCa2/main/migrations/0002_group.py deleted file mode 100644 index 46ce6f3d..00000000 --- a/BaCa2/main/migrations/0002_group.py +++ /dev/null @@ -1,22 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-06 11:36 - -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ('main', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='Group', - fields=[ - ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, primary_key=True, serialize=False, to='auth.group')), - ('course_id', models.CharField(max_length=255)), - ], - ), - ] diff --git a/BaCa2/main/migrations/0003_course_groupcourse_usercourse_delete_group.py b/BaCa2/main/migrations/0003_course_groupcourse_usercourse_delete_group.py deleted file mode 100644 index 2fc54369..00000000 --- a/BaCa2/main/migrations/0003_course_groupcourse_usercourse_delete_group.py +++ /dev/null @@ -1,44 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-06 12:33 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ('main', '0002_group'), - ] - - operations = [ - migrations.CreateModel( - name='Course', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=255)), - ('short_name', models.CharField(max_length=31)), - ], - ), - migrations.CreateModel( - name='GroupCourse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.course')), - ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='auth.group')), - ], - ), - migrations.CreateModel( - name='UserCourse', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.course')), - ('group_course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='main.groupcourse')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.DeleteModel( - name='Group', - ), - ] diff --git a/BaCa2/main/migrations/0004_course_db_name.py b/BaCa2/main/migrations/0004_course_db_name.py deleted file mode 100644 index 47e8f15e..00000000 --- a/BaCa2/main/migrations/0004_course_db_name.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-10 11:22 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('main', '0003_course_groupcourse_usercourse_delete_group'), - ] - - operations = [ - migrations.AddField( - model_name='course', - name='db_name', - field=models.CharField(default='default', max_length=127), - ), - ] diff --git a/BaCa2/main/models.py b/BaCa2/main/models.py index 39372a5e..a5e10e78 100644 --- a/BaCa2/main/models.py +++ b/BaCa2/main/models.py @@ -1,347 +1,2412 @@ -from typing import List +from __future__ import annotations -from django.db import models -from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager -from django.contrib.auth.models import Group, Permission, ContentType -from django.utils.translation import gettext_lazy as _ -from django.utils import timezone -from django.core.exceptions import ValidationError +from typing import Any, Callable, List +from django.conf import settings +from django.contrib.auth.models import AbstractBaseUser, BaseUserManager, Group, Permission +from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.db.models.query import QuerySet +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ -from BaCa2.choices import PermissionTypes, DefaultCourseGroups +from core.choices import BasicModelAction, ModelAction, PermissionCheck, UserJob +from core.tools.misc import try_getting_name_from_email +from course.manager import create_course as create_course_db +from course.manager import delete_course as delete_course_db +from course.routing import InCourse +from util.models import get_model_permissions, model_cls +from util.models_registry import ModelsRegistry +from util.other import replace_special_symbols class UserManager(BaseUserManager): """ - This class manages the creation of new user objects. Its methods allow to create instances of default users and - superusers with moderation privileges. - The class extends BaseUserManager provided in django.contrib.auth and replaces the default UserManager model. + This class manages the creation of new :py:class:`User` objects. Its methods allow for creation + of default and superuser user instances. + + This class extends the BaseUserManager provided in :py:mod:`django.contrib.auth` and replaces + the default UserManager class. """ + @staticmethod + @transaction.atomic + def _create_settings() -> 'Settings': + """ + Create a new user :py:class:`Settings` object. + + :return: New user settings object. + :rtype: :py:class:`Settings` + """ + return Settings.objects.create() + + @transaction.atomic def _create_user( - self, - email: str, - username: str, - password: str, - is_staff: bool, - is_superuser: bool, - **other_fields + self, + email: str, + is_superuser: bool, + password: str | None, + **other_fields ) -> 'User': """ - Create a new user using the provided information. This method is used by the create_user method and the - create_superuser method. + Create a new :py:class:`User` object along with its :py:class:`Settings` object. This + private method is used by the :py:meth:`create_user` and :py:meth:`create_superuser` + manager methods. :param email: New user's email. :type email: str - :param username: New user's username. - :type username: str :param password: New user's password. :type password: str :param is_staff: Indicates whether the new user has moderation privileges. :type is_staff: bool :param is_superuser: Indicates whether the new user has all moderation privileges. :type is_superuser: bool - :param **other_fields: Values for non-required user fields. - """ - - if not email: - raise ValidationError('Email address is required') - if not username: - raise ValidationError('Username is required') - if not password: - raise ValidationError('Password is required') + :param other_fields: Values for non-required user model fields. - now = timezone.now() - _email = self.normalize_email(email) + :return: The newly created user. + :rtype: :py:class:`User` + """ user = self.model( - email=_email, - username=username, - is_staff=is_staff, + email=self.normalize_email(email), is_superuser=is_superuser, - date_joined=now, + date_joined=timezone.now(), + user_settings=self._create_settings(), **other_fields ) - user.set_password(password) + if password: + user.set_password(password) + else: + user.set_unusable_password() user.save(using='default') return user - def create_user(self, email: str, username: str, password: str, **other_fields) -> 'User': + @transaction.atomic + def create_user(self, email: str, password: str = None, **other_fields) -> 'User': """ - Create a new user without moderation privileges. + Create a new :py:class:`User` without moderation privileges. :param email: New user's email. :type email: str - :param username: New user's username. - :type username: str :param password: New user's password. :type password: str - :param **other_fields: Values for non-required user fields. - """ + :param other_fields: Values for non-required user fields. - return self._create_user(email, username, password, False, False, **other_fields) + :return: The newly created user. + :rtype: :py:class:`User` + """ + return self._create_user(email=email, + password=password, + is_superuser=False, + **other_fields) - def create_superuser(self, email: str, username: str, password: str, **other_fields) -> 'User': + @transaction.atomic + def create_superuser(self, email: str, password: str, **other_fields) -> User: """ - Create a new user with all moderation privileges. + Create a new :py:class:`User` with all moderation privileges. :param email: New user's email. :type email: str - :param username: New user's username. - :type username: str :param password: New user's password. :type password: str - :param **other_fields: Values for non-required user fields. + :param other_fields: Values for non-required user fields. + + :return: The newly created superuser. + :rtype: :py:class:`User` + """ + return self._create_user(email=email, + password=password, + is_superuser=True, + **other_fields) + + def get_or_create(self, email: str, **kwargs): """ + Get or create a user with given email. If a user with given email already exists, the user + is returned. If no user with given email exists, a new user is created with given email and + any other provided fields. - return self._create_user(email, username, password, True, True, **other_fields) + :param email: Email of the user to get or create. + :type email: str + :param kwargs: Values for non-required user fields. + :return: The user with given email or the newly created user. + :rtype: :py:class:`User` + """ + try: + return self.get(email=email) + except self.model.DoesNotExist: + return self.create_user(email=email, **kwargs) -class Course(models.Model): + @staticmethod + @transaction.atomic + def delete_user(user: str | int | User) -> None: + """ + Delete given :py:class:`User` object along with its :py:class:`Settings` object. + + :param user: The user to delete. Can be specified by its id, email or the user object. + :type user: str | int | :py:class:`User` + """ + ModelsRegistry.get_user(user).delete() + + @staticmethod + def is_email_allowed(email: str) -> bool: + """ + Check if given email is allowed by the system. Allowed emails are defined in + core.settings.login.ALLOWED_INTERNAL_EMAILS. + + :param email: Email to check. + :type email: str + :return: True if the email is allowed, False otherwise. + :rtype: bool + """ + postfix = email.split('@')[-1] + return f'@{postfix}' in settings.ALLOWED_INTERNAL_EMAILS + + def create_if_allowed(self, email: str) -> User: + """ + Create new user if email is allowed by the system. Allowed emails are defined in + core.settings.login.ALLOWED_INTERNAL_EMAILS. + + :param email: Email of the user to create. + :type email: str + :return: The newly created user. + :rtype: :py:class:`User` + + :raises ValidationError: If the email is not allowed by the system. + """ + if not self.is_email_allowed(email): + raise ValidationError(_('Email is not in internal domain. ' + 'To add external users contact your administrator')) + + first_name, last_name = try_getting_name_from_email(email) + return self.create_user(email=email, + first_name=first_name, + last_name=last_name) + + def get_or_create_if_allowed(self, email: str) -> User: + """ + Get or create a user with given email if email is allowed by the system. Allowed emails are + defined in core.settings.login.ALLOWED_INTERNAL_EMAILS. + + :param email: Email of the user to get or create. + :type email: str + :return: The user with given email or the newly created user. + :rtype: :py:class:`User` + + :raises ValidationError: If the email is not allowed by the system. + """ + try: + return self.get(email=email) + except self.model.DoesNotExist: + return self.create_if_allowed(email=email) + + +class CourseManager(models.Manager): """ - This class represents a course in the default database and contains a field pointing to the course's database. - It allows for assigning users to course groups. + This class manages the creation and deletion of :py:class:`Course` objects. It calls on + :py:mod:`course.manager` methods to create and delete course databases along with corresponding + course objects in the 'default' database. """ - #: Name of the course. - name = models.CharField( - max_length=255 - ) - #: Short name of the course. - short_name = models.CharField( - max_length=31 - ) - #: Name of the course's database. :py:class:`course.routing.InCourse` :py:mod:`course.models` - db_name = models.CharField( - max_length=127, - default='default' - ) + # ------------------------------------ Course creation ------------------------------------- # + + @transaction.atomic + def create_course(self, + name: str, + short_name: str = '', + usos_course_code: str | None = None, + usos_term_code: str | None = None, + role_presets: List[RolePreset] = None) -> Course: + """ + Create a new :py:class:`Course` with given name, short name, USOS code and roles created + from given presets. A new database for the course is also created which can be accessed + using the course's short name with :py:class:`course.routing.InCourse`. + + :param name: Name of the new course. + :type name: str + :param short_name: Short name of the new course. If no short name is provided, a unique + short name is generated based on the course name or USOS code (if it's provided). The + short name can only contain alphanumeric characters and underscores. + :type short_name: str + :param usos_course_code: Course code of the course in the USOS system. + :type usos_course_code: str + :param usos_term_code: Term code of the course in the USOS system. + :type usos_term_code: str + :param role_presets: List of role presets that will be used to set up roles for the new + course. The first role in the list will be used as the default role for the course. + In addition, a course will always receive an admin role with all course + permissions assigned. If no presets are provided the admin role will be the default + role for the course. + + :return: The newly created course. + :rtype: Course + + :raises ValidationError: If the name or short name is too short. If either USOS course code + or USOS term code is provided without the other. + """ + if (usos_course_code is not None) ^ (usos_term_code is not None): + raise ValidationError('Both USOS course code and USOS term code must be provided or ' + 'neither') + + if usos_course_code is not None and usos_term_code is not None: + self._validate_usos_code(usos_course_code, usos_term_code) + + if len(name) < 5: + raise ValidationError('Course name must be at least 5 characters long') + + if short_name: + if len(short_name) < 3: + raise ValidationError('Short name must be at least 3 characters long') + short_name = short_name.lower() + else: + short_name = self._generate_short_name(name, usos_course_code, usos_term_code) + self._validate_short_name(short_name) - def __str__(self): + if not role_presets: + role_presets = [] + + roles = [Role.objects.create_role_from_preset(preset) for preset in role_presets] + roles.append(self.create_admin_role()) + + create_course_db(short_name) + course = self.model( + name=name, + short_name=short_name, + USOS_course_code=usos_course_code, + USOS_term_code=usos_term_code, + default_role=roles[0], + admin_role=roles[-1] + ) + course.save(using='default') + course.add_roles(roles) + return course + + @staticmethod + def create_admin_role() -> Role: + """ + Create an admin role for a course. The role has all permissions related to all course + actions assigned to it. + + :return: The admin role. + :rtype: Role + """ + admin_role = Role.objects.create_role(name=_('admin'), + description=_('Admin role for course leaders')) + admin_role.add_permissions(Course.CourseAction.labels) + return admin_role + + # ------------------------------------ Course deletion ------------------------------------- # + + @staticmethod + @transaction.atomic + def delete_course(course: Course | str | int) -> None: + """ + Delete given :py:class:`Course` and its database. In addition, all roles assigned to the + course will be deleted. + + :param course: The course to delete. + :type course: Course | str | int + """ + course = ModelsRegistry.get_course(course) + course.delete() + + # ----------------------------------- Auxiliary methods ------------------------------------ # + + @staticmethod + def _validate_short_name(short_name: str) -> None: + """ + Validate a given short name. A short name is valid if it consists only of alphanumeric + characters and underscores. It also has to be unique. + + :param short_name: Short name to validate. + :type short_name: str + + :raises ValidationError: If the short name contains non-alphanumeric characters other than + underscores or if a course with given short name already exists. + """ + if any(not (c.isalnum() or c == '_') for c in short_name): + raise ValidationError('Short name can only contain alphanumeric characters and' + 'underscores') + if Course.objects.filter(short_name=short_name).exists(): + raise ValidationError('A course with this short name already exists') + + @staticmethod + def _generate_short_name(name: str, + usos_course_code: str | None = None, + usos_term_code: str | None = None) -> str: """ - Returns the name of this course's database. + Generate a unique short name for a :py:class:`Course` based on its name or its USOS code. + + :param name: Name of the course. + :type name: str + :param usos_course_code: Subject code of the course in the USOS system. + :type usos_course_code: str + :param usos_term_code: Term code of the course in the USOS system. + :type usos_term_code: str + + :return: Short name for the course. + :rtype: str + + :raises ValidationError: If a unique short name could not be generated for the course. """ - return f"{self.db_name}" + if usos_course_code and usos_term_code: + short_name = f'{replace_special_symbols(usos_course_code, "_")}__' \ + f'{replace_special_symbols(usos_term_code, "_")}' + short_name = short_name.lower() + else: + short_name = '' + for word in name.split(): + short_name += word[0] + + now = timezone.now() + short_name += f'_{str(now.year)}' + short_name = short_name.lower() - def add_user(self, user: 'User', group: Group): - if not group: - group = Group.objects.get( - groupcourse__course=self, - name=DefaultCourseGroups.VIEWER - ) + if Course.objects.filter(short_name=short_name).exists(): + short_name += '_' + \ + f'{len(Course.objects.filter(short_name__startswith=short_name)) + 1}' - if not group: - raise ValidationError('No default viewer group exists for this course') + if Course.objects.filter(short_name=short_name).exists(): + raise ValidationError('Could not generate a unique short name for the course') + return short_name + @staticmethod + def _validate_usos_code(usos_course_code: str, usos_term_code: str) -> None: + """ + Validate USOS course and term codes. The codes are valid only if their combination is unique + across all courses. + + :param usos_course_code: Course code of the course in the USOS system. + :type usos_course_code: str + :param usos_term_code: Term code of the course in the USOS system. + :type usos_term_code: str + + :raises ValidationError: If the USOS codes are invalid. + """ + if Course.objects.filter(USOS_course_code=usos_course_code, + USOS_term_code=usos_term_code).exists(): + course = Course.objects.get(USOS_course_code=usos_course_code, + USOS_term_code=usos_term_code) + raise ValidationError(f'Attempted to create a course with the same USOS course and term' + f'codes as the {course} course') -class User(AbstractBaseUser, PermissionsMixin): +class Course(models.Model): """ - This class stores user information. Its methods can be used to check permissions pertaining to the default database - models as well as course access and models from course databases. - The class extends AbstractBaseUser and PermissionsMixin classes provided in django.contrib.auth to replace the - default User model. + This class represents a course in the default database. The short name of the course can be + used to access the course's database with :py:class:`course.routing.InCourse`. The methods of + this class deal with managing course database objects, as well as the users assigned to the + course and their roles within it. """ - #: User's email. Can be used to log in. - email = models.EmailField( - _("email address"), - max_length=255, - unique=True - ) - #: Unique username. Can be used during login instead of email. - username = models.CharField( - _("username"), - max_length=255, - unique=True + class CourseMemberError(Exception): + """ + Exception raised when an error occurs related to course members. + """ + pass + + class CourseRoleError(Exception): + """ + Exception raised when an error occurs related to course roles. + """ + pass + + #: Manager class for the Course model. + objects = CourseManager() + + # ----------------------------------- Course information ----------------------------------- # + + #: Name of the course. + name = models.CharField( + verbose_name=_('course name'), + max_length=100, + blank=False ) - #: Indicates whether user has moderation privileges. Required by built-in Django authorisation system. - is_staff = models.BooleanField( - default=False + #: Short name of the course. + #: Used to access the course's database with :py:class:`course.routing.InCourse`. + short_name = models.CharField( + verbose_name=_('course short name'), + max_length=40, + unique=True, + blank=False ) - #: Indicates whether user has all available moderation privileges. Required by built-in Django authorisation system. - is_superuser = models.BooleanField( - default=False + #: Subject code of the course in the USOS system. + #: Used for automatic assignment of USOS registered users to the course + USOS_course_code = models.CharField( + verbose_name=_('Subject code'), + max_length=20, + blank=True, + null=True ) - #: User's first name. - first_name = models.CharField( - _("first name"), - max_length=255, - blank=True + #: Term code of the course in the USOS system. + #: Used for automatic assignment of USOS registered users to the course + USOS_term_code = models.CharField( + verbose_name=_('Term code'), + max_length=20, + blank=True, + null=True ) - #: User's last name. - last_name = models.CharField( - _("last name"), - max_length=255, - blank=True + + # ------------------------------------- Course roles --------------------------------------- # + + #: The default role assigned to users within the course, if no other role is specified. + default_role = models.ForeignKey( + verbose_name=_('default role'), + to='Role', + on_delete=models.RESTRICT, + null=False, + blank=False, + related_name='+' ) - #: Date of account creation. - date_joined = models.DateField( - auto_now_add=True + #: The admin role for the course. The role has all permissions related to all course models. + #: This group is automatically created during course creation. + admin_role = models.ForeignKey( + verbose_name=_('admin role'), + to='Role', + on_delete=models.RESTRICT, + null=False, + blank=False, + related_name='+' ) - #: Indicates which field should be considered as username. Required when replacing default Django User model. - USERNAME_FIELD = 'username' - #: Indicates which field should be considered as email. Required when replacing default Django User model. - EMAIL_FIELD = 'email' - #: Indicates which fields besides the USERNAME_FIELD are required when creating a User object. - REQUIRED_FIELDS = ['email'] + # -------------------------------- Actions and permissions --------------------------------- # - #: Indicates which class is used to manage User objects. - objects = UserManager() + class Meta: + permissions = [ + # Member related permissions + ('view_course_member', _('Can view course members')), + ('add_course_member', _('Can add course members')), + ('remove_course_member', _('Can remove course members')), + ('change_course_member_role', _('Can change course member\'s role')), + ('add_course_admin', _('Can add course admins')), + ('add_course_members_csv', _('Can add course members from CSV')), - def __str__(self): - return self.username + # Role related permissions + ('view_course_role', _('Can view course role')), + ('edit_course_role', _('Can edit course role')), + ('add_course_role', _('Can add course role')), + ('delete_course_role', _('Can delete course role')), - @classmethod - def exists(cls, user_id: int) -> bool: + # Solution related permissions + ('view_own_submit', _('Can view own submits')), + ('add_submit_after_deadline', _('Can add submit after round deadline')), + ('add_submit_before_start', _('Can add submit before round start')), + ('view_own_result', _('Can view own results')), + + # Task related permissions + ('reupload_task', _('Can reupload task')), + ] + + class BasicAction(ModelAction): + ADD = 'add', 'add_course' + DEL = 'delete', 'delete_course' + EDIT = 'edit', 'change_course' + VIEW = 'view', 'view_course' + + class CourseAction(ModelAction): + # Member related actions + VIEW_MEMBER = 'view_member', 'view_course_member' + ADD_MEMBER = 'add_member', 'add_course_member' + ADD_MEMBERS_CSV = 'add_members_csv', 'add_course_members_csv' + DEL_MEMBER = 'remove_member', 'remove_course_member' + CHANGE_MEMBER_ROLE = 'change_member_role', 'change_course_member_role' + ADD_ADMIN = 'add_admin', 'add_course_admin' + + # Role related actions + VIEW_ROLE = 'view_role', 'view_course_role' + ADD_ROLE = 'add_role', 'add_course_role' + EDIT_ROLE = 'edit_role', 'edit_course_role' + DEL_ROLE = 'delete_role', 'delete_course_role' + + # Solution related actions + VIEW_ROUND = 'view_round', 'view_round' + ADD_ROUND = 'add_round', 'add_round' + EDIT_ROUND = 'edit_round', 'change_round' + DEL_ROUND = 'delete_round', 'delete_round' + + # Task related actions + VIEW_TASK = 'view_task', 'view_task' + ADD_TASK = 'add_task', 'add_task' + EDIT_TASK = 'edit_task', 'change_task' + DEL_TASK = 'delete_task', 'delete_task' + REUPLOAD_TASK = 'reupload_task', 'reupload_task' + REJUDGE_TASK = 'rejudge_task', 'rejudge_task' + + # Submit related actions + VIEW_SUBMIT = 'view_submit', 'view_submit' + VIEW_OWN_SUBMIT = 'view_own_submit', 'view_own_submit' + ADD_SUBMIT = 'add_submit', 'add_submit' + EDIT_SUBMIT = 'edit_submit', 'change_submit' + DEL_SUBMIT = 'delete_submit', 'delete_submit' + REJUDGE_SUBMIT = 'rejudge_submit', 'rejudge_submit' + ADD_SUBMIT_AFTER_DEADLINE = 'add_submit_after_deadline', 'add_submit_after_deadline' + ADD_SUBMIT_BEFORE_START = 'add_submit_before_start', 'add_submit_before_start' + + # Result related actions + VIEW_RESULT = 'view_result', 'view_result' + VIEW_OWN_RESULT = 'view_own_result', 'view_own_result' + EDIT_RESULT = 'edit_result', 'change_result' + DEL_RESULT = 'delete_result', 'delete_result' + VIEW_CODE = 'view_code', 'view_code' + VIEW_COMPILE_LOG = 'view_compilation_logs', 'view_compilation_logs' + VIEW_CHECKER_LOG = 'view_checker_logs', 'view_checker_logs' + VIEW_STUDENT_OUTPUT = 'view_student_output', 'view_student_output' + VIEW_BENCHMARK_OUTPUT = 'view_benchmark_output', 'view_benchmark_output' + VIEW_INPUTS = 'view_inputs', 'view_inputs' + VIEW_USED_MEMORY = 'view_used_memory', 'view_used_memory' + VIEW_USED_TIME = 'view_used_time', 'view_used_time' + + # ---------------------------------- Course representation --------------------------------- # + + def __str__(self) -> str: """ - Check whether a user with given id exists. + Returns the string representation of the Course object. - :param user_id: The id of the user in question. - :type user_id: int - :return: True if the user exists. + :return: Short name and name of the course. + :rtype: str """ + return f'{self.short_name}__{self.name}' - return cls.objects.exists(pk=user_id) + def get_data(self, user: User | str | int = None) -> dict: + """ + Returns the contents of a Course object's fields as a dictionary. Used to send course data + to the frontend. - def can_access_course(self, course: Course) -> bool: + :param user: The user whose role within the course should be included in the returned + dictionary (if specified). + :type user: User | str | int + :return: Dictionary containing the course's id, name, short name, USOS codes and default + role (as well as the role of a given user if specified). + :rtype: dict """ - Check whether the user has been assigned to the given course through the UserCourse model. + result = { + 'id': self.id, + 'name': self.name, + 'short_name': self.short_name, + 'USOS_course_code': self.USOS_course_code, + 'USOS_term_code': self.USOS_term_code, + 'default_role': f'{self.default_role}' + } + if user: + result['user_role'] = f'{self.user_role(user).name}' + return result - :param course: Course to check user access to. - :type course: Course - :return: True if user has been assigned to the given course. + # -------------------------------------- Role getters -------------------------------------- # + + @property + def course_roles(self) -> QuerySet[Role]: """ + Returns a QuerySet of all roles assigned to the course. - if UserCourse.objects.filter( - user=self, - course=course - ).exists(): - return True - return False + :return: QuerySet of all roles assigned to the course. + :rtype: QuerySet[Role] + """ + return Role.objects.filter(course=self) - def check_general_permissions( - self, - model: models.Model, - permissions: str or PermissionTypes or List[PermissionTypes] = 'all' - ) -> bool: + def get_role(self, name: str) -> Role: """ - Check permissions relating to default database models (non-course models). + Returns the role with given name assigned to the course. - :param model: The model to check permissions for. - :type model: models.Model - :param permissions: Permissions to check for the given model. Permissions can be given as a PermissionTypes - object/List of objects or as a string (in the format .) - the default - option 'all' checks all permissions related to the model, both standard and custom. The 'all_standard' - option checks the 'view', 'change', 'add' and 'delete' permissions for the given model. - :type permissions: str or PermissionTypes or List[PermissionTypes] = 'all' - :returns: True if user possesses given permissions for the given model. + :param name: Name of the role to return. + :type name: str + + :return: The role with given name. + :rtype: Role + + :raises Course.CourseRoleError: If no role with given name is assigned to the course. """ + if not self.role_exists(name): + raise Course.CourseRoleError(f'Course {self} does not have a role with name {name}') + return ModelsRegistry.get_role(name, self) - if permissions == 'all': - permissions = [f'{model._meta.app_label}.{p.codename}' for p in Permission.objects.filter( - content_type=ContentType.objects.get_for_model(model).id - )] + def get_role_permissions(self, role: str | int | Role) -> QuerySet[Permission]: + """ + Returns the QuerySet of all permissions assigned to a given role. - elif permissions == 'all_standard': - permissions = [f'{model._meta.app_label}.{p.label}_{model._meta.model_name}' for p in PermissionTypes] + :param role: Role to return permissions for. The role can be specified as either the role + object, its id or its name. + :type role: Role | str | int - elif isinstance(permissions, PermissionTypes): - permissions = [f'{model._meta.app_label}.{permissions.label}_{model._meta.model_name}'] + :return: QuerySet of all permissions assigned to the role. + :rtype: QuerySet[Permission] + """ + return ModelsRegistry.get_role(role, self).permissions.all() - elif isinstance(permissions, List): - permissions = [f'{model._meta.app_label}.{p.label}_{model._meta.model_name}' for p in permissions] + # --------------------------------- Adding/removing roles ---------------------------------- # - else: - permissions = [permissions] + @transaction.atomic + def add_role(self, role: Role | int) -> None: + """ + Add a new role to the course if it passes validation. - for p in permissions: - if not self.has_perm(p): - return False - return True + :param role: The role to add. The role can be specified as either the role object or its id. + :type role: Role | int + """ + role = ModelsRegistry.get_role(role) + self._validate_new_role(role) + role.assign_to_course(self) - def check_course_permissions( - self, - course: Course, - model: models.Model, - permissions: str or PermissionTypes or List[PermissionTypes] = 'all' - ) -> bool: + @transaction.atomic + def add_roles(self, roles: List[Role | int]) -> None: """ - Check permissions relating to course database models (checks whether the user has been assigned given - model permissions within the scope of a particular course). + Add a list of roles to the course if they pass validation. - :param course: The course to check the model permissions within. - :type course: Course - :param model: The model to check the permissions for. - :type model: models.Model - :param permissions: Permissions to check for the given model within the confines of the course. Permissions can - be given as a PermissionTypes object/List of objects or 'all' - the default 'all' option checks all - permissions related to the model. - :returns: True if the user possesses given permissions for the given model within the scope of the course. + :param roles: The roles to add. The roles can be specified as either the role objects or + their ids. + :type roles: List[Role | int] """ + for role in ModelsRegistry.get_roles(roles): + self.add_role(role) - if not self.can_access_course(course): - return False + @transaction.atomic + def create_role(self, + name: str, + permissions: List[str] | List[int] | List[Permission], + description: str = '') -> None: + """ + Create a new role for the course with given name, description and permissions. - if permissions == 'all': - permissions = PermissionTypes + :param name: Name of the new role. + :type name: str + :param permissions: List of permissions to assign to the role. The permissions can be + specified as either the permission objects, their ids or their codenames. + :type permissions: List[Permission] | List[str] | List[int] + :param description: Description of the new role. + :type description: str + """ + self.add_role(Role.objects.create_role(name=name, + description=description, + permissions=permissions)) - if isinstance(permissions, PermissionTypes): - permissions = [permissions] + @transaction.atomic + def create_role_from_preset(self, preset: int | RolePreset) -> None: + """ + Create a new role for the course based on a given preset. - for permission in permissions: - if not Group.objects.filter( - permissions__codename=f'{permission.label}_{model._meta.model_name}', - groupcourse__course=course, - groupcourse__usercourse__user=self - ).exists(): - return False - return True + :param preset: The preset to create the role from. The preset can be specified as either + its id or the preset object. + :type preset: int | RolePreset + """ + self.add_role(Role.objects.create_role_from_preset(ModelsRegistry.get_role_preset(preset))) + @transaction.atomic + def remove_role(self, role: str | int | Role) -> None: + """ + Remove a role from the course and delete it. Cannot be used to remove the default role, the + admin role or a role with users assigned to it. -class GroupCourse(models.Model): - """ - This model is used to assign groups to courses. - """ + :param role: The role to remove. The role can be specified as either the role object, its id + or its name. + :type role: Role | str | int - #: Assigned group. :py:class:`django.contrib.auth.models.Group` :py:mod:`django.contrib.auth.models.` - group = models.ForeignKey( - Group, - on_delete=models.CASCADE - ) - #: Course the group is assigned to. :py:class:`Course` - course = models.ForeignKey( - Course, - on_delete=models.CASCADE - ) + :raises Course.CourseRoleError: If the role is the default role, the admin role, has + users assigned to it or does not exist within the course. + """ + if not self.role_exists(role): + raise Course.CourseRoleError('Attempted to remove a role that does not exist within ' + 'the course') - def __str__(self): - return f'groupcourse: {self.group.name} for {self.course}' + role = ModelsRegistry.get_role(role, self) + if role == self.default_role: + raise Course.CourseRoleError('Default role cannot be removed from the course') + if role == self.admin_role: + raise Course.CourseRoleError('Admin role cannot be removed from the course') + if role.user_set.exists(): + raise Course.CourseRoleError('Cannot remove a role with users assigned to it') -class UserCourse(models.Model): - """ - Model used to assign users to courses with a given scope of course-specific permissions. This is achieved by - assigning the given user to a GroupCourse object referencing the appropriate course and group with the appropriate - permission set. - """ + role.delete() - #: Assigned user. :py:class:`User` - user = models.ForeignKey( - User, - on_delete=models.CASCADE - ) - #: Course the user is assigned to. :py:class:`Course` - course = models.ForeignKey( - Course, - on_delete=models.CASCADE - ) - #: GroupCourse object representing the course group user is assigned to. :py:class:`GroupCourse` - group_course = models.ForeignKey( - GroupCourse, - on_delete=models.CASCADE - ) + # -------------------------------- Editing role permissions -------------------------------- # + + @transaction.atomic + def change_role_permissions(self, + role: str | int | Role, + permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Replace the permissions assigned to a role with a given set of permissions. Cannot be used + to change permissions for the admin role. + + :param role: The role whose permissions are to be changed. The role can be specified as + either the role object, its id or its name. + :type role: Role | str | int + :param permissions: List of permissions to assign to the role. The permissions can be + specified as either a list of permission objects, their ids or their codenames. + :type permissions: List[Permission] | List[str] | List[int] + + :raises Course.CourseRoleError: If the role is the admin role or does not exist within the + course. + """ + if not self.role_exists(role): + raise Course.CourseRoleError('Attempted to change permissions for a role that does not' + 'exist within the course') + + role = ModelsRegistry.get_role(role, self) + + if role == self.admin_role: + raise Course.CourseRoleError('Cannot change permissions for the admin role') + + role.change_permissions(permissions) + + @transaction.atomic + def add_role_permissions(self, + role: str | int | Role, + permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Add given permissions to a role. + + :param role: The role to add permissions to. The role can be specified as either the role + object, its id or its name. + :type role: Role | str | int + :param permissions: List of permissions to add to the role. The permissions can be + specified as either a list of permission objects, their ids or their codenames. + :type permissions: List[Permission] | List[str] | List[int] + + :raises Course.CourseRoleError: If the role does not exist within the course. + """ + if not self.role_exists(role): + raise Course.CourseRoleError('Attempted to add permissions to a role that does not ' + 'exist within the course') + + ModelsRegistry.get_role(role, self).add_permissions(permissions) + + @transaction.atomic + def remove_role_permissions(self, + role: str | int | Role, + permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Remove given permissions from a role. + + :param role: The role to remove permissions from. The role can be specified as either the + role object, its id or its name. + :type role: Role | str | int + :param permissions: List of permissions to remove from the role. The permissions can be + specified as either a list of permission objects, their ids or their codenames. + :type permissions: List[Permission] | List[str] | List[int] + + :raises Course.CourseRoleError: If the role is the admin role or does not exist within the + course. + """ + if not self.role_exists(role): + raise Course.CourseRoleError('Attempted to remove permissions from a role that does ' + 'not exist within the course') + + role = ModelsRegistry.get_role(role, self) + + if role == self.admin_role: + raise Course.CourseRoleError('Cannot remove permissions from the admin role') + + role.remove_permissions(permissions) + + # ------------------------------------- Member getters ------------------------------------- # + + def members(self) -> QuerySet[User]: + """ + Returns a QuerySet of all users assigned to the course. + + :return: QuerySet of all users assigned to the course. + :rtype: QuerySet[User] + """ + return User.objects.filter(roles__course=self) + + def user_role(self, user: str | int | User) -> Role: + """ + Returns the role of a given user within the course. + + :param user: The user whose role is to be returned. The user can be specified as either + the user object, its id or its email. + + :return: The role of the user within the course. + :rtype: Role + + :raises Course.CourseMemberError: If the user is not a member of the course. + """ + if not self.user_is_member(user): + raise Course.CourseMemberError('User is not a member of the course') + return ModelsRegistry.get_user(user).roles.get(course=self) + + # -------------------------------- Adding/removing members --------------------------------- # + + @transaction.atomic + def add_member(self, user: str | int | User, role: str | int | Role | None = None) -> None: + """ + Assign given user to the course with given role. If no role is specified, the user is + assigned to the course with the default role. Cannot be used to assign a user to the admin + role. + + :param user: The user to be assigned. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + :param role: The role to assign the user to. If no role is specified, the user is assigned + to the course with the default role. The role can be specified as either the role + object, its id or its name. + :type role: Role | str | int | None + + :raises Course.CourseRoleError: If the role is the admin role or does not exist within the + course. + """ + if role is None: + role = self.default_role + + if not self.role_exists(role): + raise Course.CourseRoleError('Attempted to assign a user to a non-existent role') + + role = ModelsRegistry.get_role(role, self) + + if role == self.admin_role: + raise Course.CourseRoleError('Cannot assign a user to the admin role using the ' + 'add_user method. Use add_admin instead.') + self._validate_new_member(user) + role.add_member(user) + + @transaction.atomic + def add_members(self, + users: List[str] | List[int] | List[User], + role: str | int | Role, + ignore_errors: bool = False) -> None: + """ + Assign given list of users to the course with given role. If no role is specified, the users + are assigned to the course with the default role. Cannot be used to assign users to the + admin role. + + :param users: The users to be assigned. The users can be specified as either a list of user + objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + :param role: The role to assign the users to. If no role is specified, the users are + assigned to the course with the default role. The role can be specified as either the + role object, its id or its name. + :type role: Role | str | int | None + :param ignore_errors: If set to True, the method will not raise an error if a user is not a + member of the course. Instead, the user will be skipped. + :type ignore_errors: bool + """ + ModelsRegistry.get_role(role, self).add_members(users, ignore_errors=ignore_errors) + + @transaction.atomic + def add_admin(self, user: str | int | User) -> None: + """ + Add a new member to the course with the admin role. + + :param user: The user to be assigned. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + """ + self._validate_new_member(user) + self.admin_role.add_member(user) + + @transaction.atomic + def add_admins(self, users: List[str] | List[int] | List[User]) -> None: + """ + Add given list of users to the course with the admin role. + + :param users: The users to be assigned. The users can be specified as either a list of user + objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + """ + for user in users: + self._validate_new_member(user) + self.admin_role.add_members(users) + + @transaction.atomic + def remove_member(self, user: str | int | User) -> None: + """ + Remove given user from the course. + + :param user: The user to be removed. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + + :raises Course.CourseMemberError: If the user is not a member of the course. + """ + if not self.user_is_member(user): + raise Course.CourseMemberError('Attempted to remove a user who is not a member of the' + 'course.') + if self.user_is_admin(user): + raise Course.CourseMemberError('Attempted to remove a user from the admin role using ' + 'the remove_member method. Use remove_admin instead.') + + self.user_role(user).remove_member(user) + + @transaction.atomic + def remove_members(self, users: List[str] | List[int] | List[User]) -> None: + """ + Remove given list of users from the course. + + :param users: The users to be removed. The users can be specified as either a list of user + objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + """ + for user in users: + self.remove_member(user) + + @transaction.atomic + def remove_admin(self, user: str | int | User) -> None: + """ + Remove given user from the course admin role. + + :param user: The user to be removed. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + """ + if not self.user_is_member(user): + raise Course.CourseMemberError('Attempted to remove from the admin role a user who is ' + 'not a member of the course.') + if not self.user_is_admin(user): + raise Course.CourseMemberError('Attempted to remove a user from the admin role who is ' + 'not an admin in the course.') + + self.admin_role.remove_member(user) + + @transaction.atomic + def remove_admins(self, users: List[str] | List[int] | List[User]) -> None: + """ + Remove given list of users from the course admin role. - def __str__(self): - return f'usercourse: {self.user} to {self.group_course}' + :param users: The users to be removed. The users can be specified as either a list of user + objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + """ + self.admin_role.remove_members(users) + + # --------------------------------- Changing member roles ---------------------------------- # + + @transaction.atomic + def change_member_role(self, + user: str | int | User, + new_role: str | int | Role) -> None: + """ + Change the role of a given user within the course. Cannot be used to change the role of a + user assigned to the admin role or to assign a user to the admin role. + + :param user: The user whose role is to be changed. The user can be specified as either the + user object, its id or its email. + :type user: User | str | int + :param new_role: The role to assign to the user. The role can be specified as either the + role object, its id or its name. + :type new_role: Role | str | int + + :raises Course.CourseMemberError: If the user is not a member of the course. + :raises Course.CourseRoleError: If the role is the admin role, the user is assigned to the + admin role or the role does not exist within the course. + """ + if not self.user_is_member(user): + raise Course.CourseMemberError('Change of role was attempted for a user who is not a ' + 'member of the course') + if not self.role_exists(new_role): + raise Course.CourseRoleError('Attempted to change a member\'s role to a non-existent ' + 'role') + + new_role = ModelsRegistry.get_role(new_role, self) + + if new_role == self.admin_role: + raise Course.CourseRoleError('Attempted to change a member\'s role to the admin role') + if self.user_role(user) == self.admin_role: + raise Course.CourseRoleError('Attempted to change the role of a member assigned to the' + 'admin role') + + self.user_role(user).remove_member(user) + new_role.add_member(user) + + @transaction.atomic + def change_members_role(self, + users: List[str] | List[int] | List[User], + new_role: str | int | Role) -> None: + """ + Change the role of a given list of users within the course. Cannot be used to change the + role of users assigned to the admin role or to assign users to the admin role. + + :param users: The users whose role is to be changed. The users can be specified as either + a list of user objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + :param new_role: The role to assign to the users. The role can be specified as either the + role object, its id or its name. + :type new_role: Role | str | int + """ + users = ModelsRegistry.get_users(users) + + for user in users: + self.change_member_role(user, new_role) + + @transaction.atomic + def make_member_admin(self, user: str | int | User) -> None: + """ + Assign a given user to the admin role. Cannot be used to assign a user who is not a member + of the course to the admin role. + + :param user: The user to be assigned. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + + :raises Course.CourseMemberError: If the user is not a member of the course. + """ + if not self.user_is_member(user): + raise Course.CourseMemberError('Attempted to assign a user who is not a member of the ' + 'course to the admin role') + self.user_role(user).remove_member(user) + self.admin_role.add_member(user) + + @transaction.atomic + def make_members_admin(self, users: List[str] | List[int] | List[User]) -> None: + """ + Assign given list of users to the admin role. Cannot be used to assign users who are not + members of the course to the admin role. + + :param users: The users to be assigned. The users can be specified as either a list of user + objects, their ids or their emails. + :type users: List[User] | List[str] | List[int] + """ + users = ModelsRegistry.get_users(users) + + for user in users: + self.make_member_admin(user) + + # ---------------------------------------- Checks ------------------------------------------ # + + def role_exists(self, role: Role | str | int) -> bool: + """ + Check whether a role with given name exists within the course or if a given role is assigned + to the course. + + :param role: Role to check. The role can be specified as either the role object, its id or + its name. + :type role: Role | str | int + + :return: `True` if the role exists within the course, `False` otherwise. + :rtype: bool + """ + if isinstance(role, str): + return self.course_roles.filter(name=role).exists() + elif isinstance(role, int): + return self.course_roles.filter(id=role).exists() + else: + return self.course_roles.filter(id=role.id).exists() + + def role_has_permission(self, + role: Role | str | int, + permission: Permission | str | int) -> bool: + """ + Check whether a given role has a given permission. + + :param role: The role to check. The role can be specified as either the role object, its id + or its name. + :type role: Role | str | int + :param permission: The permission to check for. The permission can be specified as either + the permission object, its id or its codename. + :type permission: Permission | str | int + + :return: `True` if the role has the permission, `False` otherwise. + :rtype: bool + """ + return ModelsRegistry.get_role(role, self).has_permission(permission) + + def user_is_member(self, user: str | int | User) -> bool: + """ + Check whether a given user is a member of the course. + + :param user: The user to check. The user can be specified as either the user object, its id + or its email. + + :return: `True` if the user is a member of the course, `False` otherwise. + :rtype: bool + """ + return ModelsRegistry.get_user(user).roles.filter(course=self).exists() + + def user_is_admin(self, user: str | int | User) -> bool: + """ + Check whether a given user is assigned to the admin role within the course. + + :param user: The user to check. The user can be specified as either the user object, its id + or its email. + + :return: `True` if the user is assigned to the admin role, `False` otherwise. + :rtype: bool + """ + return self.user_has_role(user, self.admin_role) + + def user_has_role(self, user: str | int | User, role: Role | str | int) -> bool: + """ + Check whether a given user has a given role within the course. + + :param user: The user to check. The user can be specified as either the user object, its id + or its email. + :type user: User | str | int + :param role: The role to check. The role can be specified as either the role object, its id + or its name. + :type role: Role | str | int + + :return: `True` if the user has the role, `False` otherwise. + :rtype: bool + """ + return self.user_is_member(user) and \ + self.user_role(user) == ModelsRegistry.get_role(role, self) + + # --------------------------------------- Validators --------------------------------------- # + + def _validate_new_role(self, role: Role | int) -> None: + """ + Check whether a given role can be assigned to the course. Raises an exception if the role + cannot be assigned. + + :param role: The role to be validated. The role can be specified as either the role object + or its id. + :type role: Group | int + + :raises Course.CourseRoleError: If a role with the same name as the new role is already + assigned to the course or if the new role is already assigned to a course. + """ + role = ModelsRegistry.get_role(role) + if role.course is not None: + raise Course.CourseRoleError(f'Role {role.name} is already assigned to a course') + if self.role_exists(str(role.name)): + raise Course.CourseRoleError(f'A role with name {role.name} is already assigned to ' + f'the course. Role names must be unique within the scope ' + f'of a course.') + + def _validate_new_member(self, user: str | int | User) -> None: + """ + Check whether a given user can be assigned to the course. + + :param user: The user to be validated. The user can be specified as either the user object, + its id or its email. + :type user: User | str | int + + :raises Course.CourseMemberError: If the user is already a member of the course. + """ + if self.user_is_member(user): + raise Course.CourseMemberError('User is already a member of the course') + + # -------------------------------- Inside course actions ----------------------------------- # + + from course.models import Round, Submit, Task + + @staticmethod + def inside_course(func: Callable) -> Callable: + """ + Decorator used to wrap methods that deal with course database objects. The decorator + ensures that the method is called within the scope of a course database. + + :param func: The method to be wrapped. + :type func: Callable + + :return: The wrapped method (in course scope) + :rtype: Callable + """ + + def action(self: Course, *args, **kwargs): + with InCourse(self): + return func(*args, **kwargs) + + return action + + # Round actions ------------- + + #: Creates a new round in the course using :py:meth:`course.models.Round.objects.create_round`. + create_round = inside_course(Round.objects.create_round) + + #: Deletes a round from the course using :py:meth:`course.models.Round.objects.delete_round`. + delete_round = inside_course(Round.objects.delete_round) + + #: Returns a QuerySet of all rounds in the course using + #: :py:meth:`course.models.Round.objects.all_rounds`. + rounds = inside_course(Round.objects.all_rounds) + + #: Getter for Round model + get_round = inside_course(ModelsRegistry.get_round) + + # Task actions -------------- + + #: Creates a new task in the course using :py:meth:`course.models.Task.objects.create_task`. + create_task = inside_course(Task.objects.create_task) + + #: Deletes a task from the course using :py:meth:`course.models.Task.objects.delete_task`. + delete_task = inside_course(Task.objects.delete_task) + + #: Getter for Task model + get_task = inside_course(ModelsRegistry.get_task) + + # Submit actions ------------ + + #: Creates a new submit in the course using + #: :py:meth:`course.models.Submit.objects.create_submit`. + create_submit = inside_course(Submit.objects.create_submit) + + #: Deletes a submit from the course using :py:meth:`course.models.Submit.objects.delete_submit`. + delete_submit = inside_course(Submit.objects.delete_submit) + + #: Getter for Submit model + get_submit = inside_course(ModelsRegistry.get_submit) + + # --------------------------------------- Deletion ----------------------------------------- # + + def delete(self, using: Any = None, keep_parents: bool = False) -> None: + """ + Delete the course, all its roles and its database. + """ + delete_course_db(self.short_name) + super().delete(using, keep_parents) + + +class Settings(models.Model): + """ + This model represents a user's (:py:class:`User`) settings. It is used to store personal, + user-specific data pertaining to the website display options, notification preferences, etc. + """ + #: User's preferred UI theme. + theme = models.CharField( + verbose_name=_('UI theme'), + max_length=255, + default='dark' + ) + + +class User(AbstractBaseUser): + """ + This class stores user information. Its methods can be used to check permissions pertaining to + the default database models as well as course access and models from course databases. + The class extends AbstractBaseUser and PermissionsMixin classes provided in django.contrib.auth + to replace the default User model. + """ + + class UserPermissionError(Exception): + """ + Exception raised when an error occurs related to user permissions. + """ + pass + + # ---------------------------------- Personal information ---------------------------------- # + + #: User's email. Used to log in. + email = models.EmailField( + verbose_name=_('email address'), + max_length=255, + unique=True + ) + #: User's first name. + first_name = models.CharField( + verbose_name=_('first name'), + max_length=255, + blank=True + ) + #: User's last name. + last_name = models.CharField( + verbose_name=_('last name'), + max_length=255, + blank=True + ) + #: Date of account creation. + date_joined = models.DateField( + verbose_name=_('date joined'), + auto_now_add=True + ) + #: User's settings. + user_settings = models.OneToOneField( + verbose_name=_('user settings'), + to=Settings, + on_delete=models.RESTRICT, + null=False, + blank=False + ) + + usos_id = models.BigIntegerField( + verbose_name='USOS id', + null=True, + blank=True, + ) + + user_job = models.CharField( + verbose_name=_('user job'), + max_length=2, + blank=False, + null=False, + default='ST', + choices=UserJob.choices + ) + + # ---------------------------------- Authentication data ----------------------------------- # + + #: Indicates whether user has all available moderation privileges. + is_superuser = models.BooleanField( + verbose_name=_('superuser status'), + default=False, + help_text=_( + 'Designates that this user has all permissions without explicitly assigning them.' + ), + ) + #: Groups the user belongs to. Groups are used to grant permissions to multiple users at once + #: and to assign course access and roles to users. + groups = models.ManyToManyField( + to=Group, + verbose_name=_('groups'), + blank=True, + help_text=_( + 'The groups this user belongs to. A user will get all permissions granted to each of ' + 'their groups.' + ), + related_name='user_set', + related_query_name='user', + ) + #: Course roles user has been assigned to. Used to check course access and permissions. + roles = models.ManyToManyField( + to='Role', + verbose_name=_('roles'), + blank=True, + help_text=_('The course roles this user has been assigned to.'), + related_name='user_set', + related_query_name='user', + ) + #: Permissions specifically granted to the user. + user_permissions = models.ManyToManyField( + to=Permission, + verbose_name=_('user permissions'), + blank=True, + help_text=_('Specific permissions for this user.'), + related_name='user_set', + related_query_name='user', + ) + nickname = models.CharField( + verbose_name=_('nickname'), + max_length=255, + blank=True, + null=True, + ) + + class BasicAction(ModelAction): + ADD = 'add', 'add_user' + DEL = 'delete', 'delete_user' + EDIT = 'edit', 'change_user' + VIEW = 'view', 'view_user' + + # ------------------------------------ Django settings ------------------------------------- # + + #: Indicates which field should be considered as username. + #: Required when replacing default Django User model. + USERNAME_FIELD = 'email' + + #: Indicates which field should be considered as email. + #: Required when replacing default Django User model. + EMAIL_FIELD = 'email' + + #: Indicates which fields besides the USERNAME_FIELD are required when creating a User object. + REQUIRED_FIELDS = [] + + #: Manager class for the User model. + objects = UserManager() + + # ----------------------------------- User representation ---------------------------------- # + + def __str__(self) -> str: + """ + Returns the string representation of the User object. + + :return: User's email. + :rtype: str + """ + return self.email + + def get_data(self, course: Course | str | int = None) -> dict: + """ + Returns the contents of a User object's fields as a dictionary. Used to send user data + to the frontend. + + :param course: Course to return user's role in (if specified). + :type course: Course | str | int + :return: Dictionary containing the user's id, email, first name, last name, superuser + status, date of account creation and role in the course (if specified). + :rtype: dict + """ + result = { + 'id': self.id, + 'email': self.email, + 'first_name': self.first_name, + 'last_name': self.last_name, + 'is_superuser': self.is_superuser, + 'date_joined': self.date_joined, + 'f_is_superuser': _('YES') if self.is_superuser else _('NO'), + } + if course is not None: + result['user_role'] = ModelsRegistry.get_course(course).user_role(self).name + return result + + # ------------------------------------ Auxiliary Checks ------------------------------------ # + + @classmethod + def exists(cls, user_id: int) -> bool: + """ + Check whether a user with given id exists. + + :param user_id: The id of the user in question. + :type user_id: int + + :return: `True` if user with given id exists, `False` otherwise. + :rtype: bool + """ + + return cls.objects.exists(pk=user_id) + + def in_group(self, group: Group | str | id) -> bool: + """ + Check whether the user belongs to a given group. + + :param group: Group to check user's membership in. Can be specified as either the group + object, its name or its id. + :type group: Group | str | id + + :return: `True` if the user belongs to the group, `False` otherwise. + :rtype: bool + """ + return self.groups.filter(id=ModelsRegistry.get_group_id(group)).exists() + + def can_access_course(self, course: Course | str | int) -> bool: + """ + Check whether the user has been assigned to a given course. + + :param course: Course to check user's access to. Can be specified as either the course + object, its short name or its id. + :type course: Course | str | int + + :return: `True` if user has been assigned to the course, `False` otherwise. + :rtype: bool + """ + return Role.objects.filter(user=self, course=ModelsRegistry.get_course(course)).exists() + + def is_course_admin(self) -> bool: + """ + :return: `True` if the user is assigned to the admin role in at least one course, `False` + otherwise. + :rtype: bool + """ + courses = self.get_courses() + for course in courses: + if course.user_is_admin(self): + return True + return False + + @property + def is_uj_user(self) -> bool: + return self.usos_id is not None + + # ---------------------------------- Permission editing ----------------------------------- # + + @transaction.atomic + def add_permission(self, permission: Permission | str | int) -> None: + """ + Add an individual permission to the user. + + :param permission: Permission to add. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :raises User.UserPermissionError: If the user already has the permission. + """ + permission = ModelsRegistry.get_permission(permission) + + if self.has_individual_permission(permission): + raise User.UserPermissionError(f'Attempted to add permission {permission.codename} ' + f'to user {self} who already has it') + + self.user_permissions.add(permission) + + @transaction.atomic + def add_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Add multiple individual permissions to the user. + + :param permissions: List of permissions to add. The permissions can be specified as either + the permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + permissions = ModelsRegistry.get_permissions(permissions) + + for permission in permissions: + self.add_permission(permission) + + @transaction.atomic + def remove_permission(self, permission: Permission | str | int) -> None: + """ + Remove an individual permission from the user. + + :param permission: Permission to remove. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :raises User.UserPermissionError: If the user does not have the permission. + """ + permission = ModelsRegistry.get_permission(permission) + + if not self.has_individual_permission(permission): + raise User.UserPermissionError(f'Attempted to remove permission {permission.codename} ' + f'from user {self} who does not have it') + + self.user_permissions.remove(permission) + + @transaction.atomic + def remove_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Remove multiple individual permissions from the user. + + :param permissions: List of permissions to remove. The permissions can be specified as + either the permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + permissions = ModelsRegistry.get_permissions(permissions) + + for permission in permissions: + self.remove_permission(permission) + + # ------------------------------------ Permission checks ----------------------------------- # + + def has_individual_permission(self, permission: Permission | str | int) -> bool: + """ + Check whether the user possesses a given permission on an individual level (does not check + group-level permissions or superuser status). + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :return: `True` if the user has the permission, `False` otherwise. + :rtype: bool + """ + return self.user_permissions.filter( + id=ModelsRegistry.get_permission_id(permission) + ).exists() + + def has_group_permission(self, permission: Permission | str | int) -> bool: + """ + Check whether the user possesses a given permission on a group level (does not check + individual permissions or superuser status). + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :return: `True` if the user has the permission, `False` otherwise. + :rtype: bool + """ + return self.groups.filter(permissions=ModelsRegistry.get_permission(permission)).exists() + + def has_role_permission(self, permission: Permission | str | int) -> bool: + """ + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + :return: `True` if the user belongs to any role with the specified permission, `False` + otherwise. + :rtype: bool + """ + return self.roles.filter(permissions=ModelsRegistry.get_permission(permission)).exists() + + def has_permission(self, permission: Permission | str | int) -> bool: + """ + Check whether the user possesses a given permission. The method checks both individual and + group-level permissions as well as superuser status. + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :return: `True` if the user has the permission or is a superuser, `False` otherwise. + :rtype: bool + """ + if self.is_superuser: + return True + return self.has_individual_permission(permission) or \ + self.has_group_permission(permission) + + def has_action_permission(self, action: ModelAction, + permission_check: PermissionCheck = PermissionCheck.GEN) -> bool: + """ + Check whether the user has the permission to perform a given action on model with defined + model actions. Depending on the type of permission check specified, the method can check + user-specific permissions, group-level permissions or both (the default option, in this case + will also check superuser status). + + :param action: Action to check for. + :type action: ModelAction + :param permission_check: Type of permission check to perform. + :type permission_check: PermissionCheck + + :return: `True` if the user has the permission, `False` otherwise. If general permission + check is performed, the method will also return `True` if the user is a superuser. + :rtype: bool + + raises ValueError: If the permission_check value is not recognized. + """ + if permission_check == PermissionCheck.GEN: + return self.has_permission(action.label) + elif permission_check == PermissionCheck.INDV: + return self.has_individual_permission(action.label) + elif permission_check == PermissionCheck.GRP: + return self.has_group_permission(action.label) + raise ValueError(f'Invalid permission check type: {permission_check}') + + def has_basic_model_permissions( + self, + model: model_cls, + permissions: BasicModelAction | List[BasicModelAction] = 'all', + permission_check: PermissionCheck = PermissionCheck.GEN + ) -> bool: + """ + Check whether a user possesses a specified basic permission/list of basic permissions for a + given 'default' database model. Depending on the type of permission check specified, the + method can check user-specific permissions, group-level permissions or both (the default + option, in this case will also check superuser status). + + The default permissions option 'all' checks all basic permissions related to the model. + Basic permissions are the permissions automatically created by Django for each model (add, + change, delete, view). + + :param model: The model to check permissions for. + :type model: Type[models.Model] + :param permissions: Permissions to check for the given model. Permissions can be given as a + BasicPermissionAction object/List of objects. The default option 'all' checks all basic + action permissions related to the model. + :type permissions: BasicModelAction or List[BasicPermissionTypes] + :param permission_check: Type of permission check to perform. + :type permission_check: PermissionCheck + + :returns: `True` if the user possesses the specified permission/s for the given model, + `False` otherwise. If general permission check is performed, the method will also return + `True` if the user is a superuser. + :rtype: bool + """ + if permission_check == PermissionCheck.GEN and self.is_superuser: + return True + + permissions = get_model_permissions(model, permissions) + has_perm = {p: False for p in permissions} + + if permission_check == PermissionCheck.GEN or permission_check == PermissionCheck.GRP: + for perm in permissions: + if self.has_group_permission(perm): + has_perm[perm] = True + if permission_check == PermissionCheck.GEN or permission_check == PermissionCheck.INDV: + for perm in permissions: + if self.has_individual_permission(perm): + has_perm[perm] = True + + return all(has_perm.values()) + + def has_course_permission(self, + permission: Permission | str | int, + course: Course | str | int) -> bool: + """ + Check whether the user possesses a given permission within a given :py:class:`Course`. Also + checks superuser status. + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + :param course: Course to check permission for. The course can be specified as either the + Course object, its short name or its id. + :type course: Course | str | int + + :returns: `True` if the user has the permission or is superuser, `False` otherwise. + :rtype: bool + """ + if self.is_superuser: + return True + return ModelsRegistry.get_course(course).user_role(self).has_permission(permission) + + def has_course_action_permission(self, action: ModelAction, course: Course | str | int) -> bool: + """ + Check whether the user has the permission to perform a given action on model with defined + model actions within a given :py:class:`Course`. Also checks superuser status. + + :param action: Action to check for. + :type action: ModelAction + :param course: Course to check permission for. The course can be specified as either the + Course object, its short name or its id. + :type course: Course | str | int + + :return: `True` if the user has the permission or is superuser, `False` otherwise. + :rtype: bool + """ + return self.has_course_permission(action.label, course) + + def has_basic_course_model_permissions( + self, + model: model_cls, + course: Course | str | int, + permissions: BasicModelAction | List[BasicModelAction] = 'all' + ) -> bool: + """ + Check whether a user possesses a specified permission/list of permissions for a given + 'course' database model. Does not check user-specific permissions or group-level + permissions, checks only course-level permissions based on the user's role within the + course and superuser status. + + :param model: The model to check permissions for. + :type model: Type[models.Model] + :param course: Course to check permission for. The course can be specified as either the + Course object, its short name or its id. + :type course: Course | str | int + :param permissions: Permissions to check for the given model. Permissions can be given as a + basicModelAction object/List of objects. The default option 'all' checks all basic + action permissions related to the model. + :type permissions: BasicModelAction or List[PermissionTypes] + + :returns: `True` if the user possesses the specified permission/s for the given model or + is a superuser, `False` otherwise. + :rtype: bool + """ + if self.is_superuser: + return True + + permissions = get_model_permissions(model, permissions) + + for p in permissions: + if not self.has_course_permission(p, ModelsRegistry.get_course(course)): + return False + return True + + # ---------------------------------- Permission getters ----------------------------------- # + + def get_individual_permissions(self) -> List[Permission]: + """ + Returns a list of all individual permissions possessed by the user. + + :returns: List of all individual permissions possessed by the user. + :rtype: List[Permission] + """ + return list(self.user_permissions.all()) + + def get_group_permissions(self) -> List[Permission]: + """ + Returns a list of all group-level permissions possessed by the user. + + :returns: List of all group-level permissions possessed by the user. + :rtype: List[Permission] + """ + groups = self.groups.all() + permissions = [] + for group in groups: + permissions.extend(list(group.permissions.all())) + return permissions + + def get_permissions(self) -> List[Permission]: + """ + Returns a list of all permissions possessed by the user, both individual and group-level. + + :returns: List of all permissions possessed by the user. + :rtype: List[Permission] + """ + return self.get_individual_permissions() + self.get_group_permissions() + + def get_course_permissions(self, course: Course | str | int) -> List[Permission]: + """ + Returns a list of all permissions possessed by the user within a given course. + + :param course: Course to check permission for. The course can be specified as either the + Course object, its short name or its id. + :type course: Course | str | int + + :returns: List of all permissions possessed by the user within the given course. + :rtype: List[Permission] + """ + return ModelsRegistry.get_course(course).user_role(self).permissions.all() + + # ------------------------------------- Other getters -------------------------------------- # + + def get_courses(self) -> QuerySet[Course]: + """ + :returns: QuerySet of all :class:`Course` objects the user is assigned to. + :rtype: List[:class:`Course`] + """ + return Course.objects.filter(role_set__user=self) + + @staticmethod + def get_user_job( + is_student: bool = False, + is_doctoral: bool = False, + is_employee: bool = False, + is_admin: bool = False, + ): + if is_admin: + return UserJob.AD + if is_employee: + return UserJob.EM + if is_doctoral: + return UserJob.DC + if is_student: + return UserJob.ST + return UserJob.ST + + # --------------------------------------- Deletion ----------------------------------------- # + + def delete(self, using=None, keep_parents=False): + """ + Delete the user and their settings. + """ + settings = self.user_settings + super().delete(using, keep_parents) + settings.delete() + + def get_full_name(self): + return f'{self.first_name} {self.last_name}' + + +class RoleManager(models.Manager): + """ + Manager class for the Role model. Governs the creation of custom and preset roles as well as + their deletion. + """ + + @transaction.atomic + def create_role(self, + name: str, + description: str = '', + permissions: List[Permission] | List[str] | List[int] = None, + course: Course = None) -> Role: + """ + Create a new role with given name and permissions assigned to a specified course (if no + course is specified, the role will be unassigned). + + :param name: Name of the role. + :type name: str + :param description: Description of the role. + :type description: str + :param permissions: Permissions which should be assigned to the role. The permissions can be + specified as either the permission objects, their codenames or their ids. If no + permissions are specified, the role will be created without any permissions. + :type permissions: List[Permission] | List[str] | List[int] + :param course: Course the role should be assigned to. + :type course: Course + + :return: The newly created role. + :rtype: Role + """ + if not permissions: + permissions = [] + else: + permissions = ModelsRegistry.get_permissions(permissions) + + role = self.model(name=name, description=description) + role.save() + + for permission in permissions: + role.permissions.add(permission) + + if course: + course.add_role(role) + + return role + + @transaction.atomic + def create_role_from_preset(self, preset: RolePreset, course: Course = None) -> Role: + """ + Create a new role from given preset and assign it to a specified course (if no course is + specified, the role will be unassigned). + + :param preset: Preset to create the role from. + :type preset: RolePreset + :param course: Course the role should be assigned to. + :type course: Course + + :return: The newly created role. + :rtype: Role + """ + return self.create_role( + name=preset.name, + permissions=[perm for perm in preset.permissions.all()], + course=course + ) + + @transaction.atomic + def delete_role(self, role: Role | int) -> None: + """ + Delete a role. + + :param role: Role to delete. The role can be specified as either the role object or its id. + :type role: Role | int + """ + role = ModelsRegistry.get_role(role) + role.delete() + + +class Role(models.Model): + """ + This model represents a role within a course. It is used to assign users to courses and to + define the permissions they have within the course. + """ + + class RolePermissionError(Exception): + """ + Exception raised when an error occurs related to role permissions. + """ + pass + + class RoleMemberError(Exception): + """ + Exception raised when an error occurs related to role members. + """ + pass + + #: Name of the role. + name = models.CharField( + verbose_name=_('role name'), + max_length=100, + blank=False, + null=False + ) + #: Description of the role. + description = models.TextField( + verbose_name=_('role description'), + blank=True, + null=True + ) + #: Permissions assigned to the role. + permissions = models.ManyToManyField( + to=Permission, + verbose_name=_('role permissions'), + blank=True + ) + #: Course the role is assigned to. A single role can only be assigned to a single course. + course = models.ForeignKey( + to=Course, + on_delete=models.CASCADE, + verbose_name=_('course'), + related_name='role_set', + null=True, + ) + + objects = RoleManager() + + def __str__(self) -> str: + """ + :return: :py:meth:`Course.__str__` representation of the course and the name of the role. + :rtype: str + """ + return f'{self.name}' + + def has_permission(self, permission: Permission | str | int) -> bool: + """ + Check whether the role has a given permission. + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :return: `True` if the role has the permission, `False` otherwise. + :rtype: bool + """ + return self.permissions.filter(id=ModelsRegistry.get_permission_id(permission)).exists() + + @transaction.atomic + def add_permission(self, permission: Permission | str | int) -> None: + """ + Add a permission to the role. + + :param permission: Permission to add. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :raises Role.RolePermissionError: If the role already has the permission. + """ + permission = ModelsRegistry.get_permission(permission) + + if self.has_permission(permission): + raise Role.RolePermissionError(f'Attempted to add permission {permission.codename} ' + f'to role {self} which already has it') + + self.permissions.add(permission) + + @transaction.atomic + def add_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Add multiple permissions to the role. + + :param permissions: List of permissions to add. The permissions can be specified as either + a list of permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + permissions = ModelsRegistry.get_permissions(permissions) + + for permission in permissions: + self.add_permission(permission) + + @transaction.atomic + def remove_permission(self, permission: Permission | str | int) -> None: + """ + Remove a permission from the role. + + :param permission: Permission to remove. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :raises Role.RolePermissionError: If the role does not have the permission. + """ + permission = ModelsRegistry.get_permission(permission) + + if not self.has_permission(permission): + raise Role.RolePermissionError(f'Attempted to remove permission {permission.codename} ' + f'from role {self} which does not have it') + + self.permissions.remove(permission) + + @transaction.atomic + def remove_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Remove multiple permissions from the role. + + :param permissions: List of permissions to remove. The permissions can be specified as + either a list of permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + permissions = ModelsRegistry.get_permissions(permissions) + + for permission in permissions: + self.remove_permission(permission) + + @transaction.atomic + def change_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Change the permissions assigned to the role. All previously assigned permissions will be + removed and replaced with the new ones. + + :param permissions: List of permissions to add. The permissions can be specified as either + a list of permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + self.permissions.clear() + self.add_permissions(permissions) + + @transaction.atomic + def assign_to_course(self, course: Course | str | int) -> None: + """ + Assign the role to a course. + + :param course: Course to assign the role to. The course can be specified as either the + course object, its short name or its id. + :type course: Course | str | int + + :raises ValidationError: If the role is already assigned to a course. + """ + course = ModelsRegistry.get_course(course) + if self.course is not None: + raise ValidationError(f'Attempted to assign role {self} to course {course} while it ' + f'is already assigned to course {self.course}') + self.course = course + self.save() + + def user_is_member(self, user: str | int | User) -> bool: + """ + Check whether a user is assigned to the role. + + :param user: User to check. The user can be specified as either the user object, its email + or its id. + :type user: str | int | User + + :return: `True` if the user is assigned to the role, `False` otherwise. + :rtype: bool + """ + return self.user_set.filter(id=ModelsRegistry.get_user_id(user)).exists() + + @transaction.atomic + def add_member(self, user: str | int | User, ignore_errors: bool = False) -> None: + """ + Add a user to the role. + + :param user: User to add. The user can be specified as either the user object, its email or + its id. + :type user: str | int | User + :param ignore_errors: If set to `True`, the method will not raise an error if the user is + already assigned to the role. + :type ignore_errors: bool + + :raises Role.RoleMemberError: If the user is already assigned to the role. + """ + user = ModelsRegistry.get_user(user) + + user_is_member = self.user_is_member(user) + if user_is_member and not ignore_errors: + raise Role.RoleMemberError(f'Attempted to add user {user} to role {self} who is ' + f'already assigned to it') + if not user_is_member: + self.user_set.add(user) + + @transaction.atomic + def add_members(self, + users: List[str] | List[int] | List[User], + ignore_errors: bool = False) -> None: + """ + Add multiple users to the role. + + :param users: List of users to add. The users can be specified as either the user objects, + their emails or their ids. + :type users: List[str] | List[int] | List[User] + :param ignore_errors: If set to `True`, the method will not raise an error if a user is + already assigned to the role. + :type ignore_errors: bool + """ + users = ModelsRegistry.get_users(users) + + for user in users: + self.add_member(user, ignore_errors=ignore_errors) + + @transaction.atomic + def remove_member(self, user: str | int | User) -> None: + """ + Remove a user from the role. + + :param user: User to remove. The user can be specified as either the user object, its email + or its id. + :type user: str | int | User + + :raises Role.RoleMemberError: If the user is not assigned to the role. + """ + user = ModelsRegistry.get_user(user) + + if not self.user_is_member(user): + raise Role.RoleMemberError(f'Attempted to remove user {user} from role {self} who is ' + f'not assigned to it') + + self.user_set.remove(user) + + @transaction.atomic + def remove_members(self, users: List[str] | List[int] | List[User]) -> None: + """ + Remove multiple users from the role. + + :param users: List of users to remove. The users can be specified as either the user + objects, their emails or their ids. + :type users: List[str] | List[int] | List[User] + """ + users = ModelsRegistry.get_users(users) + + for user in users: + self.remove_member(user) + + @transaction.atomic + def delete(self) -> None: + """ + Delete the role. + """ + self.user_set.clear() + self.permissions.clear() + super().delete() + + def get_data(self) -> dict: + """ + Returns the contents of a Role object's fields as a dictionary. Used to send role data to + the frontend. + + :return: Dictionary containing the role's data. + :rtype: dict + """ + return { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'course': self.course.short_name if self.course else None, + } + + +class RolePresetManager(models.Manager): + """ + Manager class for the RolePreset model. Governs the creation of presets as well as their + deletion. + """ + + @transaction.atomic + def create_role_preset(self, + name: str, + permissions: List[Permission] | List[str] | List[int] = None, + public: bool = True, + creator: str | int | User = None) -> RolePreset: + """ + Create a new role preset with given name, permissions, public status and creator. + + :param name: Name of the preset. + :type name: str + :param permissions: Permissions which should be assigned to the preset. The permissions can + be specified as either the permission objects, their codenames or their ids. If no + permissions are specified, the preset will be created without any permissions. + :type permissions: List[Permission] | List[str] | List[int] + :param public: Indicates whether the preset is public. Public presets can be used by all + users, private presets can only be used by their creator or other users given access. + :type public: bool + :param creator: User who created the preset. If no creator is specified, the preset will + be created without a creator. + :type creator: str | int | User + + :return: The newly created preset. + :rtype: RolePreset + + :raises ValidationError: If the name is too short. + """ + if len(name) < 4: + raise ValidationError('Preset name must be at least 4 characters long') + + preset = self.model( + name=name, + public=public, + creator=ModelsRegistry.get_user(creator) + ) + preset.save() + preset.add_permissions(permissions) + return preset + + @transaction.atomic + def delete_role_preset(self, preset: RolePreset | int) -> None: + """ + Delete a role preset. + + :param preset: Preset to delete. The preset can be specified as either the preset object or + its id. + :type preset: RolePreset | int + """ + preset = ModelsRegistry.get_role_preset(preset) + preset.delete() + + +class RolePreset(models.Model): + """ + This model represents a preset from which a role can be created. Presets contain on creation + a defined set of permissions and can be used to quickly setup often recurring course roles such + as student, tutor, etc. + """ + + #: Manager class for the RolePreset model. + objects = RolePresetManager() + + #: Name of the preset. Will be used as the name of the role created from the preset. + name = models.CharField( + verbose_name=_('preset name'), + max_length=100, + blank=False, + null=False + ) + #: Permissions assigned to the preset. Will be assigned to the role created from the preset. + permissions = models.ManyToManyField( + to=Permission, + verbose_name=_('preset permissions'), + blank=True + ) + #: Whether the preset is public. Public presets can be used by all users, private presets can + # only be used by their creator or other users given access. + public = models.BooleanField( + verbose_name=_('preset is public'), + default=True, + help_text=_('Indicates whether the preset is public. Public presets can be used by all ' + 'users, private presets can only be used by their creator or other users given ' + 'access.') + ) + #: User who created the preset. + creator = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + verbose_name=_('preset creator'), + related_name='created_role_presets', + blank=True, + null=True + ) + + def __str__(self) -> str: + """ + Returns the string representation of the RolePreset object. + + :return: Name of the preset. + :rtype: str + """ + return self.name + + def get_data(self) -> dict: + """ + Returns the contents of a RolePreset object's fields as a dictionary. Used to send preset + data to the frontend. + + :return: Dictionary containing the role preset's data + :rtype: dict + """ + return { + 'id': self.id, + 'name': self.name, + 'permissions': [perm.codename for perm in self.permissions.all()], + 'public': self.public, + 'creator': self.creator.email if self.creator else None, + } + + def has_permission(self, permission: Permission | str | int) -> bool: + """ + Check whether the preset has a given permission. + + :param permission: Permission to check for. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :return: `True` if the preset has the permission, `False` otherwise. + :rtype: bool + """ + return self.permissions.filter(id=ModelsRegistry.get_permission_id(permission)).exists() + + @transaction.atomic + def add_permission(self, permission: Permission | str | int) -> None: + """ + Add a permission to the preset. + + :param permission: Permission to add. The permission can be specified as either the + permission object, its codename or its id. + :type permission: Permission | str | int + + :raises ValidationError: If the preset already has the permission. + """ + permission = ModelsRegistry.get_permission(permission) + + if self.has_permission(permission): + raise ValidationError(f'Attempted to add permission {permission.codename} to preset ' + f'{self} which already has it') + + self.permissions.add(permission) + + @transaction.atomic + def add_permissions(self, permissions: List[Permission] | List[str] | List[int]) -> None: + """ + Add multiple permissions to the preset. + + :param permissions: List of permissions to add. The permissions can be specified as either + a list of permission objects, their codenames or their ids. + :type permissions: List[Permission] | List[str] | List[int] + """ + permissions = ModelsRegistry.get_permissions(permissions) + + for permission in permissions: + self.add_permission(permission) + + @transaction.atomic + def delete(self) -> None: + """ + Delete the preset. + """ + self.permissions.clear() + super().delete() + + +class RolePresetUser(models.Model): + """ + This model is used to give users access to private presets. + """ + + #: User given access to the preset. + user = models.ForeignKey( + to=User, + on_delete=models.CASCADE, + verbose_name=_('user'), + related_name='User_role_presets' + ) + #: Preset the user has been given access to. + preset = models.ForeignKey( + to=RolePreset, + on_delete=models.CASCADE, + verbose_name=_('preset'), + related_name='role_preset_users' + ) + + def __str__(self) -> str: + """ + Returns the string representation of the RolePresetUser object. + + :return: String representation of the preset and the user. + :rtype: str + """ + return f'{self.preset}_{self.user}' diff --git a/BaCa2/main/tests.py b/BaCa2/main/tests.py index 8c3ebcc3..2254e47b 100644 --- a/BaCa2/main/tests.py +++ b/BaCa2/main/tests.py @@ -1,225 +1,800 @@ +from django.core.exceptions import ValidationError +from django.db import transaction from django.test import TestCase -from django.contrib.auth.models import Group, Permission, ContentType -from .models import User, Course, GroupCourse, UserCourse -from course.models import Task -from BaCa2.choices import PermissionTypes +from django.utils import timezone +from course.models import Round, Submit, Task +from course.routing import InCourse +from main.models import Course, Role, RolePreset, User +from package.models import PackageInstance -class CoursePermissionsTest(TestCase): - @classmethod - def setUpClass(cls): - cls.test_user1 = User.objects.create_user( - 'user1@gmail.com', - 'user1', - 'psswd', - first_name='first_name', - last_name='last_name' - ) - cls.test_user2 = User.objects.create_user( - 'user2@gmail.com', - 'user2', - 'psswd', - first_name='first_name', - last_name='last_name' - ) +class CourseTest(TestCase): + """ + Tests the creation and deletion of courses. + """ - cls.test_course = Course.objects.create( - name='test_course', - short_name='t_course' - ) - - cls.test_group1 = Group.objects.create( - name='test_group1' - ) - cls.test_group2 = Group.objects.create( - name='test_group2' - ) + course_1 = None + course_2 = None + role_preset_1 = None + role_preset_2 = None + role_1 = None + role_2 = None + user_1 = None + user_2 = None + user_3 = None + models = [] @classmethod - def tearDownClass(cls): - cls.test_user1.delete() - cls.test_user2.delete() - cls.test_course.delete() - cls.test_group1.delete() - cls.test_group2.delete() + def setUpClass(cls) -> None: + with transaction.atomic(): + cls.role_preset_1 = RolePreset.objects.create_role_preset( + name='role_preset_1', + permissions=[ + Course.CourseAction.VIEW_ROLE.label, + Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label + ] + ) + cls.models.append(cls.role_preset_1) - def test_access_course(self): - test_group_course1 = GroupCourse.objects.create( - course=self.test_course, - group=self.test_group1 - ) - UserCourse.objects.create( - course=self.test_course, - user=self.test_user1, - group_course=test_group_course1 - ) + cls.role_preset_2 = RolePreset.objects.create_role_preset( + name='role_preset_2', + permissions=[ + Course.CourseAction.VIEW_ROLE.label, + Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label, + Course.CourseAction.ADD_MEMBER.label, + Course.CourseAction.EDIT_ROLE.label + ] + ) + cls.models.append(cls.role_preset_2) - self.assertTrue( - self.test_user1.can_access_course(self.test_course) - ) - self.assertFalse( - self.test_user2.can_access_course(self.test_course) - ) + cls.role_1 = Role.objects.create_role( + name='role_1', + permissions=[ + Course.CourseAction.VIEW_ROLE.label, + Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label, + Course.BasicAction.EDIT.label + ] + ) + cls.models.append(cls.role_1) - def test_general_permissions(self): - self.test_group1.user_set.add(self.test_user1) - self.test_group1.user_set.add(self.test_user2) - self.test_group2.user_set.add(self.test_user1) + cls.role_2 = Role.objects.create_role( + name='role_2', + permissions=[ + Course.CourseAction.VIEW_ROLE.label, + Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label, + Course.BasicAction.EDIT.label, + Course.CourseAction.ADD_MEMBER.label, + Course.CourseAction.EDIT_ROLE.label + ] + ) + cls.models.append(cls.role_2) - self.test_group1.permissions.add( - Permission.objects.get(codename='view_group') - ) - self.test_group1.permissions.add( - Permission.objects.get(codename='change_group') - ) - self.test_group2.permissions.add( - Permission.objects.get(codename='add_group') - ) - self.test_group2.permissions.add( - Permission.objects.get(codename='delete_group') - ) + cls.user_1 = User.objects.create_user( + email='user_1@uj.edu.pl', + password='password', + first_name='name_1', + last_name='surname_1', + ) + cls.models.append(cls.user_1) - self.assertTrue( - self.test_user1.check_general_permissions( - Group, - PermissionTypes.VIEW + cls.user_2 = User.objects.create_user( + email='user_2@uj.edu.pl', + password='password', + first_name='name_2', + last_name='surname_2', ) - ) - self.assertTrue( - self.test_user2.check_general_permissions( - Group, - PermissionTypes.VIEW + cls.models.append(cls.user_2) + + cls.user_3 = User.objects.create_user( + email='user3@uj.edu.pl', + password='password', + first_name='name_3', + last_name='surname_3', ) - ) - self.assertFalse( - self.test_user2.check_general_permissions( - Group, - PermissionTypes.DEL + cls.models.append(cls.user_3) + + cls.course_1 = Course.objects.create_course( + name='Design Patterns', + usos_course_code='WMI.II-WP-S', + usos_term_code='23/24Z', + role_presets=[cls.role_preset_1, cls.role_preset_2] ) - ) - self.assertTrue( - self.test_user1.check_general_permissions( - Group, - [PermissionTypes.VIEW, PermissionTypes.EDIT] + cls.models.append(cls.course_1) + + cls.course_2 = Course.objects.create_course( + name='Software Testing', + usos_course_code='WMI.II-TO-S', + usos_term_code='23/24Z', + role_presets=[cls.role_preset_2] ) + cls.models.append(cls.course_2) + + @classmethod + def tearDownClass(cls) -> None: + with transaction.atomic(): + for model in [model for model in cls.models if model.id is not None]: + model.delete() + + @classmethod + def reset_roles(cls) -> None: + cls.role_1.course = None + cls.role_1.save() + cls.role_2.course = None + cls.role_2.save() + + def test_simple_course_creation_deletion(self) -> None: + """ + Tests the creation and deletion of a course without any members or custom/preset roles. + Creates two courses with admin role only and deletes them. Checks if courses and roles + are created and deleted, checks if the admin role has the correct permissions. Asserts that + the creation of a course with an already existing short_name raises a ValidationError. + """ + course1 = Course.objects.create_course( + name='course_1', + short_name='c1_23', + ) + self.models.append(course1) + + course2 = Course.objects.create_course( + name='course_2', + short_name='c2_23' ) - self.assertFalse( - self.test_user2.check_general_permissions( - Group, - [PermissionTypes.VIEW, PermissionTypes.EDIT, PermissionTypes.ADD] + self.models.append(course2) + + self.assertTrue(Course.objects.get(short_name='c1_23') == course1) + self.assertTrue(Course.objects.get(short_name='c2_23') == course2) + self.assertTrue(Role.objects.filter(name='admin', course=course1).exists()) + self.assertTrue(Role.objects.filter(name='admin', course=course2).exists()) + self.assertTrue(Role.objects.get(name='admin', course=course1) == course1.admin_role) + self.assertTrue(Role.objects.get(name='admin', course=course2) == course2.admin_role) + self.assertTrue(Role.objects.get(name='admin', course=course1) == course1.default_role) + self.assertTrue(Role.objects.get(name='admin', course=course2) == course2.default_role) + + for permission in Course.CourseAction.labels: + self.assertTrue(course1.admin_role.has_permission(permission)) + self.assertTrue(course2.admin_role.has_permission(permission)) + + with self.assertRaises(ValidationError): + Course.objects.create_course( + name='course', + short_name='c1_23' ) - ) - self.assertTrue( - self.test_user1.check_general_permissions( - Group, - 'all' + Course.objects.create_course( + name='course', + short_name='c2_23' ) + + admin_role_1_id = Role.objects.get(name='admin', course=course1).id + admin_role_2_id = Role.objects.get(name='admin', course=course2).id + + Course.objects.delete_course(course1) + Course.objects.delete_course(course2) + + self.assertFalse(Course.objects.filter(short_name='c1_23').exists()) + self.assertFalse(Course.objects.filter(short_name='c2_23').exists()) + self.assertFalse(Role.objects.filter(id=admin_role_1_id).exists()) + self.assertFalse(Role.objects.filter(id=admin_role_2_id).exists()) + + def test_course_creation_deletion(self) -> None: + """ + Tests the creation and deletion of a course with preset roles. Creates two courses with + different preset roles and deletes them. Checks if courses and roles are created and + deleted, checks if the roles generated from the presets have the correct permissions. + """ + course1 = Course.objects.create_course( + name='course_3', + short_name='c3_23', + role_presets=[self.role_preset_1] + ) + self.models.append(course1) + + course2 = Course.objects.create_course( + name='course_4', + short_name='c4_23', + role_presets=[self.role_preset_1, self.role_preset_2] ) - self.assertTrue( - self.test_user1.check_general_permissions( - Group, - 'all_standard' - ) + self.models.append(course2) + + for role_name in ['admin', self.role_preset_1.name]: + self.assertTrue(Role.objects.filter(name=role_name, course=course1).exists()) + + for role_name in ['admin', self.role_preset_1.name, self.role_preset_2.name]: + self.assertTrue(Role.objects.filter(name=role_name, course=course2).exists()) + + role_1_1 = Role.objects.get(name=self.role_preset_1.name, course=course1) + role_1_2 = Role.objects.get(name=self.role_preset_1.name, course=course2) + role_2_2 = Role.objects.get(name=self.role_preset_2.name, course=course2) + + self.assertTrue(Role.objects.get(name='admin', course=course1) == course1.admin_role) + self.assertTrue(Role.objects.get(name='admin', course=course2) == course2.admin_role) + self.assertTrue(role_1_1 == course1.default_role) + self.assertTrue(role_1_2 == course2.default_role) + + for permission in self.role_preset_1.permissions.all(): + self.assertTrue(role_1_1.has_permission(permission)) + self.assertTrue(role_1_2.has_permission(permission)) + + for permission in [perm.codename for perm in self.role_preset_2.permissions.all()]: + self.assertTrue(role_2_2.has_permission(permission)) + + role_1_1_id = role_1_1.id + role_1_2_id = role_1_2.id + role_2_2_id = role_2_2.id + + course1.delete() + course2.delete() + + self.assertFalse(Role.objects.filter(id=role_1_1_id).exists()) + self.assertFalse(Role.objects.filter(id=role_1_2_id).exists()) + self.assertFalse(Role.objects.filter(id=role_2_2_id).exists()) + + def test_course_creation_deletion_with_members(self) -> None: + """ + Tests the creation and deletion of a course with preset roles and members. Creates a course + with two preset roles and adds three members to it. Checks if the course is deleted + correctly and makes sure that the members are not deleted with the course. + """ + course1 = Course.objects.create_course( + name='course_5', + short_name='c5_23', + role_presets=[self.role_preset_1, self.role_preset_2] + ) + self.models.append(course1) + + course1.add_member(self.user_1.email, self.role_preset_1.name) + course1.add_member(self.user_2, self.role_preset_2.name) + course1.add_member(self.user_3.id, self.role_preset_2.name) + + self.assertTrue(course1.user_is_member(self.user_1)) + self.assertTrue(course1.user_is_member(self.user_2.id)) + self.assertTrue(course1.user_is_member(self.user_3.email)) + + user_1_id = self.user_1.id + user_2_id = self.user_2.id + user_3_id = self.user_3.id + + course1.delete() + + self.assertFalse(Course.objects.filter(short_name='c5_23').exists()) + self.assertTrue(User.objects.filter(id=user_1_id).exists()) + self.assertTrue(User.objects.filter(id=user_2_id).exists()) + self.assertTrue(User.objects.filter(id=user_3_id).exists()) + + def test_course_short_name_generation(self) -> None: + """ + Tests the generation of a course short_name. Creates three courses, one with and two without + USOS codes. Checks if the short_name is generated correctly for all three. Asserts that + ValidationError is raised when trying to create a course with USOS codes that are already + in use or when one of the codes was provided without the other. + """ + curr_year = timezone.now().year + + course1 = Course.objects.create_course( + name='Advanced Design Patterns', ) - self.assertFalse( - self.test_user2.check_general_permissions( - Group, - 'all' - ) + self.models.append(course1) + + course2 = Course.objects.create_course( + name='Advanced Design Patterns' ) - self.assertTrue( - self.test_user1.check_general_permissions( - Group, - 'auth.view_group' - ) + self.models.append(course2) + + course3 = Course.objects.create_course( + name='Advanced Design Patterns', + usos_course_code='WMI.II.ZWPiA-S', + usos_term_code='23/24Z' ) - self.assertFalse( - self.test_user1.check_general_permissions( - Group, - 'auth.custom_permission' + self.models.append(course3) + + self.assertTrue(Course.objects.get(short_name=f'adp_{curr_year}') == course1) + self.assertTrue(Course.objects.get(short_name=f'adp_{curr_year}_2') == course2) + self.assertTrue(Course.objects.get(short_name='wmi_ii_zwpia_s__23_24z') == course3) + + with self.assertRaises(ValidationError): + Course.objects.create_course( + name='Advanced Design Patterns', + usos_course_code='WMI.II.ZWPiA-S', ) - ) - def test_course_permissions(self): - test_group_course1 = GroupCourse.objects.create( - course=self.test_course, - group=self.test_group1 - ) - test_group_course2 = GroupCourse.objects.create( - course=self.test_course, - group=self.test_group2 - ) + with self.assertRaises(ValidationError): + Course.objects.create_course( + name='Advanced Design Patterns', + usos_term_code='23/24Z', + ) - UserCourse.objects.create( - course=self.test_course, - user=self.test_user1, - group_course=test_group_course1 - ) - UserCourse.objects.create( - course=self.test_course, - user=self.test_user2, - group_course=test_group_course2 - ) + with self.assertRaises(ValidationError): + Course.objects.create_course( + name='Advanced Design & Architectural Patterns', + usos_course_code='WMI.II.ZWPiA-S', + usos_term_code='23/24Z', + ) + + for course in [course1, course2, course3]: + Course.objects.delete_course(course) + + def test_course_add_role(self) -> None: + """ + Tests the addition of a new roles to a course. Adds a new role to the course in three + different ways and checks if the role was added correctly. Checks if appropriate exceptions + are raised when attempting to add roles already existing in the course, roles with duplicate + names, or roles assigned to other courses. + """ + self.reset_roles() - self.test_group2.permissions.add( - Permission.objects.get(codename='view_task') + role_preset_3 = RolePreset.objects.create_role_preset( + name='role_preset_3', + permissions=[Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label] ) - self.test_group2.permissions.add( - Permission.objects.get(codename='change_task') + self.models.append(role_preset_3) + + new_role_1 = Role.objects.create_role( + name='new_role_1', + permissions=[Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label, + Course.BasicAction.EDIT.label] ) + self.models.append(new_role_1) - for p in Permission.objects.filter( - content_type=ContentType.objects.get_for_model(Task).id - ): - self.test_group1.permissions.add(p) + self.course_1.create_role_from_preset(role_preset_3) + self.course_1.add_role(new_role_1) + self.course_1.create_role('new_role_2', [Course.BasicAction.EDIT.label]) - self.assertTrue( - self.test_user1.check_course_permissions( - self.test_course, - Task, - PermissionTypes.EDIT - ) - ) - self.assertTrue( - self.test_user2.check_course_permissions( - self.test_course, - Task, - PermissionTypes.VIEW - ) + self.assertTrue(self.course_1.role_exists(role_preset_3.name)) + self.assertTrue(self.course_1.role_exists(new_role_1.name)) + self.assertTrue(self.course_1.role_exists('new_role_2')) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.create_role_from_preset(role_preset_3) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.add_role(new_role_1) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.create_role('new_role_2', [Course.BasicAction.VIEW.label]) + + new_role_3 = Role.objects.create_role( + name='new_role_3', + permissions=[Course.CourseAction.VIEW_MEMBER.label, Course.BasicAction.VIEW.label] ) - self.assertFalse( - self.test_user2.check_course_permissions( - self.test_course, - Task, - PermissionTypes.DEL - ) + self.models.append(new_role_3) + + self.course_2.add_role(new_role_3) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.add_role(new_role_3) + + def test_course_remove_role(self) -> None: + """ + Tests the removal of roles from a course. Checks if roles are correctly removed and if + appropriate exceptions are raised when attempting to remove the admin or default role, + a role not assigned to the course or a role with users assigned to it. + """ + self.reset_roles() + + self.course_1.add_role(self.role_1) + self.course_1.add_role(self.role_2) + self.course_1.add_member(self.user_1, self.role_2) + self.course_1.add_member(self.user_2, self.role_2) + self.course_1.add_member(self.user_3, self.role_2) + + role_1_id = self.role_1.id + role_2_id = self.role_2.id + admin_role_id = self.course_1.admin_role.id + default_role_id = self.course_1.default_role.id + + self.course_1.remove_role(self.role_1) + + self.assertFalse(self.course_1.role_exists(role_1_id)) + self.assertFalse(Role.objects.filter(id=role_1_id).exists()) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.remove_role(admin_role_id) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.remove_role(default_role_id) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.remove_role(role_2_id) + + self.course_1.remove_member(self.user_1.email) + self.course_1.change_member_role(self.user_2.id, self.course_1.default_role.name) + self.course_1.make_member_admin(self.user_3) + + self.course_1.remove_role(role_2_id) + + self.assertFalse(self.course_1.role_exists(role_2_id)) + self.assertFalse(Role.objects.filter(id=role_2_id).exists()) + + def test_course_add_role_permissions(self) -> None: + """ + Tests the addition of permissions to roles. Checks if permissions are correctly added and + if appropriate exceptions are raised when attempting to add permissions to roles which are + not assigned to the course or when attempting to add permissions already assigned to the + given role. + """ + self.reset_roles() + + permissions = [Course.CourseAction.ADD_MEMBER.label, + Course.CourseAction.EDIT_ROLE.label, + Course.CourseAction.DEL_ROLE.label] + + self.course_1.add_role(self.role_1) + self.course_1.add_role_permissions(self.role_1.id, permissions) + + for perm in permissions: + self.assertTrue(self.course_1.role_has_permission(self.role_1.name, perm)) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.add_role_permissions(self.role_2.id, [Course.CourseAction.DEL_ROLE.label]) + + with self.assertRaises(Role.RolePermissionError): + self.course_1.add_role_permissions(self.role_1, [Course.CourseAction.EDIT_ROLE.label]) + + def test_course_remove_role_permissions(self) -> None: + """ + Tests removal of permission from a role. Checks if permissions are correctly removed and if + appropriate exceptions are raised when attempting to remove permissions from roles which + are not assigned to the course, from roles which do not have the given permissions or from + admin roles. + """ + self.reset_roles() + + permissions = [Course.CourseAction.ADD_MEMBER.label, + Course.CourseAction.EDIT_ROLE.label] + + self.course_2.add_role(self.role_2) + + self.assertTrue(self.course_2.role_has_permission( + 'role_preset_2', + Course.CourseAction.ADD_MEMBER.label + )) + + for perm in permissions: + self.assertTrue(self.course_2.role_has_permission(self.role_2.id, perm)) + + self.course_2.remove_role_permissions( + 'role_preset_2', + [Course.CourseAction.ADD_MEMBER.label] ) - self.assertTrue( - self.test_user1.check_course_permissions( - self.test_course, - Task, - [PermissionTypes.VIEW, PermissionTypes.ADD] - ) + self.course_2.remove_role_permissions( + self.role_2, + permissions ) - self.assertFalse( - self.test_user2.check_course_permissions( - self.test_course, - Task, - [PermissionTypes.VIEW, PermissionTypes.ADD] - ) + + self.assertFalse(self.course_2.role_has_permission( + 'role_preset_2', + Course.CourseAction.ADD_MEMBER.label + )) + + for perm in permissions: + self.assertFalse(self.course_2.role_has_permission(self.role_2, perm)) + + with self.assertRaises(Course.CourseRoleError): + self.course_2.remove_role_permissions(self.role_1, [Course.BasicAction.VIEW.label]) + + with self.assertRaises(Role.RolePermissionError): + self.course_2.remove_role_permissions(self.role_2, permissions) + + with self.assertRaises(Course.CourseRoleError): + self.course_2.remove_role_permissions(self.course_2.admin_role.name, permissions) + + def test_course_change_role_permissions(self) -> None: + """ + Tests changing permissions of a role. Checks if permissions are correctly changed and if + appropriate exceptions are raised when attempting to change permissions of roles which are + not assigned to the course or admin roles. + """ + self.reset_roles() + + role_1_permissions = [Course.CourseAction.VIEW_ROLE.label, + Course.CourseAction.VIEW_MEMBER.label, + Course.BasicAction.VIEW.label] + + role_1_new_permissions = [Course.CourseAction.ADD_MEMBER.label, + Course.CourseAction.EDIT_ROLE.label, + Course.BasicAction.VIEW.label] + + self.course_1.add_role(self.role_1) + + for perm in role_1_permissions: + self.assertTrue(self.course_1.role_has_permission(self.role_1, perm)) + + for perm in list(set(role_1_new_permissions) - set(role_1_permissions)): + self.assertFalse(self.course_1.role_has_permission(self.role_1, perm)) + + self.course_1.change_role_permissions(self.role_1.name, role_1_new_permissions) + + for perm in role_1_new_permissions: + self.assertTrue(self.course_1.role_has_permission(self.role_1.id, perm)) + + for perm in list(set(role_1_permissions) - set(role_1_new_permissions)): + self.assertFalse(self.course_1.role_has_permission(self.role_1, perm)) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.change_role_permissions(self.role_2, role_1_permissions) + + with self.assertRaises(Course.CourseRoleError): + self.course_1.change_role_permissions(self.course_1.admin_role.id, role_1_permissions) + + # def test_course_add_remove_member(self) -> None: + # """ + # Tests addition and removal of members to a course. Checks if members are correctly added + # and removed from the course and if appropriate exceptions are raised when attempting to + # add members which are already assigned to the course, when attempting to add members with + # roles which are not assigned to the course, when attempting to add members to admin roles + # or when attempting to remove members who are not assigned to the course. + # """ + # self.reset_roles() + # + # course_a = Course.objects.create_course(name='course_1') + # self.models.append(course_a) + # course_b = Course.objects.create_course(name='course_2') + # self.models.append(course_b) + # + # course_a.add_role(self.role_1) + # course_a.add_role(self.role_2) + # course_b.create_role_from_preset(self.role_preset_1) + # course_b.create_role_from_preset(self.role_preset_2) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # self.assertFalse(course_a.user_is_member(user.id)) + # self.assertFalse(course_b.user_is_member(user.email)) + # + # course_a.add_member(self.user_1, self.role_1.name) + # course_a.add_members([self.user_2.id, self.user_3.id], self.role_2) + # course_b.add_member(self.user_1, self.role_preset_1.name) + # course_b.add_member(self.user_2, self.role_preset_2.name) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # self.assertTrue(course_a.user_is_member(user.id)) + # + # for user in [self.user_1, self.user_2]: + # self.assertTrue(course_b.user_is_member(user)) + # + # self.assertTrue(course_a.user_has_role(self.user_1.id, self.role_1.name)) + # self.assertTrue(course_a.user_has_role(self.user_2.email, self.role_2.id)) + # self.assertTrue(course_a.user_has_role(self.user_3, self.role_2)) + # self.assertTrue(course_b.user_has_role(self.user_1, self.role_preset_1.name)) + # self.assertTrue(course_b.user_has_role(self.user_2, self.role_preset_2.name)) + # + # for perm in self.role_1.permissions.all(): + # self.assertTrue(self.user_1.has_course_permission(perm, course_a.short_name)) + # + # for perm in self.role_preset_1.permissions.all(): + # self.assertTrue(self.user_1.has_course_permission(perm.codename, course_b)) + # + # with self.assertRaises(Course.CourseMemberError): + # course_a.add_member(self.user_1, self.role_1) + # + # with self.assertRaises(Course.CourseMemberError): + # course_a.add_member(self.user_1, self.role_2.name) + # + # with self.assertRaises(Course.CourseRoleError): + # course_b.add_member(self.user_3, self.role_1.id) + # + # with self.assertRaises(Course.CourseRoleError): + # course_b.add_member(self.user_3, self.course_2.admin_role) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # course_a.remove_member(user) + # course_b.remove_members([self.user_1.id, self.user_2.id]) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # self.assertFalse(course_a.user_is_member(user)) + # self.assertFalse(course_b.user_is_member(user)) + # + # with self.assertRaises(Course.CourseMemberError): + # course_a.remove_member(self.user_1) + # + # with self.assertRaises(Course.CourseMemberError): + # self.user_1.has_course_permission(self.role_1.permissions.all()[0], course_a) + + def test_add_remove_admin(self) -> None: + """ + Tests addition and removal of admins to a course. Checks if admins are correctly added + and removed from the course and if appropriate exceptions are raised when attempting to + add admins which are already assigned to the course or when attempting to remove admins + using the remove_member method. + """ + self.reset_roles() + + course_a = Course.objects.create_course(name='course_10 test') + self.models.append(course_a) + + for user in [self.user_1, self.user_2]: + self.assertFalse(course_a.user_is_member(user)) + self.assertFalse(course_a.user_is_admin(user)) + + course_a.add_role(self.role_1) + course_a.add_member(self.user_1.id, self.role_1.name) + course_a.add_admin(self.user_2.id) + + for user in [self.user_1, self.user_2]: + self.assertTrue(course_a.user_is_member(user)) + + self.assertTrue(course_a.user_is_admin(self.user_2)) + self.assertFalse(course_a.user_is_admin(self.user_1)) + + with self.assertRaises(Course.CourseMemberError): + course_a.remove_member(self.user_2) + + with self.assertRaises(Course.CourseMemberError): + course_a.remove_admin(self.user_1) + + course_a.remove_admin(self.user_2) + course_a.remove_member(self.user_1) + + for user in [self.user_1, self.user_2]: + self.assertFalse(course_a.user_is_member(user)) + self.assertFalse(course_a.user_is_admin(user)) + + # def test_change_member_role(self) -> None: + # """ + # Tests changing the role of a member. Checks if the role is correctly changed and if + # appropriate exceptions are raised when attempting to change the role of members which are + # not assigned to the course, when attempting to change the role of members to roles which + # are not assigned to the course, when attempting to change the role of members to the admin + # role or when attempting to change the role of an admin. + # """ + # self.reset_roles() + # + # course_a = Course.objects.create_course(name='course_1') + # self.models.append(course_a) + # course_a.add_role(self.role_1) + # course_a.add_role(self.role_2) + # course_a.add_members([self.user_1.id, self.user_2.id, self.user_3.id], self.role_1) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # self.assertTrue(course_a.user_is_member(user)) + # self.assertTrue(course_a.user_has_role(user, self.role_1)) + # self.assertFalse(course_a.user_is_admin(user)) + # + # course_a.change_member_role(self.user_1, self.role_2) + # course_a.change_members_role([self.user_2.id, self.user_3.id], self.role_2) + # + # for user in [self.user_1, self.user_2, self.user_3]: + # self.assertTrue(course_a.user_is_member(user)) + # self.assertTrue(course_a.user_has_role(user, self.role_2)) + # self.assertFalse(course_a.user_is_admin(user)) + # + # course_a.remove_member(self.user_1) + # + # with self.assertRaises(Course.CourseMemberError): + # course_a.change_member_role(self.user_1, self.role_1) + # + # course_a.remove_role(self.role_1) + # + # with self.assertRaises(Course.CourseRoleError): + # course_a.change_member_role(self.user_2, self.role_1) + # + # with self.assertRaises(Course.CourseRoleError): + # course_a.change_member_role(self.user_2, self.course_1.admin_role) + # + # course_a.add_admin(self.user_1) + # + # with self.assertRaises(Course.CourseRoleError): + # course_a.change_member_role(self.user_1, self.role_2) + + +class TestCourseActions(TestCase): + course_1 = None + user_1 = None + + @classmethod + def setUpTestData(cls): + cls.course_1 = Course.objects.create_course( + name='Design Patterns 2', ) - self.assertTrue( - self.test_user1.check_course_permissions( - self.test_course, - Task, - 'all' - ) + cls.user_1 = User.objects.create_user( + email='test@test.com', + password='test' ) - self.assertFalse( - self.test_user2.check_course_permissions( - self.test_course, - Task, - 'all' - ) + + @classmethod + def tearDownClass(cls): + cls.course_1.delete() + cls.user_1.delete() + + def tearDown(self): + with InCourse(self.course_1): + Round.objects.all().delete() + Task.objects.all().delete() + Submit.objects.all().delete() + + def new_round(self, name=None): + round_ = self.course_1.create_round( + start_date=timezone.now() - timezone.timedelta(days=1), + deadline_date=timezone.now() + timezone.timedelta(days=1), + name=name ) + return round_ + + @staticmethod + def get_pkg(name='dosko'): + pkgs = PackageInstance.objects.filter(package_source__name=name).all() + if len(pkgs) == 0: + return PackageInstance.objects.create_source_and_instance(name, '1') + else: + return pkgs[0] + + def new_task(self, round_, name='Liczby Doskonałe'): + pkg = self.get_pkg() + task = self.course_1.create_task( + package_instance=pkg, + round_=round_, + task_name=name, + points=10, + ) + return task + + def new_submit(self, task, user): + submit = self.course_1.create_submit( + task=task, + user=user, + source_code='1234.cpp', + auto_send=False + ) + return submit + + def test_01_create_round(self): + self.new_round('round_1') + self.assertEqual(len(self.course_1.rounds()), 1) + self.assertEqual(self.course_1.rounds()[0].name, 'round_1') + + def test_02_delete_round(self): + r = self.new_round() + with InCourse(self.course_1): + self.assertEqual(len(Round.objects.all()), 1) + self.course_1.delete_round(r.pk) + with InCourse(self.course_1): + self.assertEqual(len(Round.objects.all()), 0) + + def test_03_get_round(self): + r1 = self.new_round('test 1') + r2 = self.new_round('test 2') + self.assertEqual(self.course_1.get_round(r1.pk), r1) + self.assertEqual(self.course_1.get_round(r2.pk), r2) + + def test_04_create_task(self): + self.new_task(self.new_round()) + r = self.course_1.rounds()[0] + self.assertEqual(len(r.tasks), 1) + t = r.tasks[0] + self.assertEqual(t.task_name, 'Liczby Doskonałe') + self.assertEqual(len(t.sets), 4) + + def test_05_get_task(self): + t = self.new_task(self.new_round()) + self.assertEqual(self.course_1.get_task(t.pk), t) + self.assertEqual(self.course_1.get_task(t), t) + + def test_06_delete_task(self): + t = self.new_task(self.new_round()) + r = self.course_1.rounds()[0] + self.assertEqual(len(r.tasks), 1) + self.course_1.delete_task(t.pk) + self.assertEqual(len(r.tasks), 0) + + def test_07_create_submit(self): + t = self.new_task(self.new_round()) + self.new_submit(t, self.user_1) + self.assertEqual(len(t.submits()), 1) + s = t.submits()[0] + self.assertEqual(s.user, self.user_1) + + def test_08_delete_submit(self): + t = self.new_task(self.new_round()) + s = self.new_submit(t, self.user_1) + self.assertEqual(len(t.submits()), 1) + self.course_1.delete_submit(s.pk) + self.assertEqual(len(t.submits()), 0) + + +class UserTest(TestCase): + pass diff --git a/BaCa2/main/urls.py b/BaCa2/main/urls.py new file mode 100644 index 00000000..9d574c08 --- /dev/null +++ b/BaCa2/main/urls.py @@ -0,0 +1,35 @@ +from django.urls import path + +from .views import ( + AdminView, + CourseModelView, + CoursesView, + DashboardView, + PermissionModelView, + ProfileView, + RoleModelView, + RoleView, + UserModelView, + change_password, + change_theme +) + +app_name = 'main' + +urlpatterns = [ + # -------------------------------------- Model views --------------------------------------- # + path('models/course/', CourseModelView.as_view(), name='course-model-view'), + path('models/user/', UserModelView.as_view(), name='user-model-view'), + path('models/role/', RoleModelView.as_view(), name='role-model-view'), + path('models/permission/', PermissionModelView.as_view(), name='permission-model-view'), + + # --------------------------------------- Main views --------------------------------------- # + path('admin/', AdminView.as_view(), name='admin'), + path('dashboard/', DashboardView.as_view(), name='dashboard'), + path('courses/', CoursesView.as_view(), name='courses'), + path('profile/', ProfileView.as_view(), name='profile'), + path('role//', RoleView.as_view(), name='role'), + + path('change_theme', change_theme, name='change-theme'), + path('change_password', change_password, name='change-password'), +] diff --git a/BaCa2/main/views.py b/BaCa2/main/views.py index 91ea44a2..06d40a04 100644 --- a/BaCa2/main/views.py +++ b/BaCa2/main/views.py @@ -1,3 +1,706 @@ -from django.shortcuts import render +import logging +import re +from typing import Any, Dict, List -# Create your views here. +from django.conf import settings +from django.contrib.auth import logout, update_session_auth_hash +from django.contrib.auth.forms import PasswordChangeForm +from django.contrib.auth.mixins import UserPassesTestMixin +from django.contrib.auth.models import Permission +from django.contrib.auth.views import LoginView +from django.http import HttpResponseRedirect, JsonResponse +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ +from django.views.generic.base import RedirectView + +from main.models import Course, Role, User +from util import decode_url_to_dict, encode_dict_to_url +from util.models_registry import ModelsRegistry +from util.responses import BaCa2JsonResponse, BaCa2ModelResponse +from util.views import BaCa2ContextMixin, BaCa2LoggedInView, BaCa2ModelView +from widgets.forms import FormWidget +from widgets.forms.course import ( + AddMemberForm, + AddMembersFromCSVForm, + AddRoleForm, + AddRolePermissionsForm, + AddRolePermissionsFormWidget, + CreateCourseForm, + CreateCourseFormWidget, + DeleteCourseForm, + DeleteRoleForm, + RemoveMembersForm, + RemoveRolePermissionsForm, + RemoveRolePermissionsFormWidget +) +from widgets.forms.main import ( + ChangePersonalData, + ChangePersonalDataWidget, + CreateUser, + CreateUserWidget +) +from widgets.listing import TableWidget, TableWidgetPaging +from widgets.listing.columns import TextColumn +from widgets.navigation import SideNav + +logger = logging.getLogger(__name__) + + +# ----------------------------------------- Model views ---------------------------------------- # + +class CourseModelView(BaCa2ModelView): + """ + View used to retrieve serialized course model data to be displayed in the front-end and to + interface between POST requests and model forms used to manage course instances. + """ + + MODEL = Course + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[Course], + request, + **kwargs) -> bool: + """ + Method used to evaluate requesting user's permission to view the model instances matching + the specified query parameters retrieved by the view if the user does not possess the 'view' + permission for all model instances. + + :param filter_params: Query parameters used to construct the filter for the retrieved query + set. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set. + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query set retrieved using the specified query parameters evaluated to a + list. + :type query_result: List[:class:`Course`] + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: `True` if the user is a member of all courses retrieved by the query, `False` + otherwise. + :rtype: bool + """ + for course in query_result: + if not course.user_is_member(request.user): + return False + return True + + def post(self, request, **kwargs) -> BaCa2ModelResponse: + """ + Delegates the handling of the POST request to the appropriate form based on the `form_name` + parameter received in the request. + + If the `course` parameter is present in the request, it is decoded and stored in the request + object as a dictionary under the `course` attribute (required for request handling by + course action forms). + + :param request: HTTP POST request object received by the view + :type request: HttpRequest + :return: JSON response to the POST request containing information about the success or + failure of the request + :rtype: :py:class:`JsonResponse` + """ + params = request.GET.dict() + + if params.get('course'): + request.course = decode_url_to_dict(params.get('course')) + else: + request.course = {} + + form_name = request.POST.get('form_name') + + if form_name == f'{Course.BasicAction.ADD.label}_form': + return CreateCourseForm.handle_post_request(request) + elif form_name == f'{Course.BasicAction.DEL.label}_form': + return DeleteCourseForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.ADD_MEMBER.label}_form': + return AddMemberForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.ADD_MEMBERS_CSV.label}_form': + return AddMembersFromCSVForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.ADD_ROLE.label}_form': + return AddRoleForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.DEL_ROLE.label}_form': + return DeleteRoleForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.DEL_MEMBER.label}_form': + return RemoveMembersForm.handle_post_request(request) + elif form_name == f'{Course.CourseAction.EDIT_ROLE.label}_form': + if 'permissions_to_add' in request.POST: + return AddRolePermissionsForm.handle_post_request(request) + elif 'permissions_to_remove' in request.POST: + return RemoveRolePermissionsForm.handle_post_request(request) + + return self.handle_unknown_form(request, **kwargs) + + @classmethod + def post_url(cls, **kwargs) -> str: + """ + :param kwargs: Additional parameters to be included in the url used in a POST request. + :type kwargs: dict + :return: URL to be used in a POST request. + :rtype: str + """ + url = super().post_url(**kwargs) + if 'course_id' in kwargs: + url += f'?{encode_dict_to_url("course", {"course_id": kwargs["course_id"]})}' + return url + + +class UserModelView(BaCa2ModelView): + """ + View used to retrieve serialized user model data to be displayed in the front-end and to + interface between POST requests and model forms used to manage user instances. + """ + + MODEL = User + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[User], + request, + **kwargs) -> bool: + """ + Method used to evaluate requesting user's permission to view the model instances matching + the specified query parameters retrieved by the view if the user does not possess the 'view' + permission for all model instances. + + :param filter_params: Query parameters used to construct the filter for the retrieved query + set. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set. + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query set retrieved using the specified query parameters evaluated to a + list. + :type query_result: List[:class:`User`] + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: `True` if the user has the 'add_member' permission in one of their courses, if + the user is the only record retrieved by the query, or if the request originated from a + view related to a course the user has the 'view_member' permission for and all users + retrieved by the query are members of the course, `False` otherwise. + :rtype: bool + """ + user = request.user + + if user.has_role_permission(Course.CourseAction.ADD_MEMBER.label): + return True + if len(query_result) == 1 and query_result[0] == user: + return True + + refer_url = request.META.get('HTTP_REFERER') + + if bool(re.search(r'course/\d+/', refer_url)): + course_id = re.search(r'course/(\d+)/', refer_url).group(1) + course = Course.objects.get(pk=course_id) + view_member = user.has_course_permission(Course.CourseAction.VIEW_MEMBER.label, course) + + if view_member and all([course.user_is_member(usr) for usr in query_result]): + return True + + return False + + def post(self, request, **kwargs) -> BaCa2ModelResponse: + """ + Delegates the handling of the POST request to the appropriate form based on the `form_name` + parameter received in the request. + + :param request: HTTP POST request object received by the view + :type request: HttpRequest + :return: JSON response to the POST request containing information about the success or + failure of the request + :rtype: :py:class:`JsonResponse` + """ + form_name = request.POST.get('form_name') + + if form_name == f'{User.BasicAction.ADD.label}_form': + return CreateUser.handle_post_request(request) + if form_name == f'{User.BasicAction.EDIT.label}_form': + return ChangePersonalData.handle_post_request(request) + + return self.handle_unknown_form(request, **kwargs) + + +class RoleModelView(BaCa2ModelView): + """ + View used to retrieve serialized role model data to be displayed in the front-end and to + interface between POST requests and model forms used to manage role instances. + """ + + MODEL = Role + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[Role], + request, + **kwargs) -> bool: + """ + Method used to evaluate requesting user's permission to view the model instances matching + the specified query parameters retrieved by the view if the user does not possess the 'view' + permission for all model instances. + + :param filter_params: Query parameters used to construct the filter for the retrieved query + set. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set. + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query set retrieved using the specified query parameters evaluated to a + list. + :type query_result: List[:class:`Role`] + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: `True` if all retrieved roles belong to a course the user has the 'view_role' + permission for, `False` otherwise. + :rtype: bool + """ + for role in query_result: + course = role.course + if not request.user.has_course_permission(Course.CourseAction.VIEW_ROLE.label, course): + return False + + return True + + +class PermissionModelView(BaCa2ModelView): + """ + View used to retrieve serialized permission model data to be displayed in the front-end and to + interface between POST requests and model forms used to manage permission instances. + """ + + @staticmethod + def get_data(instance: Permission, **kwargs) -> Dict[str, Any]: + """ + :param instance: Permission instance to be serialized. + :type instance: Permission + :return: Serialized permission instance data. + :rtype: dict + """ + return { + 'id': instance.id, + 'name': instance.name, + 'content_type_id': instance.content_type.id, + 'codename': instance.codename, + } + + MODEL = Permission + GET_DATA_METHOD = get_data + + @classmethod + def _url(cls, **kwargs) -> str: + """ + :return: Base url for the view. Used by :meth:`get_url` method. + """ + return f'/main/models/{cls.MODEL._meta.model_name}/' + + +# --------------------------------------- Authentication --------------------------------------- # + +class LoginRedirectView(RedirectView): + """ + Redirects to BaCa2 login page. + """ + + # Redirect target. + url = reverse_lazy('login') + + +class BaCa2LoginView(BaCa2ContextMixin, LoginView): + """ + Login view for BaCa2. Contains a login form widget and theme switch. Redirects to dashboard on + successful login or if user is already logged in (redirect target is set using the + 'LOGIN_REDIRECT_URL' variable in project's 'settings.py' file). + """ + + template_name = 'login.html' + redirect_authenticated_user = True + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['page_title'] = _('Login') + + self.add_widget(context, FormWidget( + name='login_form', + request=self.request, + form=self.get_form(), + button_text=_('Log in'), + display_field_errors=False, + live_validation=False, + )) + + return context + + +class BaCa2LogoutView(RedirectView): + """ + This class represents the logout view for the BaCa2 application. It extends the RedirectView + from Django. It is responsible for logging out the user and redirecting them to the appropriate + after logout page based on the type of user. + """ + + #: The URL to redirect to after logout for UJ users. + url_uj = settings.OIDC_OP_LOGOUT_URL + #: The URL to redirect to after logout for external users. + url_ext = reverse_lazy('login') + + def get_redirect_url(self, *args, **kwargs) -> str: + """ + Determines the URL to redirect to after logout based on the type of user. If the user is a + UJ user, the URL to redirect to is the OIDC_OP_LOGOUT_URL from the settings. + If the user is an external user, the URL to redirect to is the `login` URL. + + :return: The URL to redirect to after logout. + :rtype: str + """ + if self.request.user.is_uj_user: + return self.url_uj + return self.url_ext + + def get(self, request, *args, **kwargs) -> HttpResponseRedirect: + """ + Handles the GET request for this view. Logs out the user and redirects them to the + appropriate after-logout page. UJ users are redirected to the OIDC_OP_LOGOUT_URL from the + settings, while external users are redirected to the `login` URL. + + :param request: The HTTP GET request object received by the view. + :type request: HttpRequest + :return: The HTTP response to redirect the user to the appropriate login page. + :rtype: HttpResponseRedirect + """ + resp = super().get(request, *args, **kwargs) + logger.info(f'{resp.url} {request.user.is_uj_user}') + logout(request) + return resp + + +# ----------------------------------------- Admin view ----------------------------------------- # + + +class AdminView(BaCa2LoggedInView, UserPassesTestMixin): + """ + Admin view for BaCa2 used to manage users, courses and packages. Can only be accessed by + superusers. + + See also: + - :class:`BaCa2LoggedInView` + """ + template_name = 'admin.html' + + def test_func(self) -> bool: + """ + Test function for UserPassesTestMixin. Checks if the user is a superuser. + + :return: `True` if the user is a superuser, `False` otherwise. + :rtype: bool + """ + return self.request.user.is_superuser + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['page_title'] = _('Admin') + + sidenav = SideNav(request=self.request, + collapsed=False, + toggle_button=True, + tabs=['Courses', 'Users', 'Packages'], + sub_tabs={'Users': ['New User', 'Users Table'], + 'Courses': ['New Course', 'Courses Table'], + 'Packages': ['New Package', 'Packages Table']}) + self.add_widget(context, sidenav) + + if not self.has_widget(context, FormWidget, 'create_course_form_widget'): + self.add_widget(context, CreateCourseFormWidget(request=self.request)) + + self.add_widget(context, TableWidget( + name='courses_table_widget', + title='Courses', + request=self.request, + data_source=CourseModelView.get_url(), + cols=[ + TextColumn(name='id', + header='ID', + searchable=True, + auto_width=False, + width='4rem'), + TextColumn(name='name', header='Name', searchable=True), + TextColumn(name='USOS_course_code', header='Course code', searchable=True), + TextColumn(name='USOS_term_code', + header='Term code', + searchable=True, + auto_width=False, + width='8rem'), + ], + allow_select=True, + allow_delete=True, + delete_form=DeleteCourseForm(), + data_post_url=CourseModelView.post_url(), + paging=TableWidgetPaging(10, False), + link_format_string='/course/[[id]]/', + )) + + users_table = TableWidget( + name='users_table_widget', + title='Users', + request=self.request, + data_source=UserModelView.get_url(), + cols=[ + TextColumn(name='email', header=_('Email'), searchable=True), + TextColumn(name='first_name', header=_('First name'), searchable=True), + TextColumn(name='last_name', header=_('Last name'), searchable=True), + TextColumn(name='f_is_superuser', header=_('Superuser'), searchable=True), + ], + paging=TableWidgetPaging(25, False), + ) + self.add_widget(context, users_table) + + add_user_form = CreateUserWidget(request=self.request) + self.add_widget(context, add_user_form) + + return context + + +# ----------------------------------------- User views ----------------------------------------- # + + +class DashboardView(BaCa2LoggedInView): + """ + Default home page view for BaCa2. + + See also: + - :class:`BaCa2LoggedInView` + """ + template_name = 'development_info.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['user_first_name'] = self.request.user.first_name + return context + + +class CoursesView(BaCa2LoggedInView): + """ + View displaying all courses available to the user. + + See also: + - :class:`BaCa2LoggedInView` + """ + template_name = 'courses.html' + + def get_context_data(self, **kwargs): + user_id = self.request.user.id + context = super().get_context_data(**kwargs) + context['page_title'] = _('Courses') + + self.add_widget(context, TableWidget( + name='courses_table_widget', + request=self.request, + title='Your courses', + data_source=CourseModelView.get_url(mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'role_set__user': user_id}, + serialize_kwargs={'user': user_id}), + cols=[ + TextColumn(name='name', header='Name', searchable=True), + TextColumn(name='USOS_term_code', header='Semester', searchable=True), + TextColumn(name='user_role', header='Your role', searchable=True), + ], + link_format_string='/course/[[id]]/', + )) + + return context + + +class RoleView(BaCa2LoggedInView, UserPassesTestMixin): + template_name = 'course_role.html' + + def test_func(self) -> bool: + course = ModelsRegistry.get_role(self.kwargs.get('role_id')).course + user = getattr(self.request, 'user') + + if course.user_is_admin(user) or user.is_superuser: + return True + + if user.has_course_permission(Course.CourseAction.VIEW_ROLE.label, course): + return True + + return False + + def get_context_data(self, **kwargs) -> dict: + role = ModelsRegistry.get_role(self.kwargs.get('role_id')) + course = role.course + self.request.course_id = course.id + context = super().get_context_data(**kwargs) + user = getattr(self.request, 'user') + sidenav_tabs = ['Overview', 'Members'] + context['page_title'] = f'{course.name} - {role.name}' + + # overview ------------------------------------------------------------------------------- + + permissions_table = TableWidget( + name='permissions_table_widget', + title=_('Permissions'), + request=self.request, + data_source=PermissionModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'role': role.id} + ), + cols=[TextColumn(name='codename', header=_('Codename')), + TextColumn(name='name', header=_('Name'))], + refresh_button=True, + table_height=35 + ) + self.add_widget(context, permissions_table) + + # members -------------------------------------------------------------------------------- + + members_table = TableWidget( + name='members_table_widget', + title=_('Members'), + request=self.request, + data_source=UserModelView.get_url( + mode=BaCa2ModelView.GetMode.FILTER, + filter_params={'roles': role.id} + ), + cols=[TextColumn(name='email', header=_('Email')), + TextColumn(name='first_name', header=_('First name')), + TextColumn(name='last_name', header=_('Last name'))], + refresh_button=True + ) + self.add_widget(context, members_table) + + # add/remove permissions ----------------------------------------------------------------- + + if user.has_course_permission(Course.CourseAction.EDIT_ROLE.label, course): + sidenav_tabs.append('Add permissions') + add_permissions_form = AddRolePermissionsFormWidget(request=self.request, + course_id=course.id, + role_id=role.id) + self.add_widget(context, add_permissions_form) + + sidenav_tabs.append('Remove permissions') + remove_permissions_form = RemoveRolePermissionsFormWidget(request=self.request, + course_id=course.id, + role_id=role.id) + self.add_widget(context, remove_permissions_form) + + # sidenav -------------------------------------------------------------------------------- + + sidenav = SideNav(request=self.request, + collapsed=False, + toggle_button=False, + tabs=sidenav_tabs) + self.add_widget(context, sidenav) + + return context + + +class ProfileView(BaCa2LoggedInView): + """ + View for managing user settings. + + See also: + - :class:`BaCa2LoggedInView` + """ + template_name = 'profile.html' + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['page_title'] = _('Profile') + user = self.request.user + + sidenav = SideNav( + request=self.request, + tabs=['Profile', 'Security'], + ) + self.add_widget(context, sidenav) + + data_summary = TableWidget( + name='personal_data_table_widget', + title=f"{_('Personal data')} - {user.first_name} {user.last_name}", + request=self.request, + cols=[ + TextColumn(name='description', sortable=False), + TextColumn(name='value', sortable=False), + ], + data_source=[ + {'description': _('Email'), 'value': user.email}, + {'description': _('First name'), 'value': user.first_name}, + {'description': _('Last name'), 'value': user.last_name}, + {'description': _('Superuser'), 'value': user.is_superuser}, + ], + allow_global_search=False, + hide_col_headers=True, + default_sorting=False, + ) + self.add_widget(context, data_summary) + + data_change = ChangePersonalDataWidget(request=self.request) + self.add_widget(context, data_change) + + if not user.is_uj_user: + self.add_widget(context, FormWidget( + name='change_password_form_widget', + request=self.request, + form=PasswordChangeForm(user), + button_text=_('Change password'), + display_field_errors=False, + live_validation=False, + post_target_url=reverse_lazy('main:change-password'), + )) + context['change_password_title'] = _('Change password') + + return context + + +# ----------------------------------------- Util views ----------------------------------------- # + + +def change_theme(request) -> JsonResponse: + """ + Placeholder functional view for changing the website theme. + + :return: JSON response with the result of the action in the form of status string. + :rtype: JsonResponse + """ + if request.method == 'POST': + if request.user.is_authenticated: + theme = request.POST.get('theme') + request.user.user_settings.theme = theme + request.user.user_settings.save() + return JsonResponse({'status': 'ok'}) + return JsonResponse({'status': 'error'}) + + +def change_password(request) -> BaCa2JsonResponse: + """ + Placeholder functional view for changing the user password. + + :return: JSON response with the result of the action in the form of status string. + :rtype: BaCa2JsonResponse + """ + if request.method == 'POST': + form = PasswordChangeForm(request.user, request.POST) + if form.is_valid(): + user = form.save() + update_session_auth_hash(request, user) + return BaCa2JsonResponse(status=BaCa2JsonResponse.Status.SUCCESS, + message=_('Password changed.')) + else: + validation_errors = form.errors + return BaCa2JsonResponse(status=BaCa2JsonResponse.Status.INVALID, + message=_('Password not changed.'), + errors=validation_errors) diff --git a/BaCa2/manage.py b/BaCa2/manage.py index 86d7631e..4e309f3d 100644 --- a/BaCa2/manage.py +++ b/BaCa2/manage.py @@ -6,14 +6,14 @@ def main(): """Run administrative tasks.""" - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'BaCa2.settings') + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'core.settings') try: from django.core.management import execute_from_command_line except ImportError as exc: raise ImportError( "Couldn't import Django. Are you sure it's installed and " - "available on your PYTHONPATH environment variable? Did you " - "forget to activate a virtual environment?" + 'available on your PYTHONPATH environment variable? Did you ' + 'forget to activate a virtual environment?' ) from exc execute_from_command_line(sys.argv) diff --git a/BaCa2/package/admin.py b/BaCa2/package/admin.py index 8c38f3f3..846f6b40 100644 --- a/BaCa2/package/admin.py +++ b/BaCa2/package/admin.py @@ -1,3 +1 @@ -from django.contrib import admin - # Register your models here. diff --git a/BaCa2/package/apps.py b/BaCa2/package/apps.py index 6bd2784b..0ad9d5df 100644 --- a/BaCa2/package/apps.py +++ b/BaCa2/package/apps.py @@ -1,6 +1,7 @@ from django.apps import AppConfig -from BaCa2.settings import PACKAGES -from .package_manage import Package + +from baca2PackageManager import Package +from core.settings import PACKAGES class PackageConfig(AppConfig): @@ -12,6 +13,6 @@ def ready(self): try: from .models import PackageInstance for instance in PackageInstance.objects.all(): - PACKAGES[instance.key] = Package(instance.path) + PACKAGES[instance.key] = Package(instance.package_source.path, instance.commit) except ProgrammingError: pass diff --git a/BaCa2/package/migrations/0001_initial.py b/BaCa2/package/migrations/0001_initial.py deleted file mode 100644 index 8f269265..00000000 --- a/BaCa2/package/migrations/0001_initial.py +++ /dev/null @@ -1,45 +0,0 @@ -# Generated by Django 4.1.3 on 2023-01-13 22:21 - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion -import package.validators - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ] - - operations = [ - migrations.CreateModel( - name='PackageInstance', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('commit', models.CharField(max_length=2047)), - ], - ), - migrations.CreateModel( - name='PackageSource', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=511, validators=[package.validators.isStr])), - ], - ), - migrations.CreateModel( - name='PackageInstanceUser', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('package_instance', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='package.packageinstance')), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - migrations.AddField( - model_name='packageinstance', - name='package_source', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='package.packagesource'), - ), - ] diff --git a/BaCa2/package/models.py b/BaCa2/package/models.py index a3a9e598..d6efd611 100644 --- a/BaCa2/package/models.py +++ b/BaCa2/package/models.py @@ -1,15 +1,99 @@ -from django.db import models -from main.models import User -from .validators import isStr -from BaCa2.settings import BASE_DIR -# from course.models import Task +from __future__ import annotations + from pathlib import Path -from BaCa2.settings import PACKAGES -from .package_manage import Package +from typing import List -from django.utils import timezone -from django.db import transaction +from django.conf import settings from django.core.exceptions import ValidationError +from django.db import models, transaction +from django.utils import timezone + +from baca2PackageManager import Package +from baca2PackageManager.validators import isStr +from core.tools.files import DocFileHandler +from core.tools.misc import random_id +from main.models import User +from util.models_registry import ModelsRegistry + + +class PackageSourceManager(models.Manager): + """ + PackageSourceManager is a manager for the PackageSource class + """ + + @transaction.atomic + def create_package_source(self, name: str) -> PackageSource: + """ + Create a new package source from the given name + + :param name: The name of the package source + :type name: str + + :return: A new PackageSource object. + """ + package_source = self.model(name=name) + package_source.save() + if not package_source.path.exists(): + package_source.path.mkdir() + return package_source + + @transaction.atomic + def create_package_source_from_zip( + self, + name: str, + zip_file: Path, + creator: int | str | User = None, + safe_name: bool = True, + return_package_instance: bool = False + ) -> PackageSource | PackageInstance: + """ + Create a new package source from the given zip file + + :param name: The name of the package source + :type name: str + :param zip_file: The path to the zip file + :type zip_file: Path + :param creator: The creator of the package (optional) + :type creator: int | str | User + :param safe_name: If True, make the name unique + :type safe_name: bool + :param return_package_instance: If True, return the package instance instead of the package + source + :type return_package_instance: bool + + :return: A new PackageSource object. + """ + if safe_name: + name = name.replace(' ', '_') + name = f'{name}_{random_id()}' + package_source = self.model(name=name) + package_source.save() + if not package_source.path.exists(): + package_source.path.mkdir() + package_instance = PackageInstance.objects.create_package_instance_from_zip( + package_source, + zip_file + ) + package_instance.save() + if creator: + PackageInstanceUser.objects.create_package_instance_user(creator, package_instance) + + if return_package_instance: + return package_instance + return package_source + + @transaction.atomic + def delete_package_source(self, package_source: PackageSource): + """ + If the package source is not in the database, raise an exception. + Otherwise, delete the package source and its package from the database + + :param package_source: The package source to delete + :type package_source: PackageSource + """ + for instance in package_source.instances: + instance.objects.delete_package_instance() + package_source.delete() class PackageSource(models.Model): @@ -17,12 +101,15 @@ class PackageSource(models.Model): PackageSource is a source for packages instances """ #: path to the main source - MAIN_SOURCE = BASE_DIR / 'packages' + MAIN_SOURCE = settings.PACKAGES_DIR #: name of the package name = models.CharField(max_length=511, validators=[isStr]) + #: manager for the PackageSource class + objects = PackageSourceManager() + def __str__(self): - return f"Package {self.pk}: {self.name}" + return f'Package {self.pk}: {self.name}' @property def path(self) -> Path: @@ -33,6 +120,76 @@ def path(self) -> Path: """ return self.MAIN_SOURCE / self.name + @property + def instances(self): + """ + It returns the instances of the package source + + :return: The instances of the package source. + """ + return PackageInstance.objects.filter(package_source=self) + + +class PackageInstanceUserManager(models.Manager): + """ + PackageInstanceUserManager is a manager for the PackageInstanceUser class + """ + + @transaction.atomic + def create_package_instance_user( + self, + user: int | str | User, + package_instance: int | str | PackageInstance + ) -> PackageInstanceUser: + """ + Create a new package instance user from the given user and package instance + + :param user: The user + :type user: User + :param package_instance: The package instance + :type package_instance: PackageInstance + + :return: A new PackageInstanceUser object. + """ + user = ModelsRegistry.get_user(user) + package_instance = ModelsRegistry.get_package_instance(package_instance) + package_instance_user = self.model.objects.filter(user=user, + package_instance=package_instance) + if package_instance_user.exists(): + return package_instance_user.first() + package_instance_user = self.model(user=user, package_instance=package_instance) + package_instance_user.save() + return package_instance_user + + @transaction.atomic + def delete_package_instance_user(self, package_instance_user: PackageInstanceUser): + """ + If the package instance user is not in the database, raise an exception. + Otherwise, delete the package instance user from the database + + :param package_instance_user: The package instance user to delete + :type package_instance_user: PackageInstanceUser + """ + package_instance_user.delete() + + def check_user(self, + user: int | str | User, + package_instance: int | PackageInstance) -> bool: + """ + Checks if user is associated with package instance. + + :param user: The user to be checked + :type user: int | str | User + :param package_instance: The package instance to be checked + :type package_instance: int | PackageInstance + + :return: True if user is associated with package instance, False otherwise. + :rtype: bool + """ + user = ModelsRegistry.get_user(user) + package_instance = ModelsRegistry.get_package_instance(package_instance) + return self.model.objects.filter(user=user, package_instance=package_instance).exists() + class PackageInstanceUser(models.Model): """ @@ -43,19 +200,190 @@ class PackageInstanceUser(models.Model): #: package instance associated with the user package_instance = models.ForeignKey('PackageInstance', on_delete=models.CASCADE) + #: manager for the PackageInstanceUser class + objects = PackageInstanceUserManager() -class PackageInstance(models.Model): + def __str__(self): + return f'PackageInstanceUser {self.pk}: \n{self.user}\n{self.package_instance}' + + +class PackageInstanceManager(models.Manager): """ - A PackageInstance is a unique version of a PackageSource. + PackageInstanceManager is a manager for the PackageInstance class """ - #: foreign key to the PackageSource class :py:class:`PackageSource` - # This means that each PackageInstance is associated with a single PackageSource - package_source = models.ForeignKey(PackageSource, on_delete=models.CASCADE) - #: unique identifier for every package instance - commit = models.CharField(max_length=2047) - @classmethod - def exists(cls, pkg_id: int) -> bool: + @transaction.atomic + def create_package_instance(self, + package_source: int | str | PackageSource, + commit: str, + creator: int | str | User = None) -> PackageInstance: + """ + Create a new package instance from the given path + + :param package_source: The package source + :type package_source: PackageSource + :param commit: The commit of the package + :type commit: str + :param creator: The creator of the package (optional) + :type creator: int | str | User + + :return: A new PackageInstance object. + """ + package_source = ModelsRegistry.get_package_source(package_source) + package_instance = self.model(package_source=package_source, commit=commit) + settings.PACKAGES[PackageInstance.commit_msg(package_source, commit)] = Package( + package_source.path, + commit) + package_instance.save() + if creator: + PackageInstanceUser.objects.create_package_instance_user(creator, package_instance) + return package_instance + + @transaction.atomic + def create_source_and_instance(self, + name: str, + commit: str, + creator: int | str | User = None) -> PackageInstance: + """ + Create a new package source from the given name. Also create a new package instance for it. + + :param name: The name of the package source + :type name: str + :param commit: The commit of the package + :type commit: str + :param creator: The creator of the package (optional) + :type creator: int | str | User + + :return: A new PackageInstance object. + :rtype: PackageInstance + """ + package_source = PackageSource.objects.create_package_source(name) + return self.create_package_instance(package_source, commit, creator) + + @transaction.atomic + def make_package_instance_commit(self, + package_instance: int | PackageInstance, + copy_permissions: bool = True, + creator: int | str | User = None) -> PackageInstance: + """ + Create a new package instance from the given path + + :param package_instance: The package instance + :type package_instance: PackageInstance + :param copy_permissions: If True, copy the permissions from the old package instance + :type copy_permissions: bool + :param creator: The creator of the package (optional) + :type creator: int | str | User + + :return: A new PackageInstance object. + """ + package_instance = ModelsRegistry.get_package_instance(package_instance) + new_commit = f'{timezone.now().timestamp()}' + + new_package = package_instance.package.make_commit(new_commit) + new_package.check_package() + + commit_msg = PackageInstance.commit_msg(package_instance.package_source, new_commit) + settings.PACKAGES[commit_msg] = new_package + + try: + pdf_docs = new_package.doc_path('pdf') + doc = DocFileHandler(pdf_docs, 'pdf') + static_path = doc.save_as_static() + except FileNotFoundError: + static_path = None + + try: + new_instance = self.model( + package_source=package_instance.package_source, + commit=new_commit, + pdf_docs=static_path + ) + new_instance.save() + + if copy_permissions: + for user in package_instance.permitted_users: + new_instance.add_permitted_user(user) + elif creator: + PackageInstanceUser.objects.create_package_instance_user(creator, new_instance) + + return new_instance + except Exception as e: + DocFileHandler.delete_doc(static_path) + raise e + + @transaction.atomic + def delete_package_instance(self, + package_instance: int | PackageInstance, + delete_files: bool = False): + """ + If the package instance is not in the database, raise an exception. + Otherwise, delete the package instance and its package from the database + + :param package_instance: The package instance to delete + :type package_instance: int | PackageInstance + :param delete_files: If True, delete the files associated with the package instance + :type delete_files: bool + """ + package_instance = ModelsRegistry.get_package_instance(package_instance) + package_instance.delete(delete_files) + + @transaction.atomic + def create_package_instance_from_zip(self, + package_source: int | str | PackageSource, + zip_file: Path, + overwrite: bool = False, + permissions_from_instance: int | PackageInstance = None, + creator: int | str | User = None) -> PackageInstance: + """ + Create a new package instance from the given path + + :param package_source: The package source + :type package_source: PackageSource + :param zip_file: The path to the zip file + :type zip_file: Path + :param overwrite: If True, overwrite the package instance files if it already exists + :type overwrite: bool + :param permissions_from_instance: The package instance to copy the permissions from + (optional) + :type permissions_from_instance: int | PackageInstance + :param creator: The creator of the package (optional) + :type creator: int | str | User + + :return: A new PackageInstance object. + """ + package_source = ModelsRegistry.get_package_source(package_source) + commit_name = f'from_zip_{random_id()}' + pkg = Package.create_from_zip(package_source.path, commit_name, zip_file, overwrite) + settings.PACKAGES[PackageInstance.commit_msg(package_source, commit_name)] = pkg + + try: + pdf_docs = pkg.doc_path('pdf') + doc = DocFileHandler(pdf_docs, 'pdf') + static_path = doc.save_as_static() + except FileNotFoundError: + static_path = None + try: + package_instance = self.model( + package_source=package_source, + commit=commit_name, + pdf_docs=static_path + ) + + package_instance.save() + if permissions_from_instance: + permissions_from_instance = ModelsRegistry.get_package_instance( + permissions_from_instance) + for user in permissions_from_instance.permitted_users: + PackageInstanceUser.objects.create_package_instance_user(user, package_instance) + if creator: + PackageInstanceUser.objects.create_package_instance_user(creator, package_instance) + return package_instance + except Exception as e: + DocFileHandler.delete_doc(static_path) + raise e + + def exists_validator(self, pkg_id: int) -> bool: """ If the package with the given ID exists, return True, otherwise return False @@ -64,15 +392,41 @@ def exists(cls, pkg_id: int) -> bool: :return: A boolean value. """ - return cls.objects.filter(pk=pkg_id).exists() + return self.filter(pk=pkg_id).exists() + + +class PackageInstance(models.Model): + """ + A PackageInstance is a unique version of a PackageSource. + """ + #: foreign key to the PackageSource class :py:class:`PackageSource` + # This means that each PackageInstance is associated with a single PackageSource + package_source = models.ForeignKey(PackageSource, on_delete=models.CASCADE) + #: unique identifier for every package instance + commit = models.CharField(max_length=2047) + #: pdf docs file path + pdf_docs = models.FilePathField(path=settings.TASK_DESCRIPTIONS_DIR, null=True, blank=True, + default=None, max_length=2047) + + #: manager for the PackageInstance class + objects = PackageInstanceManager() def __str__(self): + return f'PackageInstance {self.pk}: \n{self.package_source}\n{self.commit}' + + @classmethod + def commit_msg(cls, pkg: PackageSource, commit: str) -> str: """ - The __str__ function is a special function that is called when you print an object + It returns a string that is the name of the package source and the commit - :return: The key of the package instance. + :param pkg: The package source + :type pkg: PackageSource + :param commit: The commit of the package + :type commit: str + + :return: The name of the package source and the commit. """ - return f"Package Instance: {self.key}" + return f'{pkg.name}.{commit}' @property def key(self) -> str: @@ -81,7 +435,7 @@ def key(self) -> str: :return: The name of the package source and the commit. """ - return f"{self.package_source.name}.{self.commit}" + return self.commit_msg(self.package_source, self.commit) @property def package(self) -> Package: @@ -91,7 +445,11 @@ def package(self) -> Package: :return: The package object. """ package_id = self.key - return PACKAGES.get(package_id) + pkg = settings.PACKAGES.get(package_id) + if pkg is None: + pkg = Package(self.package_source.path, self.commit) + settings.PACKAGES[package_id] = pkg + return pkg @property def path(self) -> Path: @@ -102,51 +460,77 @@ def path(self) -> Path: """ return self.package_source.path / self.commit - def create_from_me(self) -> 'PackageInstance': + @property + def is_used(self) -> bool: """ - Create a new package instance from the current package instance + Checks if package instance is used in any task of any course. - :return: A new PackageInstance object. + :return: True if package instance is used in any task, False otherwise. + :rtype: bool """ - new_path = self.package_source.path - new_commit = timezone.now().timestamp() - - new_package = self.package.copy(new_path, new_commit) # PackageManager TODO: COPY - new_package.check_package() - - commit_msg = f"{self.package_source.name}.{new_commit}" - PACKAGES[commit_msg] = new_package + from course.models import Task + return Task.check_instance(self) - new_instance = PackageInstance.objects.create( - package_source=self.package_source, - commit=new_commit - ) + @property + def pdf_docs_path(self) -> Path: + """ + It returns the path to the pdf docs file - return new_instance + :return: The path to the pdf docs file. + """ + return Path(str(self.pdf_docs)) - def delete_instance(self): + def delete(self, delete_files: bool = False, using=None, keep_parents=False): """ If the task is not in the database, raise an exception. Otherwise, delete the task and its package from the database + + :param delete_files: If True, delete the files associated with the package instance + :type delete_files: bool + + :raises ValidationError: If the package instance is used in any task """ - from course.models import Task - if Task.check_instance(self): + if self.is_used: raise ValidationError('Package is used and you cannot delete it') with transaction.atomic(): # deleting instance in source directory - self.package.delete() - PACKAGES.pop(self.key) + if delete_files: + self.package.delete() + if self.pdf_docs: + DocFileHandler.delete_doc(Path(self.pdf_docs)) + settings.PACKAGES.pop(self.key) # self delete instance - self.delete() + super().delete(using, keep_parents) - def share(self, user: User): + @property + def permitted_users(self) -> List[User]: """ - It creates a new instance of a package, and then creates a new PackageInstanceUser object that links - the new package instance to the user + It returns the users that have permissions to the package instance - :param user: The user to share the package with + :return: The users that have permissions to the package instance. + """ + pkg_instance_users = PackageInstanceUser.objects.filter(package_instance=self) + return [pkg_instance_user.user for pkg_instance_user in pkg_instance_users] + + @transaction.atomic + def add_permitted_user(self, user: int | str | User) -> None: + """ + It adds a user to the package instance + + :param user: The user to add :type user: User """ - new_instance = self.create_from_me() - PackageInstanceUser.objects.create(user=user, package_instance=new_instance) + PackageInstanceUser.objects.create_package_instance_user(user, self) + + def check_user(self, user: int | str | User) -> bool: + """ + Checks if user is associated with package instance. + + :param user: The user to be checked + :type user: int | str | User + + :return: True if user is associated with package instance, False otherwise. + :rtype: bool + """ + return PackageInstanceUser.objects.check_user(user, self) diff --git a/BaCa2/package/package_manage.py b/BaCa2/package/package_manage.py deleted file mode 100644 index 8beb3f5a..00000000 --- a/BaCa2/package/package_manage.py +++ /dev/null @@ -1,608 +0,0 @@ -from copy import deepcopy -from pathlib import Path -from .validators import isAny, isNone, isInt, isIntBetween, isFloat, isFloatBetween, isStr, is_, isIn, isShorter, \ - isDict, isPath, isSize, isList, memory_converting, valid_memory_size -from yaml import safe_load, dump -from re import match -from BaCa2.settings import SUPPORTED_EXTENSIONS, BASE_DIR -from BaCa2.exceptions import NoTestFound, NoSetFound, TestExistError -from os import remove, replace, walk, mkdir, rename, listdir -from shutil import rmtree - - -def merge_settings(default: dict, to_add: dict) -> dict: - """ - It takes two dictionaries, and returns a new dictionary that has the keys of the first dictionary, and the values of the - second dictionary if they exist, otherwise the values of the first dictionary. It overwrites default dict with valuses from to_add - - :param default: The default settings - :type default: dict - :param to_add: The settings you want to add to the default settings - :type to_add: dict - - :return: A dictionary with the keys of the default dictionary and the values of the to_add dictionary. - """ - new = {} - for i in default.keys(): - if to_add is not None: - if i in to_add.keys() and to_add[i] is not None: - new[i] = to_add[i] - else: - new[i] = default[i] - else: - new[i] = default[i] - return new - - -class PackageManager: - """ - It's a class that manages a package's settings - """ - def __init__(self, path: Path, settings_init: Path or dict, default_settings: dict): - """ - If the settings_init is a dict, assign it to settings. If not, load the settings_init yaml file and assign it to - settings. Then merge the settings with the default settings - - :param path: Path to the settings file - :type path: Path - :param settings_init: This is the path to the settings file - :type settings_init: Path or dict - :param default_settings: a dict with default settings - :type default_settings: dict - """ - self._path = path - # if type of settings_init is a dict assign settings_init to processed settings - settings = {} - if type(settings_init) == dict: - if bool(settings_init): - settings = settings_init - # if not, make dict from settings_init yaml - else: - # unpacking settings file to dict - with open(settings_init, mode="rt", encoding="utf-8") as file: - settings = safe_load(file) - # merge external settings with default - self._settings = merge_settings(default_settings, settings) - - def __getitem__(self, arg: str): - """ - If the key is in the dictionary, return the value. If not, raise a KeyError - - :param arg: The name of the key to get the value of - :type arg: str - - :return: The value of the key in the dictionary. - """ - try: - return self._settings[arg] - except KeyError: - raise KeyError(f'No key named {arg} has found in self_settings') - - def __setitem__(self, arg: str, val): - """ - `__setitem__` is a special method that allows us to use the `[]` operator to set a value in a dictionary - - :param arg: str - :type arg: str - :param val: the value to be set - """ - self._settings[arg] = val - # effect changes to yaml settings - self.save_to_config(self._settings) - - def check_validation(self, validators) -> bool: - """ - It checks if the value of the setting is valid by checking if it matches any of the validators for that setting - - :param validators: A dictionary of validators. The keys are the names of the settings, and the values are lists of - validators. Each validator is a tuple of the form (function, *args, **kwargs). The function is called with the - setting value as the first argument, followed by the * - - :return: The check variable is being returned. - """ - for i, j in self._settings.items(): - check = False - for k in validators[i]: - check |= k[0](j, *k[1:]) - return check - - def save_to_config(self, settings): - """ - It opens a file called config.yml in the directory specified by the path attribute of the object, and writes the - settings dictionary to it. - - :param settings: The settings to save - """ - with open(self._path / 'config.yml', mode="wt", encoding="utf-8") as file: - dump(settings, file) - - def read_from_config(self): - """ - It reads the config.yml file from the path and returns the contents - - :return: The dict from config.yml file is being returned. - """ - with open(self._path / 'config.yml', mode="rt", encoding="utf-8") as file: - return safe_load(file) - - def add_empty_file(self, filename): - """ - It creates an empty file if it doesn't already exist - - :param filename: The name of the file to be created - """ - if not isPath(self._path / filename): - with open(self._path / filename, 'w') as f: - pass - - -class Package(PackageManager): - """ - It's a class that represents a package. - """ - - #: Largest file acceptable to upload - MAX_SUBMIT_MEMORY = '10G' - #: Largest acceptable submit time - MAX_SUBMIT_TIME = 600 - SETTINGS_VALIDATION = { - 'title': [[isStr]], - 'points': [[isInt], [isFloat]], - 'memory_limit': [[isSize, MAX_SUBMIT_MEMORY]], - 'time_limit': [[isIntBetween, 0, MAX_SUBMIT_TIME], [isFloatBetween, 0, MAX_SUBMIT_TIME]], - 'allowedExtensions': [[isIn, *SUPPORTED_EXTENSIONS], [isList, [isIn, *SUPPORTED_EXTENSIONS]]], - 'hinter': [[isNone], [isPath]], - 'checker': [[isNone], [isPath]], - 'test_generator': [[isNone], [isPath]] - } - """ - Validation for ``Package`` settings. - - Available options are: - - * ``title``: package name - * ``points``: maximum amount of points to earn - * ``memory_limit``: is a memory limit - * ``time_limit``: is a time limit - * ``allowedExtensions``: extensions witch are accepted - * ``hinter``: is a path or None value to actual hinter - * ``checker``: is a path or None value to actual checker - * ``test_generator``: is a path or None value to actual generator - """ - - #: Default values for Package settings - DEFAULT_SETTINGS = { - 'title': 'p', - 'points': 0, - 'memory_limit': '512M', - 'time_limit': 10, - 'allowedExtensions': 'cpp', - 'hinter': None, - 'checker': None, - 'test_generator': None - } - - def __init__(self, path: Path): - """ - It takes a path to a folder, and then it creates a list of all the subfolders in that folder, and then it creates a - TSet object for each of those subfolders - - :param path: Path - the path to the package - :type path: Path - """ - config_path = path / 'config.yml' - sets_path = path / 'tests' - super().__init__(path, config_path, Package.DEFAULT_SETTINGS) - self._sets = [] - for i in [x[0].replace(str(sets_path) + '\\', '') for x in walk(sets_path)][1:]: - self._sets.append(TSet(sets_path / i)) - - def rm_tree(self, set_name): - """ - It removes a directory tree - - :param set_name: The name of the test set - """ - if isPath(self._path / 'tests' / set_name): - rmtree(self._path / 'tests' / set_name) - - def copy(self, new_path, new_commit) -> 'Package': - """ - Function copies the package instance and creates new one/ - - :param new_path: The path of the package - :param new_commit: New unique commit - - :return: new Package - """ - pass - - def delete(self): - """ - Function deletes the package (itself) from directory - """ - pass - - def _add_new_set(self, set_name) -> 'TSet': - """ - This function adds a new test set to the test suite - - :param set_name: The name of the new test set - - :return: A new TSet object. - """ - settings = {'name': set_name} | self._settings - set_path = self._path / 'tests' / set_name - if not isPath(set_path): - mkdir(set_path) - with open(set_path / 'config.yml', 'w') as file: - dump({'name': set_name}, file) - new_set = TSet(set_path) - self._sets.append(new_set) - return new_set - - def sets(self, set_name: str, add_new: bool = False) -> 'TSet': - """ - It returns the set with the name `set_name` if it exists, otherwise it raises an error - - :param set_name: The name of the set you want to get - :type set_name: str - :param add_new: If True, it will create a new set directory if it doesn't exist, defaults to False - :type add_new: bool (optional) - - :return: The set with the name set_name - """ - for i in self._sets: - if i['name'] == set_name: - return i - if add_new: - self._add_new_set(set_name) - else: - raise NoSetFound(f'Any set directory named {set_name} has found') - - def delete_set(self, set_name: str): - """ - It deletes a set from the sets list and removes the directory of the set - - :param set_name: The name of the set you want to delete - :type set_name: str - :return: the list of sets. - """ - for i in self._sets: - if i['name'] == set_name: - self._sets.remove(i) - self.rm_tree(set_name) - return - raise NoSetFound(f'Any set directory named {set_name} has found to delete') - - def check_package(self, subtree: bool = True) -> bool | int: - """ - It checks the package. - - :param subtree: bool = True, defaults to True - :type subtree: bool (optional) - :return: The result of the check_validation() method and the result of the check_set() method. - """ - result = True - if subtree: - for i in self._sets: - result &= i.check_set() - return self.check_validation(Package.SETTINGS_VALIDATION) & result - - -class TSet(PackageManager): - """ - It's a class that represents a set of tests and modifies it - """ - SETTINGS_VALIDATION = { - 'name': [[isStr]], - 'weight': [[isInt], [isFloat]], - 'points': [[isInt], [isFloat]], - 'memory_limit': [[isNone], [isSize, Package.MAX_SUBMIT_MEMORY]], - 'time_limit': [[isNone], [isIntBetween, 0, Package.MAX_SUBMIT_TIME], - [isFloatBetween, 0, Package.MAX_SUBMIT_TIME]], - 'checker': [[isNone], [isPath]], - 'test_generator': [[isNone], [isPath]], - 'tests': [[isNone], [isAny]], - 'makefile': [[isNone], [isPath]] - } - - """ - Validation for ``TSet`` settings. - - Available options are: - * ``name``: set name - * ``weight``: impact of set score on final score - * ``points``: maximum amount of points to earn in set - * ``memory_limit``: is a memory limit for set - * ``time_limit``: is a time limit for set - * ``checker``: is a path or None value to actual checker - * ``test_generator``: is a path or None value to actual generator - * ``tests``: tests to run in set - * ``makefile``: name of a makefile - """ - #: Default values for set settings - DEFAULT_SETTINGS = { - 'name': 'set0', - 'weight': 10, - 'points': 0, - 'memory_limit': '512M', - 'time_limit': 10, - 'checker': None, - 'test_generator': None, - 'tests': {}, - 'makefile': None - } - - def __init__(self, path: Path): - """ - It reads the config file and creates a list of tests - - :param path: Path - path to the test set - :type path: Path - """ - config_path = path / 'config.yml' - super().__init__(path, config_path, TSet.DEFAULT_SETTINGS) - self._tests = [] - self._test_settings = { - 'name': '0', - 'memory_limit': self._settings['memory_limit'], - 'time_limit': self._settings['time_limit'], - 'points': 0 - } - if self._settings['tests'] is not None: - for i in self._settings['tests'].values(): - self._tests.append(TestF(path, i, self._test_settings)) - self._add_test_from_dir() - - def move_test_file(self, to_set, filename): - """ - It moves a file from one directory to another - - :param to_set: the set you want to move the file to - :param filename: the name of the file to move - """ - if isPath(to_set._path): - if isPath(self._path / filename): - replace(self._path / filename, to_set._path / filename) - - def _add_test_from_dir(self): - """ - It takes a directory, finds all the files in it, and then adds all the tests that have both an input and output file - """ - test_files_ext = listdir(self._path) - tests = [] - for i in test_files_ext: - tests.append(match('.*[^.in|out]', i).group(0)) - tests_to_do = [] - for i in tests: - if tests.count(i) == 2: - tests_to_do.append(i) - tests_to_do = set(tests_to_do) - names = [i["name"] for i in self._tests] - for i in tests_to_do: - if i not in names: - name_dict = {'name': i} - self._tests.append(TestF(self._path, name_dict, self._test_settings)) - - def tests(self, test_name: str, add_new: bool = False) -> 'TestF': - """ - It returns a test object with the given name, if it exists, or creates a new one if it doesn't - - :param test_name: The name of the test - :type test_name: str - :param add_new: if True, then if the test is not found, it will be created, defaults to False - :type add_new: bool (optional) - - :return: A TestF object - """ - for i in self._tests: - if i['name'] == test_name: - return i - if add_new: - new_test = TestF(self._path, {'name': test_name}, self._test_settings) - self._tests.append(new_test) - self.add_empty_file(test_name + '.in') - self.add_empty_file(test_name + '.out') - return new_test - raise NoTestFound(f'Any test named {test_name} has found') - - def remove_file(self, filename): - """ - It removes a file from the current directory - - :param filename: The name of the file to remove - """ - if isPath(self._path / filename): - remove(self._path / filename) - - def _delete_chosen_test(self, test: 'TestF'): - """ - It removes the test from the list of tests, deletes the files associated with the test, - and removes the test from the config file - - :param test: the test to be deleted - :type test: 'TestF' - """ - self._tests.remove(test) - self.remove_file(test['name'] + '.in') - self.remove_file(test['name'] + '.out') - - # removes settings for this test (from _settings and from config file) - new_settings = deepcopy(self._settings) - for k, v in self._settings['tests'].items(): - if v['name'] == test['name']: - new_settings['tests'].pop(k) - self._settings = new_settings - self.save_to_config(self._settings) - - def delete_test(self, test_name: str): - """ - It deletes a test from the list of tests, and deletes associated files. - - :param test_name: The name of the test to delete - :type test_name: str - :raise NoTestsFound: If there is no test with name equal to the test_name argument. - """ - for test in self._tests: - if test['name'] == test_name: - self._delete_chosen_test(test) - return - - raise NoTestFound(f'No test named {test_name} has found to delete') - - def _move_chosen_test(self, test: 'TestF', to_set: 'TSet'): - """ - Move a test from one test set to another - - :param test: 'TestF' - the test to be moved - :type test: 'TestF' - :param to_set: the set to which the test will be moved - :type to_set: 'TSet' - """ - name_list_ta = [j['name'] for j in to_set._tests] - if test['name'] not in name_list_ta: - to_set._tests.append(test) - self._tests.remove(test) - else: - raise TestExistError(f'Test named {test["name"]} exist in to_set files') - - self.move_test_file(to_set, test['name'] + '.in') - self.move_test_file(to_set, test['name'] + '.out') - - def _move_config(self, to_set: 'TSet', test_name: str): - """ - It takes a test name and a set object, and moves the test from the current set to the set object - - :param to_set: The set to move the test to - :type to_set: 'TSet' - :param test_name: The name of the test you want to move - :type test_name: str - """ - new_settings = deepcopy(self._settings) - for i, j in self._settings['tests'].items(): - if j['name'] == test_name: - to_set._settings['tests'][i] = j - new_settings['tests'].pop(i) - - self._settings = new_settings - self.remove_file('config.yml') - self.save_to_config(self._settings) - to_set.remove_file('config.yml') - to_set.save_to_config(to_set._settings) - - # moves test to to_set (and all settings pinned to this test in self._settings) - def move_test(self, test_name: str, to_set: 'TSet'): - """ - It moves a test from one set to another - - :param test_name: The name of the test to move - :type test_name: str - :param to_set: The set to which the test will be moved - :type to_set: 'TSet' - """ - search = False - for i in self._tests: - if i['name'] == test_name: - self._move_chosen_test(i, to_set) - self._move_config(to_set, test_name) - search |= True - return - search |= False - - if not search: - raise NoTestFound(f'Any test named {test_name} has found to move to to_set') - - # check set validation - def check_set(self, subtree=True) -> bool | int: - """ - It checks the set. - - :param subtree: If True, check the subtree of tests, defaults to True (optional) - - :return: The result of the check_validation() method and the result of the check_set() method. - """ - result = True - if subtree: - for i in self._tests: - result &= i.check_test() - - return self.check_validation(TSet.SETTINGS_VALIDATION) & result - - -class TestF(PackageManager): - """ - It's a class that represents test in a set. - """ - SETTINGS_VALIDATION = { - 'name': [[isStr]], - 'memory_limit': [[isNone], [isSize, Package.MAX_SUBMIT_MEMORY]], - 'time_limit': [[isNone], [isIntBetween, 0, Package.MAX_SUBMIT_TIME], - [isFloatBetween, 0, Package.MAX_SUBMIT_TIME]], - 'points': [[isInt], [isFloat]] - } - """ - Validation for ``TestF`` settings. - - Available options are: - * ``name``: test name - * ``points``: maximum amount of points to earn in test - * ``memory_limit``: is a memory limit for test - * ``time_limit``: is a time limit for test - """ - - def __init__(self, path: Path, additional_settings: dict or Path, default_settings: dict): - """ - This function initializes the class by calling the superclass's __init__ function, which is the __init__ function of - the Config class - - :param path: The path to the file that contains the settings - :type path: Path - :param additional_settings: This is a dictionary of settings that you want to override the default settings with - :type additional_settings: dict or Path - :param default_settings: This is a dictionary of default settings that will be used if the settings file doesn't - contain a value for a setting - :type default_settings: dict - """ - super().__init__(path, additional_settings, default_settings) - - def _rename_files(self, old_name, new_name): - """ - It renames a file - - :param old_name: The name of the file you want to rename - :param new_name: The new name of the file - """ - rename(self._path / old_name, self._path / new_name) - - def __setitem__(self, arg: str, val): - """ - It renames the files and changes the name in the yaml file - - :param arg: the key of the dictionary - :type arg: str - :param val: the new value - """ - # effect changes to yaml settings - settings = self.read_from_config() - if arg == 'name': - self._rename_files(self._settings['name'] + '.in', val + '.in') - self._rename_files(self._settings['name'] + '.out', val + '.out') - - for i, j in settings['tests'].items(): - if j['name'] == self._settings['name']: - new_key = 'test' + val - j[arg] = val - settings['tests'][new_key] = j - del settings['tests'][i] - break - - self.save_to_config(settings) - self._settings[arg] = val - - def check_test(self) -> bool: - """ - It checks if the test is valid - :return: The return value is the result of the check_validation method. - """ - return self.check_validation(TestF.SETTINGS_VALIDATION) diff --git a/BaCa2/package/packages/1/config.yml b/BaCa2/package/packages/1/config.yml deleted file mode 100644 index 61cef033..00000000 --- a/BaCa2/package/packages/1/config.yml +++ /dev/null @@ -1,3 +0,0 @@ -title: 'fibonacci' -maxPoints: 11 -allowedExtensions: 'cpp' \ No newline at end of file diff --git a/BaCa2/package/packages/1/doc/index.md b/BaCa2/package/packages/1/doc/index.md deleted file mode 100644 index 49546043..00000000 --- a/BaCa2/package/packages/1/doc/index.md +++ /dev/null @@ -1,30 +0,0 @@ -# Liczby Fibonacciego -Program ma wypisywać n pierwszych liczb Fibonacciego -Liczby mają zostać wypisane w osobnych liniach, każdy n-ty wynik ma zostać -odzielony znakiem nowej linii (dodatkowym).\ -Wejście: cyfra n oznaczająca ile zestawów liczb Fibonacciego będzie trzeba -obliczyć. Kolejno zostaje podane n cyfr oznaczających ile -liczb Fibonacciego trzeba będzie wypisać. -### Example -IN:\ -3\ -2\ -4\ -7 - -OUT:\ -0\ -1 - -0\ -1\ -1\ -2 - -0\ -1\ -1\ -2\ -3\ -5\ -8 diff --git a/BaCa2/package/packages/1/prog/solution.cpp b/BaCa2/package/packages/1/prog/solution.cpp deleted file mode 100644 index bc482289..00000000 --- a/BaCa2/package/packages/1/prog/solution.cpp +++ /dev/null @@ -1,26 +0,0 @@ -#include -using namespace std; -int main() { - int n; - cin >> n; - int range[n]; - int maxVal = 0 - for(int i = 0; i < n; ++i) { - cin >> range[i]; - if(range[i] > maxVal) { - maxVal = range[i] - } - } - int values[maxVal]; - values[0] = 0 - values[1] = 1 - for(int i = 2; i < maxVal; i++) { - values[i] = values[i - 2] + values[i - 1] - } - for(int i = 0; i < n; i++) { - for(int j = 0; j < range[i]; j++) { - cout << values[j]; - } - cout << endl; - } -} \ No newline at end of file diff --git a/BaCa2/package/packages/1/tests/set0/config.yml b/BaCa2/package/packages/1/tests/set0/config.yml deleted file mode 100644 index 5852217b..00000000 --- a/BaCa2/package/packages/1/tests/set0/config.yml +++ /dev/null @@ -1,19 +0,0 @@ -checker: null -makefile: null -memory_limit: 10M -name: set0 -points: 1 -test_generator: null -tests: - test2: - memory_limit: 8M - name: '2' - points: 0.5 - time_limit: 8 - test3: - memory_limit: 6M - name: '3' - points: 0.5 - time_limit: 9 -time_limit: 10 -weight: 10 diff --git a/BaCa2/package/packages/1/tests/set1/2.out b/BaCa2/package/packages/1/tests/set1/2.out deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/1.in b/BaCa2/package/packages/1/tests/set2/1.in deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/1.out b/BaCa2/package/packages/1/tests/set2/1.out deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/5.in b/BaCa2/package/packages/1/tests/set2/5.in deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/5.out b/BaCa2/package/packages/1/tests/set2/5.out deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/6.in b/BaCa2/package/packages/1/tests/set2/6.in deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/6.out b/BaCa2/package/packages/1/tests/set2/6.out deleted file mode 100644 index e69de29b..00000000 diff --git a/BaCa2/package/packages/1/tests/set2/config.yml b/BaCa2/package/packages/1/tests/set2/config.yml deleted file mode 100644 index 42e7c322..00000000 --- a/BaCa2/package/packages/1/tests/set2/config.yml +++ /dev/null @@ -1,19 +0,0 @@ -checker: null -makefile: null -memory_limit: 10M -name: set2 -points: 5 -test_generator: null -tests: - test1: - memory_limit: 6M - name: '1' - points: 3 - time_limit: 9 - test6: - memory_limit: 1M - name: '6' - points: 0 - time_limit: 13 -time_limit: 10 -weight: 10 diff --git a/BaCa2/package/packages/tests_to_testing/1.in b/BaCa2/package/packages/tests_to_testing/1.in deleted file mode 100644 index 34bdb001..00000000 --- a/BaCa2/package/packages/tests_to_testing/1.in +++ /dev/null @@ -1,4 +0,0 @@ -3 -2 -4 -7 \ No newline at end of file diff --git a/BaCa2/package/packages/tests_to_testing/1.out b/BaCa2/package/packages/tests_to_testing/1.out deleted file mode 100644 index 7d6ac646..00000000 --- a/BaCa2/package/packages/tests_to_testing/1.out +++ /dev/null @@ -1,15 +0,0 @@ -0 -1 - -0 -1 -1 -2 - -0 -1 -1 -2 -3 -5 -8 \ No newline at end of file diff --git a/BaCa2/package/packages/tests_to_testing/config.yml b/BaCa2/package/packages/tests_to_testing/config.yml deleted file mode 100644 index 6b0a6c62..00000000 --- a/BaCa2/package/packages/tests_to_testing/config.yml +++ /dev/null @@ -1,5 +0,0 @@ -memoryLimit: 10M -name: set0 -points: 1 -tests: {} -timeLimit: 10 diff --git a/BaCa2/package/tests.py b/BaCa2/package/tests.py index cabd9548..14208ac8 100644 --- a/BaCa2/package/tests.py +++ b/BaCa2/package/tests.py @@ -1,588 +1,187 @@ -from unittest import TestCase -from django.test import TestCase -from BaCa2.exceptions import NoSetFound, NoTestFound -from .validators import isAny, isNone, isInt, isIntBetween, isFloat, isFloatBetween, isStr, is_, isIn, isShorter, isDict, isPath, isSize, isList, memory_converting, valid_memory_size, hasStructure, isSize -from .package_manage import TestF, TSet, Package -from BaCa2.settings import BASE_DIR -from pathlib import Path -from yaml import safe_load -import random -import string - - -def generate_rand_int(): - return random.randint(0, 10000000) - - -def compare_memory_unit(unit1, unit2): - if unit1 == 'B': - return True - elif unit1 == 'K' and (unit2 == 'K' or unit2 == 'M' or unit2 == 'G'): - return True - elif unit1 == 'M' and (unit2 == 'M' or unit2 == 'G'): - return True - elif unit1 == 'G' and unit2 == 'G': - return True - return False +import shutil +from django.conf import settings +from django.test import TestCase -def generate_rand_dict(): - _dict = {} - for i in range(100): - key1 = random.choice(string.ascii_letters) - value1 = random.randint(1, 10000) - key2 = random.randint(1, 10000) - value2 = random.choice(string.ascii_letters) - if i % 4 == 0: - _dict[key1] = value1 - elif i % 4 == 1: - _dict[key2] = value2 - elif i % 4 == 2: - _dict[key1] = value2 +from main.models import User +from parameterized import parameterized + +from .models import * + + +class TestPackage(TestCase): + user = None + user2 = None + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user('test@test.com', 'test') + cls.user2 = User.objects.create_user('test2@test.com', 'test') + cls.zip_file = settings.PACKAGES_DIR / 'test_pkg.zip' + + def tearDown(self): + PackageInstance.objects.all().delete() + PackageSource.objects.all().delete() + PackageInstanceUser.objects.all().delete() + + @parameterized.expand([(True,), (None,)]) + def test_01_create_package_instance(self, usr): + if usr: + usr = self.user + pkg = PackageInstance.objects.create_source_and_instance('dosko', '1', creator=usr) + self.assertIsInstance(pkg.package, Package) + self.assertIn(pkg.key, settings.PACKAGES.keys()) + self.assertEqual(pkg.package['title'], 'Liczby Doskonałe') + + if usr: + self.assertEqual(len(pkg.permitted_users), 1) + self.assertEqual(pkg.permitted_users[0], usr) + + @parameterized.expand([('none',), ('creator',), ('permission_pass',)]) + def test_02_create_new_commit(self, usr_mode): + usr = None + if usr_mode in ('creator', 'permission_pass'): + usr = self.user + pkg = PackageInstance.objects.create_source_and_instance('dosko', '1', creator=usr) + if usr_mode == 'permission_pass': + pkg.add_permitted_user(self.user2) + self.assertEqual(len(pkg.permitted_users), 2) + new_pkg = None + try: + if usr_mode == 'permission_pass': + new_pkg = PackageInstance.objects.make_package_instance_commit(pkg) + elif usr_mode == 'creator': + new_pkg = PackageInstance.objects.make_package_instance_commit( + pkg, + copy_permissions=False, + creator=self.user2, + ) + else: + new_pkg = PackageInstance.objects.make_package_instance_commit( + pkg, + copy_permissions=False, + ) + self.assertIsInstance(new_pkg.package, Package) + self.assertIn(new_pkg.key, settings.PACKAGES.keys()) + self.assertEqual(new_pkg.package['title'], 'Liczby Doskonałe') + self.assertNotEqual(new_pkg.key, pkg.key) + self.assertNotEqual(new_pkg.path, pkg.path) + + if usr_mode == 'permission_pass': + self.assertEqual(len(new_pkg.permitted_users), 2) + self.assertIn(self.user, new_pkg.permitted_users) + self.assertIn(self.user2, new_pkg.permitted_users) + elif usr_mode == 'creator': + self.assertEqual(len(new_pkg.permitted_users), 1) + self.assertIn(self.user2, new_pkg.permitted_users) + else: + self.assertEqual(len(new_pkg.permitted_users), 0) + + shutil.rmtree(new_pkg.path) + new_pkg.delete() + except Exception as e: + shutil.rmtree(new_pkg.path) + new_pkg.delete() + raise e + + @parameterized.expand([(False,), (True,)]) + def test_03_delete_package_instance(self, users): + pkg = PackageInstance.objects.create_source_and_instance('dosko', '1') + new_pkg = None + try: + new_pkg = PackageInstance.objects.make_package_instance_commit(pkg) + if users: + new_pkg.add_permitted_user(self.user) + new_pkg.add_permitted_user(self.user2) + path = new_pkg.path + pk = new_pkg.pk + PackageInstance.objects.delete_package_instance(new_pkg, delete_files=True) + self.assertFalse(path.exists()) + with self.assertRaises(PackageInstance.DoesNotExist): + PackageInstance.objects.get(pk=pk) + if users: + users_assigned = PackageInstanceUser.objects.filter(package_instance_id=pk).count() + self.assertEqual(users_assigned, 0) + except Exception as e: + shutil.rmtree(new_pkg.path) + new_pkg.delete() + raise e + + @parameterized.expand([ + ('without users', False, False, ), + ('with creator', True, False, ), + ('with instance permissions', False, True), + ('with creator and instance permissions', True, True)]) + def test_04_create_from_zip(self, name, usr, from_instance): + pkg_src = PackageSource.objects.create_package_source('dosko') + pkg = None + if usr: + usr = self.user else: - _dict[key2] = value1 - return _dict - - -def generate_rand_list(): - _list = [] - for i in range(100): - val1 = random.choice(string.ascii_letters) - val2 = random.randint(1, 10000) - val3 = random.uniform(1, 10000) - _list.append(val1) - _list.append(val2) - _list.append(val3) - return list - - -class ValidationsTests(TestCase): - - def test_isInt(self): - """ - It tests if the function isInt() works correctly - """ - for i in range(1000): - a = generate_rand_int() - self.assertTrue(isInt(a)) - float(a) - self.assertTrue(isInt(a)) - str(a) - self.assertTrue(isInt(a)) - - self.assertFalse(isInt(0.5)) - self.assertFalse(isInt('0.5')) - self.assertFalse(isInt(1235.4567)) - self.assertFalse(isInt('1235.4567')) - self.assertFalse(isInt('5,5')) - self.assertFalse(isInt("5 and more")) - self.assertFalse(isInt("It is just a string")) - - def test_isIntBetween(self): # a <= val < b - """ - It tests if the function is_IntBetween() works correctly """ - for i in range(1000): - a = generate_rand_int() - b = generate_rand_int() - if a > b: - a, b = b, a - val = random.randint(a, b - 1) - self.assertTrue(isIntBetween(val, a, b)) - float(a) - float(b) - float(val) - self.assertTrue(isIntBetween(val, a, b)) - val = random.randint(0, a - 1) - self.assertFalse(isIntBetween(val, a, b)) - val = random.randint(b + 1, 10000001) - self.assertFalse(isIntBetween(val, a, b)) - self.assertTrue(isIntBetween(5, 2, 10)) - self.assertTrue(isIntBetween(68, 68, 78)) - self.assertTrue(isIntBetween(-4, -7, -1)) - self.assertTrue(isIntBetween(-6, -6, 0)) - self.assertFalse(isIntBetween(5, 1, 3)) - self.assertFalse(isIntBetween(5, -7, -1)) - self.assertFalse(isIntBetween(5, -6, 0)) - self.assertFalse(isIntBetween(5, 6, 67)) - self.assertFalse(isIntBetween(67, 6, 67)) - - def test_isFloat(self): - """ - It tests if the function isFloat() works correctly - """ - for i in range(1000): - a = random.uniform(0.1, 100000.0) - self.assertTrue(isFloat(a)) - float(a) - self.assertTrue(isFloat(a)) - str(a) - self.assertTrue(isFloat(a)) - a = random.random() - self.assertTrue(isFloat(a)) - float(a) - self.assertTrue(isFloat(a)) - str(a) - self.assertTrue(isFloat(a)) - self.assertTrue(isFloat('5')) - self.assertTrue(isFloat(5)) - self.assertTrue(isFloat(0)) - self.assertTrue(isFloat('0')) - self.assertTrue(isFloat(123456)) - self.assertTrue(isFloat('123456')) - self.assertTrue(isFloat(0.5)) - self.assertTrue(isFloat('0.5')) - self.assertTrue(isFloat(1235.4567)) - self.assertTrue(isFloat('1235.4567')) - self.assertFalse(isFloat('5,5')) - self.assertFalse(isFloat("5 and more")) - self.assertFalse(isFloat("53.46 and more")) - self.assertFalse(isFloat("It is just a string")) - - def test_isFloatBetween(self): - """ - It tests if the function isFloatBetween() works correctly - """ - for i in range(1000): - a = random.randint(1, 10000000) - b = random.randint(1, 10000000) - if a > b: - a, b = b, a - val = random.uniform(a, b - 1) - self.assertTrue(isFloatBetween(val, a, b)) - float(a) - float(b) - float(val) - self.assertTrue(isFloatBetween(val, a, b)) - val = random.uniform(0.0, a - 1.0) - self.assertFalse(isFloatBetween(val, a, b)) - val = random.uniform(b + 1.0, 10000001.0) - self.assertFalse(isFloatBetween(val, a, b)) - - def test_isStr(self): - """ - It tests if the function isStr() works correctly - """ - for i in range(1000): - val = random.choice(string.ascii_letters) - self.assertTrue(isStr(val)) - for i in range(10000): - val2 = random.randint(1, 10000000) - self.assertFalse(isStr(val2)) - float(val2) - self.assertFalse(isStr(val2)) - - def test_is_(self): - """ - It tests if the function is_() works correctly - """ - for i in range(1000): - val = random.choice(string.ascii_letters) - schema = val - self.assertTrue(is_(val, schema)) - schema = random.choice(string.ascii_letters) - if (schema != val): - self.assertFalse(is_(val, schema)) - val = random.randint(1, 100000) - self.assertFalse(is_(val, schema)) - float(val) - self.assertFalse(is_(val, schema)) - - def test_isShorter(self): - """ - It tests if the function isShorter() works correctly - """ - for i in range(1000): - val = random.choice(string.ascii_letters) - _int = random.randint(len(val) + 1, 10000) - self.assertTrue(isShorter(val, _int)) - _int = random.randint(0, len(val) - 1) - self.assertFalse(isShorter(val, _int)) - for i in range(10000): - val2 = random.randint(1, 10000000) - _int = random.randint(1, 10000000) - self.assertFalse(isShorter(val2, _int)) - float(val2) - self.assertFalse(isShorter(val2, _int)) - - def test_isDict(self): - """ - It tests if the function isDict() works correctly - """ - for i in range(1000): - _dict = generate_rand_dict() - self.assertTrue(isDict(_dict)) - for i in range(1000): - value = random.randint(1, 10000) - self.assertFalse(isDict(value)) - float(value) - self.assertFalse(isDict(value)) - value = random.choice(string.ascii_letters) - self.assertFalse(isDict(value)) - - def test_isList(self): - """ - It tests if the function isList() works correctly - """ - for i in range(1000): - _list = generate_rand_list() - self.assertTrue(isList(_list)) - - def test_memory_converting(self): - """ - It tests if the function memory_converting() works correctly - """ - # error message in case if test case got failed - message = "First value and second value are not equal!" - # assertEqual() to check equality of first & second value - size = 456 - val = str(size) + "B" - # test for B unit value - self.assertEqual(memory_converting(val), size, message) - val = str(size) + "K" - # test for K unit value - self.assertEqual(memory_converting(val), size * 1024, message) - val = str(size) + "M" - # test for M unit value - self.assertEqual(memory_converting(val), size * 1024 * 1024, message) - val = str(size) + "G" - # test for G unit value - self.assertEqual(memory_converting(val), size * 1024 * 1024 * 1024, message) - - def test_valid_memory_size(self): - """ - It tests if the function memory_size() works correctly - """ - unit_list = ['B', 'K', 'M', 'G'] - for i in range(1000): - size = random.randint(1, 100000) - max_size = random.randint(size, 100001) - unit1 = random.choice(unit_list) - unit2 = random.choice(unit_list) - if not (compare_memory_unit(unit1, unit2)): - unit1, unit2 = unit2, unit1 # now unit1 is smaller than unit - mem_size = str(size) + unit1 - max_mem_size = str(max_size) + unit2 - self.assertTrue(valid_memory_size(mem_size, max_mem_size)) - - def test_hasStructure(self): - """ - It tests if the function hasStructure() works correctly - """ - # validator at the end of a string - structure = "set" - self.assertTrue(hasStructure("set123", structure)) - self.assertTrue(hasStructure("set", structure)) - structure = "test_" - self.assertTrue(hasStructure("test_a", structure)) - self.assertTrue(hasStructure("test_wrong", structure)) - self.assertTrue(hasStructure("test_0", structure)) - # structure at the end and in the middle - structure = "test_set" - self.assertTrue(hasStructure("test123_set123", structure)) - self.assertFalse(hasStructure("test_13_set12", structure)) - self.assertFalse(hasStructure("test12set34", structure)) - self.assertFalse(hasStructure("test123_set", structure)) - self.assertFalse(hasStructure("test_123_set", structure)) - self.assertFalse(hasStructure("test_123", structure)) - # structure at the beginning - structure = ".in" - self.assertFalse(hasStructure("1. in", structure)) - self.assertFalse(hasStructure("str.in", structure)) - self.assertFalse(hasStructure("2.5.in", structure)) - - # two structures with alternative at beginning - structure = "|_course" - self.assertTrue(hasStructure("ASD_course", structure)) - self.assertTrue(hasStructure("MD_course", structure)) - self.assertTrue(hasStructure("123_course", structure)) - # structure at the beginning, middle and at the end - structure = "test||" - self.assertTrue(hasStructure("23test23", structure)) - self.assertTrue(hasStructure("123testSTR", structure)) - self.assertTrue(hasStructure("123test5.67", structure)) - self.assertFalse(hasStructure("test", structure)) - self.assertFalse(hasStructure("34TEST23", structure)) - - def test_isAny(self): - """ - It tests if the function isAny() works correctly - """ - for i in range(1000): - val = random.randint(0, 10000000) - self.assertTrue(isAny(val)) - float(val) - self.assertTrue(isAny(val)) - str(val) - self.assertTrue(isAny(val)) - val = random.uniform(0.1, 100000.0) - self.assertTrue(isAny(val)) - float(val) - self.assertTrue(isAny(val)) - str(val) - self.assertTrue(isAny(val)) - a = random.random() - self.assertTrue(isAny(val)) - float(val) - self.assertTrue(isAny(val)) - str(val) - self.assertTrue(isAny(val)) - val = random.choice(string.ascii_letters) - self.assertTrue(isAny(val)) - _dict = generate_rand_dict() - self.assertTrue(isAny(_dict)) - _list = generate_rand_list() - self.assertTrue(isAny(_list)) - - def test_isNone(self): - """ - It tests if the function isNone() works correctly - """ - for i in range(1000): - val = random.randint(0, 10000000) - self.assertFalse(isNone(val)) - float(val) - self.assertFalse(isNone(val)) - str(val) - val = random.choice(string.ascii_letters) - self.assertFalse(isNone(val)) - _dict = generate_rand_dict() - self.assertFalse(isNone(_dict)) - _list = generate_rand_list() - self.assertFalse(isNone(_list)) - self.assertTrue(isNone(None)) - - def test_isIn(self): - """ - It tests if the function isIn() works correctly - """ - rand_int = generate_rand_int() - rand_float = random.uniform(0.1, 0.9) - rand_str = random.choice(string.ascii_letters) - rand_dict = generate_rand_dict() - rand_list = generate_rand_list() - self.assertTrue(isIn(rand_int, rand_int, rand_float, rand_str, rand_dict, rand_list)) - self.assertTrue(isIn(rand_float, rand_int, rand_float, rand_str, rand_dict, rand_list)) - self.assertTrue(isIn(rand_str, rand_int, rand_float, rand_str, rand_dict, rand_list)) - self.assertTrue(isIn(rand_dict, rand_int, rand_float, rand_str, rand_dict, rand_list)) - self.assertTrue(isIn(rand_list, rand_int, rand_float, rand_str, rand_dict, rand_list)) - self.assertFalse(isIn(rand_int, rand_float, rand_str, rand_dict)) - self.assertFalse(isIn(rand_list, rand_float, rand_str, rand_dict)) - self.assertFalse(isIn(rand_dict, rand_float, rand_str, rand_list)) - - def test_isPath(self): - """ - It tests if the function isPath() works correctly. - """ - abs_path = Path("BaCa2/package/packages/tests_to_testing/config.yml").resolve() - self.assertTrue(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/tests_to_testing/1.out").resolve() - self.assertTrue(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/1/config.yml").resolve() - self.assertTrue(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/1/prog/solution.cpp").resolve() - self.assertTrue(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/tests_to_testing/10.out").resolve() - self.assertFalse(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/1/config234.yml").resolve() - self.assertFalse(isPath(abs_path)) - abs_path = Path("BaCa2/package/packages/1/prog/sopution.cpp").resolve() - self.assertFalse(isPath(abs_path)) - - def test_isSize(self): - """ - It tests if the function isSize() works correctly - """ - unit_list = ['B', 'K', 'M', 'G'] - for i in range(1000): - size = random.randint(1, 100000) - max_size = random.randint(size, 100001) - unit1 = random.choice(unit_list) - unit2 = random.choice(unit_list) - if not (compare_memory_unit(unit1, unit2)): - unit1, unit2 = unit2, unit1 # now unit1 is smaller than unit - mem_size = str(size) + unit1 - max_mem_size = str(max_size) + unit2 - self.assertTrue(isSize(mem_size, max_mem_size)) - -# It tests the TestF class -class TestFTests(TestCase): - def setUp(self): - """ - It creates a test object with the following parameters: - - * `Path(__file__).resolve().parent / 'packages' / 'tests_to_testing'` - the path to the directory containing the - test files. - * `{}` - the input data for the test. - * `{'name': '1', 'memory_limit': '10M', 'time_limit': 5}` - the test parameters - """ - self.test = TestF(Path(__file__).resolve().parent / 'packages' / 'tests_to_testing', {}, { - 'name': '1', - 'memory_limit': '10M', - 'time_limit': 5 - }) - def test_check_valid(self): - """ - It checks if the test is valid. - """ - self.assertTrue(self.test.check_test()) - - def test_get_settings(self): - """ - This function tests the get_settings function in the test_settings.py file - """ - self.assertEqual(self.test['name'], '1') - self.assertEqual(self.test['memory_limit'], '10M') - self.assertEqual(self.test['time_limit'], 5) - - def test_set_settings(self): - """ - It tests that the settings can be set - """ - self.test['name'] = '2' - self.assertEqual(self.test['name'], '2') - self.test['name'] = '1' - -# `TestTSet` is a class that tests the class `TSet` -class TSetTests(TestCase): - def setUp(self): - """ - The function `setUp` is a method of the class `TestTSet` that creates three instances of the class `TSet` and - assigns them to the variables `self.set0`, `self.set1`, and `self.set2` - """ - self.path = Path(__file__).resolve().parent / 'packages\\1\\tests' - self.set0 = TSet(self.path / 'set0') - self.set1 = TSet(self.path / 'set1') - self.set2 = TSet(self.path / 'set2') - - def test_init(self): - """ - `test_init` checks that the `_path` attribute of the `Set` object is equal to the path of the set - """ - self.assertEqual(self.set0._path, self.path / 'set0') - self.assertEqual(self.set1._path, self.path / 'set1') - self.assertEqual(self.set2._path, self.path / 'set2') - - def test_check_valid(self): - """ - This function checks if the set is valid - """ - self.assertTrue(self.set0.check_set()) - self.assertEqual(len(self.set0._tests), 2) - self.assertTrue(self.set1.check_set()) - self.assertTrue(self.set2.check_set()) - - def test_func_tests1(self): - self.assertEqual(self.set0.tests('2'), self.set0._tests[0]) - test = [i for i in self.set1._tests if i['name'] == '2'] - self.assertEqual(self.set1.tests('2'), test[0]) - self.assertEqual(self.set2.tests('3', True), self.set2._tests[3]) - self.set2.delete_test('3') - - def test_func_tests2(self): - """ - It tests the function tests() in the class TestSet. - """ - self.assertRaises(NoTestFound, self.set0.tests, '1') - self.assertRaises(NoTestFound, self.set1.tests, '3') - self.assertRaises(NoTestFound, self.set2.tests, '2') - - def test_func_delete1(self): - """ - This function tests the delete_test function in the TestSet class - """ - self.assertEqual(self.set0.tests('4', True), self.set0._tests[2]) - self.set0.delete_test('4') - names = [i['name'] for i in self.set0._tests] - self.assertNotIn('4', names) - - def test_func_delete2(self): - """ - It checks if the test is in the set. - """ - self.assertRaises(NoTestFound, self.set0.tests, '1') - self.assertRaises(NoTestFound, self.set1.tests, '3') - self.assertRaises(NoTestFound, self.set2.tests, '2') - - def test_func_move1(self): - """ - It moves a test from one test set to another. - """ - self.set2.move_test('5', self.set1) - names = [i['name'] for i in self.set1._tests] - self.assertIn('5', names) - self.set1.move_test('5', self.set2) - - def test_func_move2(self): - """ - It moves a test from one set to another. - """ - self.set2.move_test('6', self.set1) - with open(self.set1._path / 'config.yml', mode="rt", encoding="utf-8") as file: - settings = safe_load(file) - value = False - for i in settings['tests'].values(): - if i['name'] == '6': - value = True - self.assertTrue(value) - self.set1.move_test('6', self.set2) - - def test_func_move2(self): - """ - It tests if the function tests() raises an error when the test number is not found. - """ - self.assertRaises(NoTestFound, self.set0.tests, '1') - self.assertRaises(NoTestFound, self.set1.tests, '3') - self.assertRaises(NoTestFound, self.set2.tests, '2') - -# It tests the package class -class PackageTests(TestCase): - def setUp(self): - """ - The function takes a path to a package and returns a package object - """ - self.path = Path(__file__).resolve().parent.parent / 'package\packages\\1' - self.package = Package(self.path) - - def test_init(self): - """ - The function tests that the package's path is the same as the path that was passed to the function, and that the - package's title is the same as the title in the config.yml file - """ - self.assertEqual(self.package._path, self.path) - with open(self.path / 'config.yml', mode="rt", encoding="utf-8") as file: - settings = safe_load(file) - self.assertEqual(self.package['title'], settings['title']) - - def test_func_sets1(self): - """ - It tests if the function sets() returns the correct set. - """ - self.assertEqual(self.package.sets('set0'), self.package._sets[0]) - - def test_func_sets2(self): - """ - It tests that the function sets() raises an exception when it is passed a set that does not exist. - """ - self.assertRaises(NoSetFound, self.package.sets, 'set3') - - def test_func_sets3(self): - """ - This function tests the sets function in the package class - """ - self.package.sets('set4', True) - sets = [i['name'] for i in self.package._sets] - self.assertIn('set4', sets) - self.assertTrue(self.package.sets('set4')._path.exists()) - self.package.delete_set('set4') - - def test_func_delete(self): - """ - This function tests the delete function of the package class - """ - self.assertRaises(NoSetFound, self.package.sets, 'set4') - - def test_check_valid(self): - """ - It checks if the package is valid. - """ - self.assertTrue(self.package.check_package()) + usr = None + if from_instance: + pkg = PackageInstance.objects.create_package_instance(pkg_src, commit='1', creator=usr) + pkg.add_permitted_user(self.user2) + try: + pkg = PackageInstance.objects.create_package_instance_from_zip( + pkg_src, + self.zip_file, + permissions_from_instance=pkg, + creator=usr + ) + self.assertIsInstance(pkg.package, Package) + self.assertIn(pkg.key, settings.PACKAGES.keys()) + self.assertEqual(pkg.package['title'], 'zip test pkg') + self.assertEqual(pkg.package['points'], 123) + if usr and from_instance: + self.assertEqual(len(pkg.permitted_users), 2) + self.assertIn(self.user, pkg.permitted_users) + self.assertIn(self.user2, pkg.permitted_users) + elif usr: + self.assertEqual(len(pkg.permitted_users), 1) + self.assertIn(self.user, pkg.permitted_users) + elif from_instance: + self.assertEqual(len(pkg.permitted_users), 1) + self.assertIn(self.user2, pkg.permitted_users) + else: + self.assertEqual(len(pkg.permitted_users), 0) + + shutil.rmtree(pkg.path) + pkg.delete() + except Exception as e: + if pkg is not None: + shutil.rmtree(pkg.path) + pkg.delete() + raise e + + @parameterized.expand([(False,), (True,)]) + def test_05_create_source_from_zip(self, usr): + pkg_src = None + if usr: + usr = self.user + else: + usr = None + try: + pkg_src = PackageSource.objects.create_package_source_from_zip( + 'test', + self.zip_file, + creator=usr, + safe_name=False + ) + self.assertIsInstance(pkg_src, PackageSource) + pkg = pkg_src.instances.first() + self.assertIn(pkg.key, settings.PACKAGES.keys()) + self.assertEqual(pkg.package['title'], 'zip test pkg') + self.assertEqual(pkg.package['points'], 123) + if usr: + self.assertEqual(len(pkg.permitted_users), 1) + self.assertIn(self.user, pkg.permitted_users) + else: + self.assertEqual(len(pkg.permitted_users), 0) + shutil.rmtree(pkg_src.path) + pkg_src.delete() + except Exception as e: + if pkg_src is not None: + shutil.rmtree(pkg_src.path) + pkg_src.delete() + raise e diff --git a/BaCa2/package/validators.py b/BaCa2/package/validators.py deleted file mode 100644 index 56092bf6..00000000 --- a/BaCa2/package/validators.py +++ /dev/null @@ -1,240 +0,0 @@ -from pathlib import Path -from re import findall, split -from BaCa2.settings import BASE_DIR - - -def isAny(val): - """ - any non-empty value is allowed - - :return: A boolean value. - """ - return bool(val) - - -def isNone(val): - """ - check if val is None - - :return: A boolean value. - """ - return val is None - - -def isInt(val): - """ - check if val can be converted to int - - :return: A boolean value. - """ - if type(val) == float: - return False - try: - int(val) - return True - except ValueError: - return False - - -def isIntBetween(val, a: int, b: int): - """ - check if val is an int value between a and b - - :return: A boolean value. - """ - if isInt(val): - if a <= val < b: - return True - return False - - -def isFloat(val): - """ - check if val can be converted to float - - :return: A boolean value. - """ - try: - float(val) - return True - except ValueError: - return False - - -def isFloatBetween(val, a: int, b: int): - """ - check if val is a float value between a and b - - :return: A boolean value. - """ - if isFloat(val): - if a <= val < b: - return True - return False - - -def isStr(val): - """ - check if val can be converted to string - - :return: A boolean value. - """ - if type(val) == str: - return True - return False - - -def is_(val, schema: str): - """ - check if val is exactly like schema - - :return: A boolean value. - """ - if isStr(val): - return val == schema - return False - - -def isIn(val, *args): - """ - check if val is in args - - :return: A boolean value. - """ - return val in args - - -def isShorter(val, l: int): - """ - check if val is string and has len < len(l) - - :return: A boolean value. - """ - if isStr(val): - return len(val) < l - return False - - -def isDict(val): - """ - check if val has dict type - - :return: A boolean value. - """ - return type(val) == dict - - -def isPath(val): - """ - check if val is path in package_dir - - :return: A boolean value. - """ - if val is None: - return False - try: - val = Path(val) - if val.exists(): - return True - return False - except ValueError: - return False - -def resolve_validator(func_list, arg): - """ - takes the validator function with arguments, and check that if validator function is true for arg (other arguments for func) - """ - func_name = str(func_list[0]) - func_arguments_ext = ',' + ','.join(func_list[1:]) - return eval(func_name + '("' + str(arg) + '"' + func_arguments_ext + ')') - - -def hasStructure(val, struct: str): - """ - check if val has structure provided by struct and fulfills validators functions from struct - - :return: A boolean value. - """ - validators = findall("<.*?>", struct) - validators = [i[1:-1].split(',') for i in validators] - constant_words = findall("[^<>]{0,}<", struct) + findall("[^>]{0,}$", struct) - constant_words = [i.strip("<") for i in constant_words] - if len(validators) == 1: - values_to_check = [val] - else: - # words_in_pattern = [i for i in constant_words if i != '|' and i != ''] - # regex_pattern = '|'.join([i for i in constant_words if i != '|' and i != '']) - values_to_check = split('|'.join([i for i in constant_words if i != '|' and i != '']), val) - if struct.startswith('<') == False: - values_to_check = values_to_check[1:] - valid_idx = 0 - const_w_idx = 0 - values_idx = 0 - temp_alternative = False - result = True - while valid_idx < len(validators) and values_idx < len(values_to_check): - temp_alternative |= resolve_validator(validators[valid_idx], values_to_check[values_idx]) - if constant_words[const_w_idx] == '|': - if constant_words[const_w_idx + 1] != '|': - values_idx += 1 - else: - if constant_words[const_w_idx + 1] != '|': - values_idx += 1 - result &= temp_alternative - temp_alternative = False - valid_idx += 1 - const_w_idx += 1 - return result - - -def memory_converting(val: str): - """ - function is converting memory from others units to bytes - - :return: Memory converted to bytes. (In INT type) - """ - if val[-1] == 'B': - return int(val[0:-1]) - elif val[-1] == 'K': - return int(val[0:-1]) * 1024 - elif val[-1] == 'M': - return int(val[0:-1]) * 1024 * 1024 - elif val[-1] == 'G': - return int(val[0:-1]) * 1024 * 1024 * 1024 - - -def valid_memory_size(first: str, second: str): - """ - checks if first is smaller than second considering memory - - :return: A boolean value. - """ - if memory_converting(first) <= memory_converting(second): - return True - return False - - -def isSize(val: str, max_size: str): - """ - check if val has structure like - - :return: A boolean value. - """ - val = val.strip() - return hasStructure(val[:-2], "") and hasStructure(val[-1], "") and valid_memory_size(val, max_size) - - -def isList(val, *args): - """ - check if val is a list and every element from list fulfill at least one validator from args - - :return: A boolean value. - """ - if type(val) == list: - result = False - for i in val: - for j in args: - result |= hasStructure(i, j) - if not result: - return result - return True \ No newline at end of file diff --git a/BaCa2/package/views.py b/BaCa2/package/views.py index 91ea44a2..60f00ef0 100644 --- a/BaCa2/package/views.py +++ b/BaCa2/package/views.py @@ -1,3 +1 @@ -from django.shortcuts import render - # Create your views here. diff --git a/BaCa2/packages_source/dosko/1/config.yml b/BaCa2/packages_source/dosko/1/config.yml new file mode 100644 index 00000000..21718714 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/config.yml @@ -0,0 +1,5 @@ +title: Liczby Doskonałe + +time_limit: 5 s + +memoryLimit: 256 MB diff --git a/BaCa2/packages_source/dosko/1/doc/doskozad.html b/BaCa2/packages_source/dosko/1/doc/doskozad.html new file mode 100644 index 00000000..6f9405e2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/doc/doskozad.html @@ -0,0 +1,16 @@ +

    Zadanie Liczba Doskonała

    +

    Liczba doskonała to taka liczba, która jest sumą wszystkich swoich dzielników, nie licząc siebie. Przykładem liczby doskonałej jest $6 = 1+2+3$ oraz $28$ czy $496$.

    +

    Napisz program, który sprawdzi, czy zadana liczba jest doskonała.

    +

    Wejście

    +

    Progam przyjmuje jedną liczbę całkowitą dodatnią nie większą niż $10^6$.

    +

    Wyjście

    +

    Program wypisuje TAK jeśli podana liczba jest doskonała, a NIE jeśli nie jest.

    +

    Przykład

    +

    Wejście: +28

    +

    Wyjście: +TAK

    +

    Wejście: +30

    +

    Wyjście: +NIE

    diff --git a/BaCa2/packages_source/dosko/1/doc/doskozad.md b/BaCa2/packages_source/dosko/1/doc/doskozad.md new file mode 100644 index 00000000..079644ba --- /dev/null +++ b/BaCa2/packages_source/dosko/1/doc/doskozad.md @@ -0,0 +1,35 @@ +# Zadanie Liczba Doskonała + +Liczba doskonała to taka liczba, która jest sumą wszystkich swoich dzielników, nie licząc siebie. Przykładem liczby doskonałej jest $6 = 1+2+3$ oraz $28$ czy $496$. + +Napisz program, który sprawdzi, czy zadana liczba jest doskonała. + +## Wejście + +Progam przyjmuje jedną liczbę całkowitą dodatnią nie większą niż $10^6$. + +## Wyjście + +Program wypisuje `TAK` jeśli podana liczba jest doskonała, a `NIE` jeśli nie jest. + +## Przykład + +Wejście: +``` +28 +``` + +Wyjście: +``` +TAK +``` + +Wejście: +``` +30 +``` + +Wyjście: +``` +NIE +``` diff --git a/BaCa2/packages_source/dosko/1/doc/doskozad.pdf b/BaCa2/packages_source/dosko/1/doc/doskozad.pdf new file mode 100644 index 00000000..f70a35c0 Binary files /dev/null and b/BaCa2/packages_source/dosko/1/doc/doskozad.pdf differ diff --git a/BaCa2/packages_source/dosko/1/doc/doskozad.txt b/BaCa2/packages_source/dosko/1/doc/doskozad.txt new file mode 100644 index 00000000..d2029e0f --- /dev/null +++ b/BaCa2/packages_source/dosko/1/doc/doskozad.txt @@ -0,0 +1,19 @@ +Zadanie Liczba Doskonała + +Liczba doskonała to taka liczba, która jest sumą wszystkich swoich dzielników, nie licząc siebie. Przykładem liczby doskonałej jest 6=1+2+3 oraz 28 czy 496. + +Napisz program, który sprawdzi, czy zadana liczba jest doskonała. + +Wejście: +Progam przyjmuje jedną liczbę całkowitą dodatnią nie większą niż 106. + +Wyjście: +Program wypisuje TAK jeśli podana liczba jest doskonała, a NIE jeśli nie jest. + +Przykład: + +Wejście: 28 +Wyjście:TAK + +Wejście: 30 +Wyjście: NIE diff --git a/BaCa2/packages_source/dosko/1/prog/dosko.py b/BaCa2/packages_source/dosko/1/prog/dosko.py new file mode 100644 index 00000000..f78a53dc --- /dev/null +++ b/BaCa2/packages_source/dosko/1/prog/dosko.py @@ -0,0 +1,18 @@ + +def sumadziel(n): + wynik = 0 + for i in range(1, n): + if n%i == 0: + wynik += i + return wynik + +def main(): + n = int(input()) + if n == sumadziel(n): + print('TAK') + else: + print('NIE') + + +if __name__ == '__main__': + main() diff --git a/BaCa2/package/packages/1/tests/set1/config.yml b/BaCa2/packages_source/dosko/1/tests/set0/config.yml similarity index 59% rename from BaCa2/package/packages/1/tests/set1/config.yml rename to BaCa2/packages_source/dosko/1/tests/set0/config.yml index 88be3647..c3268028 100644 --- a/BaCa2/package/packages/1/tests/set1/config.yml +++ b/BaCa2/packages_source/dosko/1/tests/set0/config.yml @@ -1,9 +1,9 @@ checker: null makefile: null -memory_limit: 5M -name: set1 -points: 10 +memory_limit: 512M +name: set0 +points: 0 test_generator: null tests: {} time_limit: 10 -weight: 7 +weight: 10 diff --git a/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.in b/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.in new file mode 100644 index 00000000..9902f178 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.in @@ -0,0 +1 @@ +28 diff --git a/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.out b/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.out new file mode 100644 index 00000000..9250e21e --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set0/dosko0a.out @@ -0,0 +1 @@ +TAK diff --git a/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.in b/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.in new file mode 100644 index 00000000..64bb6b74 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.in @@ -0,0 +1 @@ +30 diff --git a/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.out b/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set0/dosko0b.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/config.yml b/BaCa2/packages_source/dosko/1/tests/set1_simple/config.yml new file mode 100644 index 00000000..907d0219 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/config.yml @@ -0,0 +1,9 @@ +checker: null +makefile: null +memory_limit: 512M +name: set1_simple +points: 0 +test_generator: null +tests: {} +time_limit: 10 +weight: 10 diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.in b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.in new file mode 100644 index 00000000..1e8b3149 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.in @@ -0,0 +1 @@ +6 diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.out b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.out new file mode 100644 index 00000000..9250e21e --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1a.out @@ -0,0 +1 @@ +TAK diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.in b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.in new file mode 100644 index 00000000..7b5813c6 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.in @@ -0,0 +1 @@ +234 diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.out b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1b.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.in b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.in new file mode 100644 index 00000000..68cefe31 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.in @@ -0,0 +1 @@ +457743 diff --git a/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.out b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set1_simple/dosko1c.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set2/config.yml b/BaCa2/packages_source/dosko/1/tests/set2/config.yml new file mode 100644 index 00000000..b8434292 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/config.yml @@ -0,0 +1,9 @@ +checker: null +makefile: null +memory_limit: 512M +name: set2 +points: 0 +test_generator: null +tests: {} +time_limit: 10 +weight: 10 diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.in b/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.in new file mode 100644 index 00000000..9902f178 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.in @@ -0,0 +1 @@ +28 diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.out b/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.out new file mode 100644 index 00000000..9250e21e --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2a.out @@ -0,0 +1 @@ +TAK diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.in b/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.in new file mode 100644 index 00000000..8a5b6ebf --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.in @@ -0,0 +1 @@ +53725 diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.out b/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2b.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.in b/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.in new file mode 100644 index 00000000..3ce947b7 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.in @@ -0,0 +1 @@ +87654 diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.out b/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2c.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.in b/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.in new file mode 100644 index 00000000..97b925a4 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.in @@ -0,0 +1 @@ +567 diff --git a/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.out b/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set2/dosko2d.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set3/config.yml b/BaCa2/packages_source/dosko/1/tests/set3/config.yml new file mode 100644 index 00000000..ff2ec6f5 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/config.yml @@ -0,0 +1,9 @@ +checker: null +makefile: null +memory_limit: 512M +name: set3 +points: 0 +test_generator: null +tests: {} +time_limit: 10 +weight: 10 diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.in b/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.in new file mode 100644 index 00000000..39efca76 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.in @@ -0,0 +1 @@ +496 diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.out b/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.out new file mode 100644 index 00000000..9250e21e --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3a.out @@ -0,0 +1 @@ +TAK diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.in b/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.in new file mode 100644 index 00000000..45b0f829 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.in @@ -0,0 +1 @@ +987 diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.out b/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3b.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.in b/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.in new file mode 100644 index 00000000..7ed6ff82 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.in @@ -0,0 +1 @@ +5 diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.out b/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3c.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.in b/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.in new file mode 100644 index 00000000..48082f72 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.in @@ -0,0 +1 @@ +12 diff --git a/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.out b/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.out new file mode 100644 index 00000000..b40eece2 --- /dev/null +++ b/BaCa2/packages_source/dosko/1/tests/set3/dosko3d.out @@ -0,0 +1 @@ +NIE diff --git a/BaCa2/packages_source/kolejka/commit1/2.in b/BaCa2/packages_source/kolejka/commit1/2.in new file mode 100644 index 00000000..d0403c07 --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/2.in @@ -0,0 +1 @@ +../../../tests/set0/2.in diff --git a/BaCa2/packages_source/kolejka/commit1/2.out b/BaCa2/packages_source/kolejka/commit1/2.out new file mode 100644 index 00000000..0e59e930 --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/2.out @@ -0,0 +1 @@ +../../../tests/set0/2.out diff --git a/BaCa2/packages_source/kolejka/commit1/3.in b/BaCa2/packages_source/kolejka/commit1/3.in new file mode 100644 index 00000000..46f09cca --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/3.in @@ -0,0 +1 @@ +../../../tests/set0/3.in diff --git a/BaCa2/packages_source/kolejka/commit1/3.out b/BaCa2/packages_source/kolejka/commit1/3.out new file mode 100644 index 00000000..1e6831aa --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/3.out @@ -0,0 +1 @@ +../../../tests/set0/3.out diff --git a/BaCa2/package/packages/1/tests/set1/1.in b/BaCa2/packages_source/kolejka/commit1/config.yml similarity index 100% rename from BaCa2/package/packages/1/tests/set1/1.in rename to BaCa2/packages_source/kolejka/commit1/config.yml diff --git a/BaCa2/packages_source/kolejka/commit1/test.yaml b/BaCa2/packages_source/kolejka/commit1/test.yaml new file mode 100644 index 00000000..dcc7fb2b --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/test.yaml @@ -0,0 +1,9 @@ +!include : ../common/test.yaml +cpp_standard : c++17 +gcc_arguments : '-pthread -O2 -static' +compile_time : 30s +compile_memory : 512MB +source_size : 100KB +binary_size : 10MB +output_size : 1GB +error_size : 1MB diff --git a/BaCa2/packages_source/kolejka/commit1/tests.yaml b/BaCa2/packages_source/kolejka/commit1/tests.yaml new file mode 100644 index 00000000..21903ef6 --- /dev/null +++ b/BaCa2/packages_source/kolejka/commit1/tests.yaml @@ -0,0 +1,35 @@ +test2: + !include : test.yaml + #generator : !file generator.cpp + # ./generator > file.in < [input] + #hinter : !file hinter.cpp + # ./hinter > file.out < file.in + #checker : !file checker.cpp + # ./checker input hint answer ## Check return code + #environment : !file makefile + memory: 8M + time: 8s + input: !file 2.in + hint: !file 2.out + cpp_standard : c++17 + gcc_arguments : '-pthread -O2 -static' + compile_time : 30s + compile_memory : 512MB + source_size : 100KB + binary_size : 10MB + output_size : 1GB + error_size : 1MB +test3: + !include : test.yaml + time: 9s + memory: 6M + input: !file 3.in + hint: !file 3.out + cpp_standard : c++17 + gcc_arguments : '-pthread -O2 -static' + compile_time : 30s + compile_memory : 512MB + source_size : 100KB + binary_size : 10MB + output_size : 1GB + error_size : 1MB diff --git a/BaCa2/packages_source/kolejka/common/judge.py b/BaCa2/packages_source/kolejka/common/judge.py new file mode 100644 index 00000000..59cbf520 --- /dev/null +++ b/BaCa2/packages_source/kolejka/common/judge.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +# vim:ts=4:sts=4:sw=4:expandtab +import os +import sys + +if __name__ == '__main__': + sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), 'kolejka-judge')) + from kolejka.judge import main + main(__file__) +from kolejka.judge.commands import * +from kolejka.judge.parse import * +from kolejka.judge.tasks import * + + +def judge(args): + tool_time = parse_time('60s') + prepare_time = parse_time('5s') + source_size_limit = parse_memory(args.test.get('source_size', '100K')) + binary_size_limit = parse_memory(args.test.get('binary_size', '10M')) + compile_time = parse_time(args.test.get('compile_time', '10s')) + compile_memory = parse_memory(args.test.get('compile_memory', '1G')) + c_standard = args.test.get('c_standard', 'c11') + cpp_standard = args.test.get('cpp_standard', 'c++17') + gcc_arguments = [ arg.strip() for arg in args.test.get('gcc_arguments', '').split() if arg.strip() ] + time_limit = parse_time(args.test.get('time', '10s')) + memory_limit = parse_memory(args.test.get('memory', '1G')) + output_size_limit = parse_memory(args.test.get('output_size', '1G')) + error_size_limit = parse_memory(args.test.get('error_size', '1M')) + basename = args.test.get('basename', None) + args.add_steps( + system=SystemPrepareTask(default_logs=False), + source=SolutionPrepareTask(source=args.solution, basename=basename, allow_extract=True, override=args.test.get('environment', None), limit_real_time=prepare_time), + source_rules=SolutionSourceRulesTask(max_size=source_size_limit), + builder=SolutionBuildAutoTask([ + [SolutionBuildCMakeTask, [], {}], + [SolutionBuildMakeTask, [], {}], + [SolutionBuildGXXTask, [], {'standard': cpp_standard, 'build_arguments': gcc_arguments}], + [SolutionBuildGCCTask, [], {'standard': c_standard, 'build_arguments': gcc_arguments, 'libraries': ['m']}], + [SolutionBuildPython3ScriptTask, [], {}], + ], limit_real_time=compile_time, limit_memory=compile_memory), + build_rules=SolutionBuildRulesTask(max_size=binary_size_limit), + ) + args.add_steps(io=SingleIOTask( + input_path=args.test.get('input', None), + tool_override=args.test.get('tools', None), + tool_time=tool_time, + tool_c_standard=c_standard, + tool_cpp_standard=cpp_standard, + tool_gcc_arguments=gcc_arguments, + generator_source=args.test.get('generator', None), + verifier_source=args.test.get('verifier', None), + hint_path=args.test.get('hint', None), + hinter_source=args.test.get('hinter', None), + checker_source=args.test.get('checker', None), + limit_cores=1, + limit_time=time_limit, + limit_memory=memory_limit, + limit_output_size=output_size_limit, + limit_error_size=error_size_limit, + ) + ) + if parse_bool(args.test.get('debug', 'no')): + args.add_steps(debug=CollectDebugTask()) + args.add_steps(logs=CollectLogsTask()) + result = args.run() + print('Result {} on test {}.'.format(result.status, args.id)) diff --git a/BaCa2/packages_source/kolejka/common/test.yaml b/BaCa2/packages_source/kolejka/common/test.yaml new file mode 100644 index 00000000..2a39b7b1 --- /dev/null +++ b/BaCa2/packages_source/kolejka/common/test.yaml @@ -0,0 +1,25 @@ +memory: 512MB +kolejka: + image : 'kolejka/satori:extended' + exclusive : false + requires : [ 'cpu:xeon e3-1270 v5' ] + collect : [ 'log.zip' ] + limits: + time : '20s' + memory : '4G' + swap : 0 + cpus : 4 + network : false + pids : 256 + storage : '2G' + workspace : '2G' + satori: + result: + execute_time_real : '/io/executor/run/real_time' + execute_time_cpu : '/io/executor/run/cpu_time' + execute_memory : '/io/executor/run/memory' + compile_log : 'str:/builder/**/stdout,/builder/**/stderr' + tool_log : 'str:/io/generator/**/stderr,/io/verifier/**/stdout,/io/verifier/**/stderr,/io/hinter/**/stderr' + checker_log : 'str:/io/checker/**/stdout,/io/checker/**/stderr' + logs : '/logs/logs' + debug : '/debug/debug' diff --git a/BaCa2/packages_source/kolejka/run_example.sh b/BaCa2/packages_source/kolejka/run_example.sh new file mode 100644 index 00000000..467ca148 --- /dev/null +++ b/BaCa2/packages_source/kolejka/run_example.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +#curl -s -L https://kolejka.matinf.uj.edu.pl/kolejka-judge -o common/kolejka-judge +#curl -s -L https://kolejka.matinf.uj.edu.pl/kolejka-client -o common/kolejka-client + +for s in 1_1_set0; do + rm -rf "${s}.task" + rm -rf "${s}.result" + python3 common/kolejka-judge task common/judge.py "${s}"/tests.yaml ../../prog/solution.cpp "${s}.task" + python3 common/kolejka-client --config-file kolejka.conf execute "${s}.task" "${s}.result" +done diff --git a/BaCa2/packages_source/test_pkg.zip b/BaCa2/packages_source/test_pkg.zip new file mode 100644 index 00000000..912f94fe Binary files /dev/null and b/BaCa2/packages_source/test_pkg.zip differ diff --git a/BaCa2/scratch.py b/BaCa2/scratch.py new file mode 100644 index 00000000..319272a9 --- /dev/null +++ b/BaCa2/scratch.py @@ -0,0 +1,63 @@ +import os +import sys + +import django + +# sys.path.insert(0, os.path.abspath('.')) +os.environ['DJANGO_SETTINGS_MODULE'] = 'core.settings' +django.setup() + +from datetime import datetime, timedelta +from time import sleep + +from broker_api.models import BrokerSubmit +from core.settings import SUBMITS_DIR +from course.manager import create_course, delete_course +from course.models import Round, Submit, Task +from course.routing import InCourse +from main.models import Course, User +from package.models import PackageInstance + +course = Course(name='course1', short_name='c1', db_name='course1_db') +course.save() +create_course(course.name) + +pkg_instance = PackageInstance.objects.create_package_instance('dosko', '1') +pkg_instance.save() + +user = User.objects.create_user(username=f'user1_{datetime.now().timestamp()}', + password='user1', + email=f'test{datetime.now().timestamp()}@test.pl') + +with InCourse(course.name): + round = Round.objects.create(start_date=datetime.now(), + deadline_date=datetime.now() + timedelta(days=1), + reveal_date=datetime.now() + timedelta(days=2)) + round.save() + + task = Task.create_new( + task_name='Liczby doskonałe', + package_instance=pkg_instance, + round=round, + points=10, + ) + task.save() + +# src_code = SUBMITS_DIR / '1234.cpp' +src_code = SUBMITS_DIR / 'dosko.py' +src_code = src_code.absolute() + +submit_id = None +with InCourse(course.name): + submit = Submit.create_new(source_code=src_code, task=task, usr=user) + submit.pk = datetime.now().timestamp() + submit.save() + submit_id = submit.pk + +broker_submit = BrokerSubmit.send(course, submit_id, pkg_instance) + +while broker_submit.status != BrokerSubmit.StatusEnum.SAVED: + sleep(1) + broker_submit.refresh_from_db() + +delete_course('course1') diff --git a/BaCa2/submits/1234.cpp b/BaCa2/submits/1234.cpp new file mode 100644 index 00000000..60fd1209 --- /dev/null +++ b/BaCa2/submits/1234.cpp @@ -0,0 +1,5 @@ +#include + +int main(){ + std::cout << "Hello World!" << std::endl; +} diff --git a/BaCa2/templates/account/account_inactive.html b/BaCa2/templates/account/account_inactive.html deleted file mode 100644 index 3347f4fd..00000000 --- a/BaCa2/templates/account/account_inactive.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Account Inactive" %}{% endblock %} - -{% block content %} -

    {% trans "Account Inactive" %}

    - -

    {% trans "This account is inactive." %}

    -{% endblock %} diff --git a/BaCa2/templates/account/base.html b/BaCa2/templates/account/base.html deleted file mode 100644 index cf58393a..00000000 --- a/BaCa2/templates/account/base.html +++ /dev/null @@ -1,42 +0,0 @@ - - - - - - {% block head_title %}{% endblock %} - {% block extra_head %} - {% endblock %} - - - {% block body %} - - {% if messages %} -
    - Messages: -
      - {% for message in messages %} -
    • {{message}}
    • - {% endfor %} -
    -
    - {% endif %} - -
    - {% block content %} - {% endblock %} - {% endblock %} - {% block extra_body %} - {% endblock %} - - diff --git a/BaCa2/templates/account/email.html b/BaCa2/templates/account/email.html deleted file mode 100644 index 373cd669..00000000 --- a/BaCa2/templates/account/email.html +++ /dev/null @@ -1,74 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "E-mail Addresses" %}{% endblock %} - -{% block content %} -

    {% trans "E-mail Addresses" %}

    -{% if user.emailaddress_set.all %} -

    {% trans 'The following e-mail addresses are associated with your account:' %}

    - - - -{% else %} -

    {% trans 'Warning:'%} {% trans "You currently do not have any e-mail address set up. You should really add an e-mail address so you can receive notifications, reset your password, etc." %}

    - -{% endif %} - - {% if can_add_email %} -

    {% trans "Add E-mail Address" %}

    - -
    - {% csrf_token %} - {{ form.as_p }} - -
    - {% endif %} - -{% endblock %} - - -{% block extra_body %} - -{% endblock %} diff --git a/BaCa2/templates/account/email/account_already_exists_message.txt b/BaCa2/templates/account/email/account_already_exists_message.txt deleted file mode 100644 index e2733a73..00000000 --- a/BaCa2/templates/account/email/account_already_exists_message.txt +++ /dev/null @@ -1,13 +0,0 @@ -{% extends "account/email/base_message.txt" %} -{% load i18n %} - -{% block content %}{% autoescape off %}{% blocktrans %}You are receiving this e-mail because you or someone else tried to signup for an -account using e-mail address: - -{{ email }} - -However, an account using that e-mail address already exists. In case you have -forgotten about this, please use the password forgotten procedure to recover -your account: - -{{ password_reset_url }}{% endblocktrans %}{% endautoescape %}{% endblock %} diff --git a/BaCa2/templates/account/email/account_already_exists_subject.txt b/BaCa2/templates/account/email/account_already_exists_subject.txt deleted file mode 100644 index 481edb0c..00000000 --- a/BaCa2/templates/account/email/account_already_exists_subject.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktrans %}Account Already Exists{% endblocktrans %} -{% endautoescape %} diff --git a/BaCa2/templates/account/email/base_message.txt b/BaCa2/templates/account/email/base_message.txt deleted file mode 100644 index 46f04f34..00000000 --- a/BaCa2/templates/account/email/base_message.txt +++ /dev/null @@ -1,7 +0,0 @@ -{% load i18n %}{% autoescape off %}{% blocktrans with site_name=current_site.name %}Hello from {{ site_name }}!{% endblocktrans %} - -{% block content %}{% endblock %} - -{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}Thank you for using {{ site_name }}! -{{ site_domain }}{% endblocktrans %} -{% endautoescape %} diff --git a/BaCa2/templates/account/email/email_confirmation_message.txt b/BaCa2/templates/account/email/email_confirmation_message.txt deleted file mode 100644 index 7f922d87..00000000 --- a/BaCa2/templates/account/email/email_confirmation_message.txt +++ /dev/null @@ -1,7 +0,0 @@ -{% extends "account/email/base_message.txt" %} -{% load account %} -{% load i18n %} - -{% block content %}{% autoescape off %}{% user_display user as user_display %}{% blocktrans with site_name=current_site.name site_domain=current_site.domain %}You're receiving this e-mail because user {{ user_display }} has given your e-mail address to register an account on {{ site_domain }}. - -To confirm this is correct, go to {{ activate_url }}{% endblocktrans %}{% endautoescape %}{% endblock %} diff --git a/BaCa2/templates/account/email/email_confirmation_signup_message.txt b/BaCa2/templates/account/email/email_confirmation_signup_message.txt deleted file mode 100644 index 9996f7e5..00000000 --- a/BaCa2/templates/account/email/email_confirmation_signup_message.txt +++ /dev/null @@ -1 +0,0 @@ -{% include "account/email/email_confirmation_message.txt" %} diff --git a/BaCa2/templates/account/email/email_confirmation_signup_subject.txt b/BaCa2/templates/account/email/email_confirmation_signup_subject.txt deleted file mode 100644 index 4c85ebb9..00000000 --- a/BaCa2/templates/account/email/email_confirmation_signup_subject.txt +++ /dev/null @@ -1 +0,0 @@ -{% include "account/email/email_confirmation_subject.txt" %} diff --git a/BaCa2/templates/account/email/email_confirmation_subject.txt b/BaCa2/templates/account/email/email_confirmation_subject.txt deleted file mode 100644 index b0a876f5..00000000 --- a/BaCa2/templates/account/email/email_confirmation_subject.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktrans %}Please Confirm Your E-mail Address{% endblocktrans %} -{% endautoescape %} diff --git a/BaCa2/templates/account/email/password_reset_key_message.txt b/BaCa2/templates/account/email/password_reset_key_message.txt deleted file mode 100644 index 5871c1e6..00000000 --- a/BaCa2/templates/account/email/password_reset_key_message.txt +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "account/email/base_message.txt" %} -{% load i18n %} - -{% block content %}{% autoescape off %}{% blocktrans %}You're receiving this e-mail because you or someone else has requested a password for your user account. -It can be safely ignored if you did not request a password reset. Click the link below to reset your password.{% endblocktrans %} - -{{ password_reset_url }}{% if username %} - -{% blocktrans %}In case you forgot, your username is {{ username }}.{% endblocktrans %}{% endif %}{% endautoescape %}{% endblock %} diff --git a/BaCa2/templates/account/email/password_reset_key_subject.txt b/BaCa2/templates/account/email/password_reset_key_subject.txt deleted file mode 100644 index 6840c40b..00000000 --- a/BaCa2/templates/account/email/password_reset_key_subject.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktrans %}Password Reset E-mail{% endblocktrans %} -{% endautoescape %} diff --git a/BaCa2/templates/account/email/unknown_account_message.txt b/BaCa2/templates/account/email/unknown_account_message.txt deleted file mode 100644 index e4e89d01..00000000 --- a/BaCa2/templates/account/email/unknown_account_message.txt +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "account/email/base_message.txt" %} -{% load i18n %} - -{% block content %}{% autoescape off %}{% blocktrans %}You are receiving this e-mail because you or someone else has requested a -password for your user account. However, we do not have any record of a user -with email {{ email }} in our database. - -This mail can be safely ignored if you did not request a password reset. - -If it was you, you can sign up for an account using the link below.{% endblocktrans %} - -{{ signup_url }}{% endautoescape %}{% endblock %} diff --git a/BaCa2/templates/account/email/unknown_account_subject.txt b/BaCa2/templates/account/email/unknown_account_subject.txt deleted file mode 100644 index 6840c40b..00000000 --- a/BaCa2/templates/account/email/unknown_account_subject.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load i18n %} -{% autoescape off %} -{% blocktrans %}Password Reset E-mail{% endblocktrans %} -{% endautoescape %} diff --git a/BaCa2/templates/account/email_confirm.html b/BaCa2/templates/account/email_confirm.html deleted file mode 100644 index ac0891b5..00000000 --- a/BaCa2/templates/account/email_confirm.html +++ /dev/null @@ -1,31 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% load account %} - -{% block head_title %}{% trans "Confirm E-mail Address" %}{% endblock %} - - -{% block content %} -

    {% trans "Confirm E-mail Address" %}

    - -{% if confirmation %} - -{% user_display confirmation.email_address.user as user_display %} - -

    {% blocktrans with confirmation.email_address.email as email %}Please confirm that {{ email }} is an e-mail address for user {{ user_display }}.{% endblocktrans %}

    - -
    -{% csrf_token %} - -
    - -{% else %} - -{% url 'account_email' as email_url %} - -

    {% blocktrans %}This e-mail confirmation link expired or is invalid. Please issue a new e-mail confirmation request.{% endblocktrans %}

    - -{% endif %} - -{% endblock %} diff --git a/BaCa2/templates/account/login.html b/BaCa2/templates/account/login.html deleted file mode 100644 index 17bc3a6a..00000000 --- a/BaCa2/templates/account/login.html +++ /dev/null @@ -1,46 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% load account socialaccount %} - -{% block head_title %}{% trans "Sign In" %}{% endblock %} - -{% block content %} - -

    {% trans "Sign In" %}

    - -{% get_providers as socialaccount_providers %} - -{% if socialaccount_providers %} -

    {% blocktrans with site.name as site_name %}Please sign in with one -of your existing third party accounts. Or, sign up -for a {{ site_name }} account and sign in below:{% endblocktrans %}

    - -
    - -
      - {% include "socialaccount/snippets/provider_list.html" with process="login" %} -
    - - - -
    - -{% include "socialaccount/snippets/login_extra.html" %} - -{% else %} -

    {% blocktrans %}If you have not created an account yet, then please -sign up first.{% endblocktrans %}

    -{% endif %} - - - -{% endblock %} diff --git a/BaCa2/templates/account/logout.html b/BaCa2/templates/account/logout.html deleted file mode 100644 index 2549a901..00000000 --- a/BaCa2/templates/account/logout.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Sign Out" %}{% endblock %} - -{% block content %} -

    {% trans "Sign Out" %}

    - -

    {% trans 'Are you sure you want to sign out?' %}

    - -
    - {% csrf_token %} - {% if redirect_field_value %} - - {% endif %} - -
    - - -{% endblock %} diff --git a/BaCa2/templates/account/messages/cannot_delete_primary_email.txt b/BaCa2/templates/account/messages/cannot_delete_primary_email.txt deleted file mode 100644 index de555712..00000000 --- a/BaCa2/templates/account/messages/cannot_delete_primary_email.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}You cannot remove your primary e-mail address ({{email}}).{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/email_confirmation_sent.txt b/BaCa2/templates/account/messages/email_confirmation_sent.txt deleted file mode 100644 index 7a526f8b..00000000 --- a/BaCa2/templates/account/messages/email_confirmation_sent.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Confirmation e-mail sent to {{email}}.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/email_confirmed.txt b/BaCa2/templates/account/messages/email_confirmed.txt deleted file mode 100644 index 3427a4d8..00000000 --- a/BaCa2/templates/account/messages/email_confirmed.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}You have confirmed {{email}}.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/email_deleted.txt b/BaCa2/templates/account/messages/email_deleted.txt deleted file mode 100644 index 5cf7cf91..00000000 --- a/BaCa2/templates/account/messages/email_deleted.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Removed e-mail address {{email}}.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/logged_in.txt b/BaCa2/templates/account/messages/logged_in.txt deleted file mode 100644 index f49248a7..00000000 --- a/BaCa2/templates/account/messages/logged_in.txt +++ /dev/null @@ -1,4 +0,0 @@ -{% load account %} -{% load i18n %} -{% user_display user as name %} -{% blocktrans %}Successfully signed in as {{name}}.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/logged_out.txt b/BaCa2/templates/account/messages/logged_out.txt deleted file mode 100644 index 2cd4627d..00000000 --- a/BaCa2/templates/account/messages/logged_out.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}You have signed out.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/password_changed.txt b/BaCa2/templates/account/messages/password_changed.txt deleted file mode 100644 index bd5801c4..00000000 --- a/BaCa2/templates/account/messages/password_changed.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Password successfully changed.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/password_set.txt b/BaCa2/templates/account/messages/password_set.txt deleted file mode 100644 index 9d224ee0..00000000 --- a/BaCa2/templates/account/messages/password_set.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Password successfully set.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/primary_email_set.txt b/BaCa2/templates/account/messages/primary_email_set.txt deleted file mode 100644 index b6a70dd6..00000000 --- a/BaCa2/templates/account/messages/primary_email_set.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Primary e-mail address set.{% endblocktrans %} diff --git a/BaCa2/templates/account/messages/unverified_primary_email.txt b/BaCa2/templates/account/messages/unverified_primary_email.txt deleted file mode 100644 index 9c9d0d87..00000000 --- a/BaCa2/templates/account/messages/unverified_primary_email.txt +++ /dev/null @@ -1,2 +0,0 @@ -{% load i18n %} -{% blocktrans %}Your primary e-mail address must be verified.{% endblocktrans %} diff --git a/BaCa2/templates/account/password_change.html b/BaCa2/templates/account/password_change.html deleted file mode 100644 index 108ccede..00000000 --- a/BaCa2/templates/account/password_change.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Change Password" %}{% endblock %} - -{% block content %} -

    {% trans "Change Password" %}

    - -
    - {% csrf_token %} - {{ form.as_p }} - - {% trans "Forgot Password?" %} -
    -{% endblock %} diff --git a/BaCa2/templates/account/password_reset.html b/BaCa2/templates/account/password_reset.html deleted file mode 100644 index de23d9eb..00000000 --- a/BaCa2/templates/account/password_reset.html +++ /dev/null @@ -1,24 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% load account %} - -{% block head_title %}{% trans "Password Reset" %}{% endblock %} - -{% block content %} - -

    {% trans "Password Reset" %}

    - {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} - {% endif %} - -

    {% trans "Forgotten your password? Enter your e-mail address below, and we'll send you an e-mail allowing you to reset it." %}

    - -
    - {% csrf_token %} - {{ form.as_p }} - -
    - -

    {% blocktrans %}Please contact us if you have any trouble resetting your password.{% endblocktrans %}

    -{% endblock %} diff --git a/BaCa2/templates/account/password_reset_done.html b/BaCa2/templates/account/password_reset_done.html deleted file mode 100644 index d947d79f..00000000 --- a/BaCa2/templates/account/password_reset_done.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% load account %} - -{% block head_title %}{% trans "Password Reset" %}{% endblock %} - -{% block content %} -

    {% trans "Password Reset" %}

    - - {% if user.is_authenticated %} - {% include "account/snippets/already_logged_in.html" %} - {% endif %} - -

    {% blocktrans %}We have sent you an e-mail. If you have not received it please check your spam folder. Otherwise contact us if you do not receive it in a few minutes.{% endblocktrans %}

    -{% endblock %} diff --git a/BaCa2/templates/account/password_reset_from_key.html b/BaCa2/templates/account/password_reset_from_key.html deleted file mode 100644 index 7da153b5..00000000 --- a/BaCa2/templates/account/password_reset_from_key.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% block head_title %}{% trans "Change Password" %}{% endblock %} - -{% block content %} -

    {% if token_fail %}{% trans "Bad Token" %}{% else %}{% trans "Change Password" %}{% endif %}

    - - {% if token_fail %} - {% url 'account_reset_password' as passwd_reset_url %} -

    {% blocktrans %}The password reset link was invalid, possibly because it has already been used. Please request a new password reset.{% endblocktrans %}

    - {% else %} -
    - {% csrf_token %} - {{ form.as_p }} - -
    - {% endif %} -{% endblock %} diff --git a/BaCa2/templates/account/password_reset_from_key_done.html b/BaCa2/templates/account/password_reset_from_key_done.html deleted file mode 100644 index 85641c2e..00000000 --- a/BaCa2/templates/account/password_reset_from_key_done.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} -{% block head_title %}{% trans "Change Password" %}{% endblock %} - -{% block content %} -

    {% trans "Change Password" %}

    -

    {% trans 'Your password is now changed.' %}

    -{% endblock %} diff --git a/BaCa2/templates/account/password_set.html b/BaCa2/templates/account/password_set.html deleted file mode 100644 index f5615720..00000000 --- a/BaCa2/templates/account/password_set.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Set Password" %}{% endblock %} - -{% block content %} -

    {% trans "Set Password" %}

    - -
    - {% csrf_token %} - {{ form.as_p }} - -
    -{% endblock %} diff --git a/BaCa2/templates/account/signup.html b/BaCa2/templates/account/signup.html deleted file mode 100644 index 8b53b442..00000000 --- a/BaCa2/templates/account/signup.html +++ /dev/null @@ -1,21 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Signup" %}{% endblock %} - -{% block content %} -

    {% trans "Sign Up" %}

    - -

    {% blocktrans %}Already have an account? Then please sign in.{% endblocktrans %}

    - - - -{% endblock %} diff --git a/BaCa2/templates/account/signup_closed.html b/BaCa2/templates/account/signup_closed.html deleted file mode 100644 index bc839506..00000000 --- a/BaCa2/templates/account/signup_closed.html +++ /dev/null @@ -1,11 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Sign Up Closed" %}{% endblock %} - -{% block content %} -

    {% trans "Sign Up Closed" %}

    - -

    {% trans "We are sorry, but the sign up is currently closed." %}

    -{% endblock %} diff --git a/BaCa2/templates/account/snippets/already_logged_in.html b/BaCa2/templates/account/snippets/already_logged_in.html deleted file mode 100644 index 00799f00..00000000 --- a/BaCa2/templates/account/snippets/already_logged_in.html +++ /dev/null @@ -1,5 +0,0 @@ -{% load i18n %} -{% load account %} - -{% user_display user as user_display %} -

    {% trans "Note" %}: {% blocktrans %}you are already logged in as {{ user_display }}.{% endblocktrans %}

    diff --git a/BaCa2/templates/account/verification_sent.html b/BaCa2/templates/account/verification_sent.html deleted file mode 100644 index 3b2d7e57..00000000 --- a/BaCa2/templates/account/verification_sent.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} - -{% block content %} -

    {% trans "Verify Your E-mail Address" %}

    - -

    {% blocktrans %}We have sent an e-mail to you for verification. Follow the link provided to finalize the signup process. If you do not see the verification e-mail in your main inbox, check your spam folder. Please contact us if you do not receive the verification e-mail within a few minutes.{% endblocktrans %}

    - -{% endblock %} diff --git a/BaCa2/templates/account/verified_email_required.html b/BaCa2/templates/account/verified_email_required.html deleted file mode 100644 index d8e5378d..00000000 --- a/BaCa2/templates/account/verified_email_required.html +++ /dev/null @@ -1,23 +0,0 @@ -{% extends "account/base.html" %} - -{% load i18n %} - -{% block head_title %}{% trans "Verify Your E-mail Address" %}{% endblock %} - -{% block content %} -

    {% trans "Verify Your E-mail Address" %}

    - -{% url 'account_email' as email_url %} - -

    {% blocktrans %}This part of the site requires us to verify that -you are who you claim to be. For this purpose, we require that you -verify ownership of your e-mail address. {% endblocktrans %}

    - -

    {% blocktrans %}We have sent an e-mail to you for -verification. Please click on the link inside that e-mail. If you do not see the verification e-mail in your main inbox, check your spam folder. Otherwise -contact us if you do not receive it within a few minutes.{% endblocktrans %}

    - -

    {% blocktrans %}Note: you can still change your e-mail address.{% endblocktrans %}

    - - -{% endblock %} diff --git a/BaCa2/templates/admin.html b/BaCa2/templates/admin.html new file mode 100644 index 00000000..6ff1f706 --- /dev/null +++ b/BaCa2/templates/admin.html @@ -0,0 +1,38 @@ +{% extends 'base.html' %} +{% load static %} + +{% block content %} +
    +
    + {% with widgets.TableWidget.courses_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.FormWidget.create_course_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.FormWidget.create_user_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.TableWidget.users_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    +
    +
    + Packages +
    +
    +{% endblock %} diff --git a/BaCa2/templates/base.html b/BaCa2/templates/base.html index 566549bd..9ca8a749 100644 --- a/BaCa2/templates/base.html +++ b/BaCa2/templates/base.html @@ -1,10 +1,221 @@ +{% load static %} + - Title + + + {{ page_title }} + {% block title %} + {% endblock %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% comment %} + + {% endcomment %} + + + + + + + + + + + + + + + + + + + + + + + {% block head %} + {% endblock %} + - + + + + + +{% if display_navbar %} + {% with widgets.NavBar.navbar as navbar_widget %} + {% include 'widget_templates/navbar.html' %} + {% endwith %} +{% endif %} + +
    + {% if display_navbar %} + + {% endif %} +
    + + + +{% block initializers %} +{% endblock %} + + + +{% block scripts %} +{% endblock %} + + + +{% block version_footer %} + +{% endblock %} - \ No newline at end of file + diff --git a/BaCa2/templates/course.html b/BaCa2/templates/course.html new file mode 100644 index 00000000..156ae5c5 --- /dev/null +++ b/BaCa2/templates/course.html @@ -0,0 +1,5 @@ +{% extends 'base.html' %} + +{% block content %} + PLACEHOLDER +{% endblock %} diff --git a/BaCa2/templates/course_admin.html b/BaCa2/templates/course_admin.html new file mode 100644 index 00000000..fefd328a --- /dev/null +++ b/BaCa2/templates/course_admin.html @@ -0,0 +1,103 @@ +{% extends 'base.html' %} + +{% block content %} + {% if view_members_tab %} +
    +
    + {% with widgets.TableWidget.members_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if add_member_tab %} +
    +
    + {% with widgets.FormWidget.add_member_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if add_members_csv_tab %} +
    +
    + {% with widgets.FormWidget.add_members_from_csv_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if remove_members_tab %} +
    +
    + {% with widgets.FormWidget.remove_members_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if view_roles_tab %} +
    +
    + {% with widgets.TableWidget.roles_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if add_role_tab %} +
    +
    + {% with widgets.FormWidget.add_role_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if view_rounds_tab %} +
    +
    + {% with widgets.TableWidget.rounds_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if add_round_tab %} +
    +
    + {% with widgets.FormWidget.create_round_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if view_tasks_tab %} +
    +
    + {% with widgets.TableWidget.tasks_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if add_task_tab %} +
    +
    + {% with widgets.FormWidget.create_task_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if results_tab %} +
    +
    + {% with widgets.TableWidget.results_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} +{% endblock %} diff --git a/BaCa2/templates/course_edit_round.html b/BaCa2/templates/course_edit_round.html new file mode 100644 index 00000000..9fb03db7 --- /dev/null +++ b/BaCa2/templates/course_edit_round.html @@ -0,0 +1,13 @@ +{% extends "base.html" %} + +{% block content %} + {% for r in rounds %} +
    +
    + {% with r.round_edit_form as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endfor %} +{% endblock %} diff --git a/BaCa2/templates/course_role.html b/BaCa2/templates/course_role.html new file mode 100644 index 00000000..461e08ac --- /dev/null +++ b/BaCa2/templates/course_role.html @@ -0,0 +1,32 @@ +{% extends 'base.html' %} + +{% block content %} +
    +
    + {% with widgets.TableWidget.permissions_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.TableWidget.members_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.FormWidget.add_role_permissions_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    +
    +
    + {% with widgets.FormWidget.remove_role_permissions_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    +{% endblock %} diff --git a/BaCa2/templates/course_submit_summary.html b/BaCa2/templates/course_submit_summary.html new file mode 100644 index 00000000..4aa2a71f --- /dev/null +++ b/BaCa2/templates/course_submit_summary.html @@ -0,0 +1,47 @@ +{% extends "base.html" %} + +{% block content %} + {% if display_sidenav %} +
    +
    + {% endif %} +
    + {% with widgets.TableWidget.summary_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    + + {% for s in sets %} +
    + {% with s.table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    + {% endfor %} + {% if display_sidenav %} +
    +
    + {% endif %} + +
    +
    + {% with widgets.CodeBlock.source_code_block as codeblock %} + {% include "widget_templates/code_block.html" %} + {% endwith %} +
    +
    + + {% if display_test_summaries %} + {% for s in sets %} +
    +
    + {% for test_summary in s.tests %} + {% with test_summary as brief_result_summary %} + {% include "widget_templates/brief_result_summary.html" %} + {% endwith %} + {% endfor %} +
    +
    + {% endfor %} + {% endif %} +{% endblock %} diff --git a/BaCa2/templates/course_task.html b/BaCa2/templates/course_task.html new file mode 100644 index 00000000..15901b77 --- /dev/null +++ b/BaCa2/templates/course_task.html @@ -0,0 +1,58 @@ +{% extends "base.html" %} +{% load i18n %} + +{% block content %} +
    +
    + {% with widgets.TextDisplayer.description as displayer %} + {% include "widget_templates/text_displayer.html" %} + {% endwith %} + {% if attachments %} +

    {% trans "Attachments" %}

    +
    + {% for attachment in attachments %} +
    + {% include 'widget_templates/attachment.html' %} +
    + {% endfor %} +
    + {% endif %} +
    +
    + {% if edit_tab %} +
    +
    + {% with widgets.FormWidget.simple_edit_task_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if reupload_tab %} +
    +
    + {% with widgets.FormWidget.reupload_task_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if submit_tab %} +
    +
    + {% with widgets.FormWidget.create_submit_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% endif %} + {% if results_tab %} +
    +
    + {% with widgets.TableWidget.results_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    + {% endif %} +{% endblock %} diff --git a/BaCa2/templates/course_task_admin.html b/BaCa2/templates/course_task_admin.html new file mode 100644 index 00000000..a9dd8698 --- /dev/null +++ b/BaCa2/templates/course_task_admin.html @@ -0,0 +1,21 @@ +{% extends "base.html" %} + +{% block content %} +
    +
    + Task description +
    +
    +
    +
    + {% with widgets.TableWidget.submissions_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    +
    +
    +
    + Edit task +
    +
    +{% endblock %} diff --git a/BaCa2/templates/courses.html b/BaCa2/templates/courses.html new file mode 100644 index 00000000..841ffaf5 --- /dev/null +++ b/BaCa2/templates/courses.html @@ -0,0 +1,7 @@ +{% extends 'base.html' %} + +{% block content %} + {% with widgets.TableWidget.courses_table_widget as table_widget %} + {% include 'widget_templates/listing/table.html' %} + {% endwith %} +{% endblock %} diff --git a/BaCa2/templates/dashboard.html b/BaCa2/templates/dashboard.html new file mode 100644 index 00000000..357a3992 --- /dev/null +++ b/BaCa2/templates/dashboard.html @@ -0,0 +1,4 @@ +{% extends 'base.html' %} + +{% block content %} +{% endblock %} diff --git a/BaCa2/templates/development_info.html b/BaCa2/templates/development_info.html new file mode 100644 index 00000000..6ee47ed2 --- /dev/null +++ b/BaCa2/templates/development_info.html @@ -0,0 +1,37 @@ +{% extends 'base.html' %} + +{% block content %} +

    Cześć {{ user_first_name }},

    +

    Witaj w dostępie beta aplikacji BaCa2. W tym semestrze aplikacja będzie przechodzić dużą + metamorfozę, do której możesz się przyczynić. W ramach kursów Metody Numeryczne oraz + Advanced and Modern C++ Programming możesz przetestować działanie nowej "bacy".

    +

    Wszelkie uwagi i błędy, jakie pojawią się w trakcie Twojego korzystania z aplikacji, prosimy, + abyś zgłaszał je przez + zakładkę "Issues" naszego repozytorium na GitHub lub przez wiadomość e-mail: + baca2@ii.uj.edu.pl. Zgłoszone błędy pozwolą nam + sprawniej rozwinąć + aplikację

    + +

    Ważne informacje

    +

    W tym semestrze planujemy przejście z wersji 1.0-beta do wersji 2.0-release. + Nowe zmiany i + opisy naprawianych błędów będą publikowane w zakładce "Nowości". Na tej stronie za jakiś + czas pojawi się pulpit nawigacyjny, pozwalający szybciej poruszać się po serwisie.

    +

    Jako, że proces produkcji jest w toku, co jakiś czas zdarzać się będą przerwy techniczne + aplikacji. Stałym terminem, zarezerwowanym na aktualizację jest środa w godzinach + 21:00-22:00. Dodatkowe terminy będziemy publikować na bieżąco.

    + +

    Wsparcie rozwoju aplikacji

    +

    Jeśli chcesz wesprzeć rozwój aplikacji, jest ku temu kilka możliwości:

    +
      +
    • Korzystanie z serwisu, pomimo potencjalnych trudności - im większa jest baza + użytkowników w okresie próbnym, tym lepiej jesteśmy w stanie przetestować działanie + aplikacji pod obciążeniem +
    • +
    • Testowanie aplikacji i zgłaszanie błędów - jesteśmy otwarci na próby "zepsucia" naszej + aplikacji. Jeżeli Ci się to uda, zachęcamy, aby napisać do nas maila z opisem sytuacji, + która doprowadziła do awarii. Osoby, które znajdą takie błędy, dopiszemy do testerów + aplikacji w finalnej wersji. +
    • +
    +{% endblock %} diff --git a/BaCa2/templates/icons/3-lines.svg b/BaCa2/templates/icons/3-lines.svg new file mode 100644 index 00000000..70d29637 --- /dev/null +++ b/BaCa2/templates/icons/3-lines.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/BaCa2/templates/icons/bell.svg b/BaCa2/templates/icons/bell.svg new file mode 100644 index 00000000..2acbdc24 --- /dev/null +++ b/BaCa2/templates/icons/bell.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/BaCa2/templates/icons/moon.svg b/BaCa2/templates/icons/moon.svg new file mode 100644 index 00000000..131a7eb1 --- /dev/null +++ b/BaCa2/templates/icons/moon.svg @@ -0,0 +1,6 @@ + + + + diff --git a/BaCa2/templates/icons/pencil.svg b/BaCa2/templates/icons/pencil.svg new file mode 100644 index 00000000..d9074011 --- /dev/null +++ b/BaCa2/templates/icons/pencil.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/BaCa2/templates/icons/refresh.svg b/BaCa2/templates/icons/refresh.svg new file mode 100644 index 00000000..b061e233 --- /dev/null +++ b/BaCa2/templates/icons/refresh.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/BaCa2/templates/icons/sun.svg b/BaCa2/templates/icons/sun.svg new file mode 100644 index 00000000..a4dae68a --- /dev/null +++ b/BaCa2/templates/icons/sun.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + diff --git a/BaCa2/templates/icons/trash.svg b/BaCa2/templates/icons/trash.svg new file mode 100644 index 00000000..ca9eadc3 --- /dev/null +++ b/BaCa2/templates/icons/trash.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/BaCa2/templates/icons/university.svg b/BaCa2/templates/icons/university.svg new file mode 100644 index 00000000..324415b9 --- /dev/null +++ b/BaCa2/templates/icons/university.svg @@ -0,0 +1,7 @@ + + + + + diff --git a/BaCa2/templates/icons/user-share.svg b/BaCa2/templates/icons/user-share.svg new file mode 100644 index 00000000..8d6d32c3 --- /dev/null +++ b/BaCa2/templates/icons/user-share.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/BaCa2/templates/icons/user.svg b/BaCa2/templates/icons/user.svg new file mode 100644 index 00000000..5daf606b --- /dev/null +++ b/BaCa2/templates/icons/user.svg @@ -0,0 +1,8 @@ + + + + + + diff --git a/BaCa2/templates/login.html b/BaCa2/templates/login.html new file mode 100644 index 00000000..99d817a1 --- /dev/null +++ b/BaCa2/templates/login.html @@ -0,0 +1,67 @@ +{% extends 'base.html' %} +{% load static %} + +{% block head %} + +{% endblock %} + +{% block content %} +
    +
    +
    + {% include "util/baca2_logo.html" %} +
    +
    +
    +
    +
    +
    +

    Zaloguj się

    + {% with widgets.FormWidget.login_form as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} + +
    + {% include 'icons/user-share.svg' %} + Użytkownik zewnętrzny +
    +
    +
    +
    lub
    + +
    +
    + Nie masz konta? Zarejestruj się +
    +
    +
    +
    +
    + {% include "util/theme_switch.html" %} +
    +
    +
    +{% endblock %} + +{% block initializers %} + +{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/BaCa2/templates/mail/add_to_course.html b/BaCa2/templates/mail/add_to_course.html new file mode 100644 index 00000000..1995e022 --- /dev/null +++ b/BaCa2/templates/mail/add_to_course.html @@ -0,0 +1,10 @@ +{% extends "mail/base.html" %} +{% block content %} +

    Hi!

    +

    We kindly inform You, that You have been just registered to the course {{ course_name }} on + BaCa2 system.

    +

    If You believe, that it was a mistake, please contact us: + baca2@ii.uj.edu.pl

    + +

    System link: BaCa2

    +{% endblock %} diff --git a/BaCa2/templates/mail/add_to_course_pl.html b/BaCa2/templates/mail/add_to_course_pl.html new file mode 100644 index 00000000..aa87f594 --- /dev/null +++ b/BaCa2/templates/mail/add_to_course_pl.html @@ -0,0 +1,9 @@ +{% extends "mail/base.html" %} +{% block content %} +

    Cześć!

    +

    Zostałeś właśnie dopisany do kursu {{ course_name }} w systemie BaCa2.

    +

    Jeśli uważasz, że to pomyłka, skontaktuj się z administratorem systemu pod adresem e-mail: + baca2@ii.uj.edu.pl

    + +

    Link do systemu: BaCa2

    +{% endblock %} diff --git a/BaCa2/templates/mail/base.html b/BaCa2/templates/mail/base.html new file mode 100644 index 00000000..3969971f --- /dev/null +++ b/BaCa2/templates/mail/base.html @@ -0,0 +1,18 @@ + + + + + + {{% block title %}{% endblock %} + + +{% block content %}{% endblock %} +{% block footer %} + {% if footer_note %} +
    +

    {{ footer_note }}

    +
    + {% endif %} +{% endblock %} + + diff --git a/BaCa2/templates/mail/new_account.html b/BaCa2/templates/mail/new_account.html new file mode 100644 index 00000000..40a7d39c --- /dev/null +++ b/BaCa2/templates/mail/new_account.html @@ -0,0 +1,14 @@ +{% extends "mail/base.html" %} +{% block content %} +

    Welcome to BaCa2!

    +

    + You have been successfully registered to BaCa2. Here are your credentials: +

    +

    + Username: {{ email }}
    + Password: {{ password }} +

    +

    + It is strongly recommended to change your password after your first login. +

    +{% endblock %} diff --git a/BaCa2/templates/profile.html b/BaCa2/templates/profile.html new file mode 100644 index 00000000..7dea4d7c --- /dev/null +++ b/BaCa2/templates/profile.html @@ -0,0 +1,32 @@ +{% extends "base.html" %} +{% block content %} +
    +
    +
    + {% with widgets.TableWidget.personal_data_table_widget as table_widget %} + {% include "widget_templates/listing/table.html" %} + {% endwith %} +
    + {% with widgets.FormWidget.change_personal_data_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    + {% if not change_password_header %} +
    +
    +
    +
    +

    {{ change_password_title }}

    +
    +
    + {% with widgets.FormWidget.change_password_form_widget as form_widget %} + {% include "widget_templates/forms/default.html" %} + {% endwith %} +
    +
    +
    +
    + {% endif %} + +{% endblock %} diff --git a/BaCa2/templates/task_edit.html b/BaCa2/templates/task_edit.html new file mode 100644 index 00000000..7a601925 --- /dev/null +++ b/BaCa2/templates/task_edit.html @@ -0,0 +1,7 @@ +{% extends "base.html" %} + +{% block content %} + {% with widgets.FormWidget.edit_task_form_widget as form_widget %} + {% include "widget_templates/forms/task_edit_form.html" %} + {% endwith %} +{% endblock %} diff --git a/BaCa2/templates/util/baca2_logo.html b/BaCa2/templates/util/baca2_logo.html new file mode 100644 index 00000000..5be066b6 --- /dev/null +++ b/BaCa2/templates/util/baca2_logo.html @@ -0,0 +1,82 @@ + + + + + diff --git a/BaCa2/templates/util/theme_button.html b/BaCa2/templates/util/theme_button.html new file mode 100644 index 00000000..c76a0e44 --- /dev/null +++ b/BaCa2/templates/util/theme_button.html @@ -0,0 +1,18 @@ +{% load static %} + + +
    + {% csrf_token %} + +
    diff --git a/BaCa2/templates/util/theme_switch.html b/BaCa2/templates/util/theme_switch.html new file mode 100644 index 00000000..198e23f5 --- /dev/null +++ b/BaCa2/templates/util/theme_switch.html @@ -0,0 +1,34 @@ +{% load static %} + + +
    +
    +
    +
    + {% include "icons/moon.svg" %} +
    +
    +
    +
    + {% csrf_token %} +
    + +
    +
    +
    +
    +
    + {% include "icons/sun.svg" %} +
    +
    +
    +
    diff --git a/BaCa2/templates/widget_templates/attachment.html b/BaCa2/templates/widget_templates/attachment.html new file mode 100644 index 00000000..a0bfebfb --- /dev/null +++ b/BaCa2/templates/widget_templates/attachment.html @@ -0,0 +1,8 @@ +{% load static %} + + + {{ attachment.title }} + diff --git a/BaCa2/templates/widget_templates/brief_result_summary.html b/BaCa2/templates/widget_templates/brief_result_summary.html new file mode 100644 index 00000000..b41d26e3 --- /dev/null +++ b/BaCa2/templates/widget_templates/brief_result_summary.html @@ -0,0 +1,92 @@ +{% load static %} +{% load i18n %} + +
    +
    +
    {{ brief_result_summary.set_name }}/{{ brief_result_summary.test_name }}
    +
    +
    +
    +
    +
    + {% trans "Status:" %} + {{ brief_result_summary.result.f_status }} +
    + {% if brief_result_summary.result.f_time_real %} +
    + {% trans "Time used:" %} + {{ brief_result_summary.result.f_time_real }} +
    + {% endif %} + {% if brief_result_summary.result.f_runtime_memory %} +
    + {% trans "Memory used:" %} + {{ brief_result_summary.result.f_runtime_memory }} +
    + {% endif %} +
    +
    +
    + + {% with brief_result_summary.result as result %} + {% if result.logs_present %} + + +
    + + {% if result.compile_log_widget %} +
    + {% with result.compile_log_widget as codeblock %} + {% include "widget_templates/code_block.html" %} + {% endwith %} +
    + {% endif %} + + {% if result.checker_log_widget %} +
    + {% with result.checker_log_widget as codeblock %} + {% include "widget_templates/code_block.html" %} + {% endwith %} +
    + {% endif %} + +
    + {% endif %} + {% endwith %} +
    diff --git a/BaCa2/templates/widget_templates/code_block.html b/BaCa2/templates/widget_templates/code_block.html new file mode 100644 index 00000000..067adc9e --- /dev/null +++ b/BaCa2/templates/widget_templates/code_block.html @@ -0,0 +1,21 @@ +{% if codeblock.display_wrapper %} +
    + {% if codeblock.title %} +
    +
    {{ codeblock.title }}
    +
    + {% endif %} +
    +{% endif %} +
    +        
    +            {{ codeblock.code }}
    +        
    +
    +{% if codeblock.display_wrapper %} +
    +
    +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/base.html b/BaCa2/templates/widget_templates/forms/base.html new file mode 100644 index 00000000..9a75b983 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/base.html @@ -0,0 +1,51 @@ +
    +
    + {% csrf_token %} + + {% if form_widget.display_non_field_errors %} + {% if form_widget.form.non_field_errors %} + + {% endif %} + {% endif %} + + {% block form_header %} + {% endblock %} + + {% block form_fields %} + {% endblock %} + + {% block form_footer %} + {% endblock %} +
    + + {% with form_widget.submit_confirmation_popup as popup_widget %} + {% if popup_widget %} + {% include 'widget_templates/popups/submit_confirmation.html' %} + {% endif %} + {% endwith %} + + {% with form_widget.submit_success_popup as popup_widget %} + {% if popup_widget %} + {% include 'widget_templates/popups/submit_success.html' %} + {% endif %} + {% endwith %} + + {% with form_widget.submit_failure_popup as popup_widget %} + {% if popup_widget %} + {% include 'widget_templates/popups/submit_failure.html' %} + {% endif %} + {% endwith %} +
    diff --git a/BaCa2/templates/widget_templates/forms/choice_field.html b/BaCa2/templates/widget_templates/forms/choice_field.html new file mode 100644 index 00000000..64906b5e --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/choice_field.html @@ -0,0 +1,20 @@ +{% extends 'widget_templates/forms/choice_field_base.html' %} + +{% block field %} + {% if form_widget.floating_labels %} +
    + {% else %} + {% include 'widget_templates/forms/field_label.html' %} + {% endif %} + + {% include 'widget_templates/forms/field_render.html' %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% if form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} +
    + {% endif %} +{% endblock %} diff --git a/BaCa2/templates/widget_templates/forms/choice_field_base.html b/BaCa2/templates/widget_templates/forms/choice_field_base.html new file mode 100644 index 00000000..29b2846b --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/choice_field_base.html @@ -0,0 +1,21 @@ +{% load custom_tags %} + +
    +
    + {% block field %} + {% endblock %} +
    + + {% include 'widget_templates/forms/field_errors.html' %} +
    + +{% if form_widget.live_validation %} + +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/datetime_field.html b/BaCa2/templates/widget_templates/forms/datetime_field.html new file mode 100644 index 00000000..4e13b31d --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/datetime_field.html @@ -0,0 +1,70 @@ +{% load static %} +{% load custom_tags %} + +
    +
    + + {% if not form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} + {% endif %} + +
    +
    + +
    + + {% if form_widget.floating_labels %} +
    + {% endif %} + + {% include 'widget_templates/forms/field_render.html' %} + + {% if form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} +
    + {% endif %} +
    + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
    + + {% include 'widget_templates/forms/field_errors.html' %} +
    + + + +{% if form_widget.live_validation and field.field.widget.input_type != "password" %} + +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/default.html b/BaCa2/templates/widget_templates/forms/default.html new file mode 100644 index 00000000..25269fe0 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/default.html @@ -0,0 +1,25 @@ +{% extends 'widget_templates/forms/base.html' %} + +{% block form_header %} + {% if form_widget.form_observer %} + {% with form_widget.form_observer as form_observer %} + {% include 'widget_templates/forms/form_observer.html' %} + {% endwith %} + {% endif %} +{% endblock %} + +{% block form_fields %} + {% for hidden_field in form_widget.form.hidden_fields %} + {% if not hidden_field.field.widget.attrs.class == 'table-select-field' %} + {{ hidden_field.errors }} + {{ hidden_field }} + {% endif %} + {% endfor %} + {% with form_widget.elements as element_group %} + {% include 'widget_templates/forms/element_group.html' %} + {% endwith %} +{% endblock %} + +{% block form_footer %} + {% include 'widget_templates/forms/footer.html' %} +{% endblock %} diff --git a/BaCa2/templates/widget_templates/forms/element_group.html b/BaCa2/templates/widget_templates/forms/element_group.html new file mode 100644 index 00000000..7f0163d4 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/element_group.html @@ -0,0 +1,67 @@ +{% load custom_tags %} + +
    + {% if element_group.frame %} +
    + {% if element_group.display_title %} +
    +
    {{ element_group.title }}
    +
    + {% endif %} +
    + {% endif %} + {% if not element_group.frame and element_group.display_title %} +
    +
    +
    {{ element_group.title }}
    +
    + {% endif %} + {% if element_group.toggleable %} +
    + +
    + {% endif %} + {% if not element_group.frame and element_group.display_title %} +
    + {% endif %} + {% if element_group.layout == 'horizontal' %} +
    + {% endif %} + {% for element in element_group.elements %} + {% if element_group.layout == 'horizontal' %} +
    + {% endif %} + {% if element|is_instance_of:"widgets.forms.base.FormElementGroup" %} + {% with element as element_group %} + {% include "widget_templates/forms/element_group.html" %} + {% endwith %} + {% else %} + {% with form_widget.form|get_form_field:element as field %} + {% include "widget_templates/forms/field.html" %} + {% endwith %} + {% endif %} + {% if element_group.layout == 'horizontal' %} +
    + {% endif %} + {% endfor %} + {% if element_group.layout == 'horizontal' %} +
    + {% endif %} + {% if element_group.frame %} +
    +
    + {% endif %} +
    diff --git a/BaCa2/templates/widget_templates/forms/field.html b/BaCa2/templates/widget_templates/forms/field.html new file mode 100644 index 00000000..11a3564e --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/field.html @@ -0,0 +1,15 @@ +{% with field.field.special_field_type as special_type %} + {% if special_type == 'choice' %} + {% include 'widget_templates/forms/choice_field.html' %} + {% elif special_type == 'model_choice' %} + {% include 'widget_templates/forms/model_choice_field.html' %} + {% elif special_type == 'table_select' %} + {% include 'widget_templates/forms/table_select_field.html' %} + {% elif special_type == 'datetime' %} + {% include 'widget_templates/forms/datetime_field.html' %} + {% elif field.field.widget.input_type == 'file' %} + {% include 'widget_templates/forms/file_upload_field.html' %} + {% elif not field.is_hidden %} + {% include 'widget_templates/forms/text_field.html' %} + {% endif %} +{% endwith %} diff --git a/BaCa2/templates/widget_templates/forms/field_errors.html b/BaCa2/templates/widget_templates/forms/field_errors.html new file mode 100644 index 00000000..2aacb857 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/field_errors.html @@ -0,0 +1,9 @@ +{% if form_widget.display_field_errors and form_widget.form.is_bound %} + {% if field.errors %} + {% for error in field.errors %} +
    + {{ error }} +
    + {% endfor %} + {% endif %} +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/field_label.html b/BaCa2/templates/widget_templates/forms/field_label.html new file mode 100644 index 00000000..6dfc4d45 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/field_label.html @@ -0,0 +1,4 @@ + diff --git a/BaCa2/templates/widget_templates/forms/field_render.html b/BaCa2/templates/widget_templates/forms/field_render.html new file mode 100644 index 00000000..6dec48f7 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/field_render.html @@ -0,0 +1,13 @@ +{% load widget_tweaks %} + +{% with form_widget.live_validation|yesno:"true,false" as live_validation %} +{% with field.field.widget.input_type as field_type %} + {% if not field_class %} + {% with field.field.widget.attrs.class as field_class %} + {% render_field field class=field_class id=field.name placeholder="-" type=field_type data-live-validation=live_validation %} + {% endwith %} + {% else %} + {% render_field field class=field_class id=field.name placeholder="-" type=field_type data-live-validation=live_validation %} + {% endif %} +{% endwith %} +{% endwith %} diff --git a/BaCa2/templates/widget_templates/forms/file_upload_field.html b/BaCa2/templates/widget_templates/forms/file_upload_field.html new file mode 100644 index 00000000..121ab9dc --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/file_upload_field.html @@ -0,0 +1,30 @@ +{% load custom_tags %} + +
    +
    + {% include 'widget_templates/forms/field_label.html' %} + + {% include 'widget_templates/forms/form_control_field.html' %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +
    + + {% include 'widget_templates/forms/field_errors.html' %} +
    + +{% if form_widget.live_validation %} + +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/footer.html b/BaCa2/templates/widget_templates/forms/footer.html new file mode 100644 index 00000000..1c9736da --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/footer.html @@ -0,0 +1,30 @@ + diff --git a/BaCa2/templates/widget_templates/forms/form_control_field.html b/BaCa2/templates/widget_templates/forms/form_control_field.html new file mode 100644 index 00000000..ffd05267 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/form_control_field.html @@ -0,0 +1,15 @@ +{% if form_widget.display_field_errors and form_widget.form.is_bound %} + {% if field.errors %} + {% with "form-control is-invalid" as field_class %} + {% include "widget_templates/forms/field_render.html" %} + {% endwith %} + {% else %} + {% with "form-control is-invalid" as field_class %} + {% include "widget_templates/forms/field_render.html" %} + {% endwith %} + {% endif %} +{% else %} + {% with "form-control" as field_class %} + {% include "widget_templates/forms/field_render.html" %} + {% endwith %} +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/form_observer.html b/BaCa2/templates/widget_templates/forms/form_observer.html new file mode 100644 index 00000000..e0a2d81e --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/form_observer.html @@ -0,0 +1,51 @@ +
    + {% if form_observer.tabs %} + + {% endif %} +
    + {% if form_observer.tabs %} +
    +
    + {% endif %} +
    +
    +
    +
    + {% if form_observer.tabs %} +
    + {% for tab in form_observer.tabs %} +
    +
    +
    +
    + {% endfor %} +
    + {% endif %} +
    +
    diff --git a/BaCa2/templates/widget_templates/forms/model_choice_field.html b/BaCa2/templates/widget_templates/forms/model_choice_field.html new file mode 100644 index 00000000..26ecab0c --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/model_choice_field.html @@ -0,0 +1,30 @@ +{% extends 'widget_templates/forms/choice_field_base.html' %} + +{% block field %} + {% if not form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} + {% endif %} + +
    +
    + + + +
    + + {% if form_widget.floating_labels %} +
    + {% endif %} + + {% include 'widget_templates/forms/field_render.html' %} + + {% if form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} +
    + {% endif %} +
    + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} +{% endblock %} diff --git a/BaCa2/templates/widget_templates/forms/table_select_field.html b/BaCa2/templates/widget_templates/forms/table_select_field.html new file mode 100644 index 00000000..d1a89931 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/table_select_field.html @@ -0,0 +1,36 @@ +{% load widget_tweaks %} +{% load custom_tags %} + +
    +
    + {% include 'widget_templates/forms/field_render.html' %} +
    + + {% with field.field.table_widget as table_widget %} + {% include 'widget_templates/listing/table.html' %} + {% endwith %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% include 'widget_templates/forms/field_errors.html' %} +
    + +{% if form_widget.live_validation %} + +{% endif %} diff --git a/BaCa2/templates/widget_templates/forms/task_edit_form.html b/BaCa2/templates/widget_templates/forms/task_edit_form.html new file mode 100644 index 00000000..5007993f --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/task_edit_form.html @@ -0,0 +1,35 @@ +{% extends "widget_templates/forms/base.html" %} +{% load custom_tags %} + +{% block form_header %} + {% if form_widget.form_observer %} +
    + {% with form_widget.form_observer as form_observer %} + {% include 'widget_templates/forms/form_observer.html' %} + {% endwith %} +
    + {% endif %} +{% endblock %} + +{% block form_fields %} + {% for hidden_field in form_widget.form.hidden_fields %} + {% if not hidden_field.field.widget.attrs.class == 'table-select-field' %} + {{ hidden_field.errors }} + {{ hidden_field }} + {% endif %} + {% endfor %} + + {% for element_group in form_widget.elements.elements %} +
    +
    + {% include "widget_templates/forms/element_group.html" %} +
    +
    + {% endfor %} +{% endblock %} + +{% block form_footer %} +
    + {% include "widget_templates/forms/footer.html" %} +
    +{% endblock %} diff --git a/BaCa2/templates/widget_templates/forms/text_field.html b/BaCa2/templates/widget_templates/forms/text_field.html new file mode 100644 index 00000000..f3d72b78 --- /dev/null +++ b/BaCa2/templates/widget_templates/forms/text_field.html @@ -0,0 +1,63 @@ +{% load widget_tweaks %} +{% load custom_tags %} + +
    +
    + {% if form_widget.floating_labels %} +
    + {% else %} + {% include 'widget_templates/forms/field_label.html' %} + {% endif %} + + {% include 'widget_templates/forms/form_control_field.html' %} + + {% if field.help_text %} + {{ field.help_text }} + {% endif %} + + {% if form_widget.floating_labels %} + {% include 'widget_templates/forms/field_label.html' %} +
    + {% endif %} + + {% if field.name in form_widget.toggleable_fields %} + {% with button_params=form_widget.toggleable_params|get_item:field.name %} + + {% endwith %} + {% endif %} +
    + + {% include 'widget_templates/forms/field_errors.html' %} +
    + +{% if form_widget.live_validation and field.field.widget.input_type != "password" %} + +{% endif %} diff --git a/BaCa2/templates/widget_templates/listing/column_header.html b/BaCa2/templates/widget_templates/listing/column_header.html new file mode 100644 index 00000000..6d444d7a --- /dev/null +++ b/BaCa2/templates/widget_templates/listing/column_header.html @@ -0,0 +1,18 @@ +{% load bootstrap_icons %} + +{% if col.header_icon and col.header %} +
    +
    + {% bs_icon col.header_icon %} +
    +
    + {{ col.header }} +
    +
    +{% elif col.header_icon %} +
    + {% bs_icon col.header_icon extra_classes='icon-header' %} +
    +{% else %} + {{ col.header }} +{% endif %} diff --git a/BaCa2/templates/widget_templates/listing/form_submit_column.html b/BaCa2/templates/widget_templates/listing/form_submit_column.html new file mode 100644 index 00000000..a5b13fe1 --- /dev/null +++ b/BaCa2/templates/widget_templates/listing/form_submit_column.html @@ -0,0 +1,12 @@ + +
    + {% with col.form_widget as form_widget %} + {% include 'widget_templates/forms/default.html' %} + {% endwith %} +
    + {% include 'widget_templates/listing/column_header.html' %} + diff --git a/BaCa2/templates/widget_templates/listing/table.html b/BaCa2/templates/widget_templates/listing/table.html new file mode 100644 index 00000000..a1354a62 --- /dev/null +++ b/BaCa2/templates/widget_templates/listing/table.html @@ -0,0 +1,135 @@ +{% load custom_tags %} +{% load i18n %} + +
    + +
    + {% if table_widget.allow_delete %} +
    + {% with table_widget.delete_record_form_widget as form_widget %} + {% include 'widget_templates/forms/default.html' %} + {% endwith %} +
    + {% endif %} +
    + +
    + + {% if table_widget.display_util_header %} +
    +
    + {% if table_widget.display_title %} +
    +

    {{ table_widget.title }}

    +
    + {% endif %} +
    +
    + {% if table_widget.allow_global_search %} +
    + +
    + {% endif %} + {% if table_widget.paging.allow_length_change == 'true' %} +
    +
    +
    +
    + {% endif %} + {% if table_widget.table_buttons %} +
    +
    + {% if table_widget.refresh_button %} +
    + +
    + {% endif %} + {% if table_widget.delete_button %} +
    + +
    + {% endif %} +
    +
    + {% endif %} +
    +
    +
    +
    + +
    + {% endif %} + +
    + {% if table_widget.resizable_height %} +
    + {% endif %} +
    + + + + {% for col in table_widget.cols %} + {% include col.template %} + {% endfor %} + + +
    +
    + {% if table_widget.resizable_height %} +
    + {% endif %} +
    + + {% if table_widget.resizable_height %} +
    + + {% trans "resize" %} + +
    + {% endif %} +
    +
    + + + diff --git a/BaCa2/templates/widget_templates/listing/text_column.html b/BaCa2/templates/widget_templates/listing/text_column.html new file mode 100644 index 00000000..5632ea40 --- /dev/null +++ b/BaCa2/templates/widget_templates/listing/text_column.html @@ -0,0 +1,23 @@ +{% load bootstrap_icons %} + + + {% if col.searchable == "true" and col.search_header %} + {% if col.header_icon %} +
    + + {% bs_icon col.header_icon %} + + {% endif %} + + {% if col.header_icon %} +
    + {% endif %} + {% else %} + {% include 'widget_templates/listing/column_header.html' %} + {% endif %} + diff --git a/BaCa2/templates/widget_templates/markup_displayer.html b/BaCa2/templates/widget_templates/markup_displayer.html new file mode 100644 index 00000000..0a7aecf4 --- /dev/null +++ b/BaCa2/templates/widget_templates/markup_displayer.html @@ -0,0 +1,29 @@ +{% load static %} +{% load i18n %} + + + +
    + {% if displayer.pdf_download %} + + {% endif %} +
    + {{ displayer.content|safe }} +
    +
    diff --git a/BaCa2/templates/widget_templates/navbar.html b/BaCa2/templates/widget_templates/navbar.html new file mode 100644 index 00000000..70645d8a --- /dev/null +++ b/BaCa2/templates/widget_templates/navbar.html @@ -0,0 +1,68 @@ +{% load static %} + + diff --git a/BaCa2/templates/widget_templates/pdf_displayer.html b/BaCa2/templates/widget_templates/pdf_displayer.html new file mode 100644 index 00000000..ea0e558a --- /dev/null +++ b/BaCa2/templates/widget_templates/pdf_displayer.html @@ -0,0 +1,49 @@ +{% load static %} +{% load i18n %} + +
    +
    +
    + +
    +
    +
    + +
    +
    + +
    +
    +
    +

    {% trans "Page" %}

    +
    +
    + +
    +
    +

    {% trans "of" %}

    + 1 +
    +
    +
    +
    +
    +
    +
    + +
    +
    + + diff --git a/BaCa2/templates/widget_templates/popups/base.html b/BaCa2/templates/widget_templates/popups/base.html new file mode 100644 index 00000000..2953c96c --- /dev/null +++ b/BaCa2/templates/widget_templates/popups/base.html @@ -0,0 +1,23 @@ + diff --git a/BaCa2/templates/widget_templates/popups/submit_confirmation.html b/BaCa2/templates/widget_templates/popups/submit_confirmation.html new file mode 100644 index 00000000..85b4dd2b --- /dev/null +++ b/BaCa2/templates/widget_templates/popups/submit_confirmation.html @@ -0,0 +1,45 @@ +{% extends 'widget_templates/popups/base.html' %} + +{% block popup_header %} + + +{% endblock %} + +{% block popup_body %} + + {% if popup_widget.input_summary %} + + {% endif %} +{% endblock %} + +{% block popup_footer %} +
    +
    + +
    +
    + +
    +
    +{% endblock %} diff --git a/BaCa2/templates/widget_templates/popups/submit_failure.html b/BaCa2/templates/widget_templates/popups/submit_failure.html new file mode 100644 index 00000000..cd8bd93f --- /dev/null +++ b/BaCa2/templates/widget_templates/popups/submit_failure.html @@ -0,0 +1,36 @@ +{% extends 'widget_templates/popups/base.html' %} + +{% block popup_header %} +
    + +
    +{% endblock %} + +{% block popup_body %} + +{% endblock %} + +{% block popup_footer %} + +{% endblock %} diff --git a/BaCa2/templates/widget_templates/popups/submit_success.html b/BaCa2/templates/widget_templates/popups/submit_success.html new file mode 100644 index 00000000..b8132991 --- /dev/null +++ b/BaCa2/templates/widget_templates/popups/submit_success.html @@ -0,0 +1,33 @@ +{% extends 'widget_templates/popups/base.html' %} + +{% block popup_header %} +
    + +
    +{% endblock %} + +{% block popup_body %} + +{% endblock %} + +{% block popup_footer %} + +{% endblock %} diff --git a/BaCa2/templates/widget_templates/sidenav.html b/BaCa2/templates/widget_templates/sidenav.html new file mode 100644 index 00000000..f964174a --- /dev/null +++ b/BaCa2/templates/widget_templates/sidenav.html @@ -0,0 +1,47 @@ +{% load static %} +
    + + {% with sidenav_widget.toggle_button as button %} + {% if button.on %} +
    + +
    + {% endif %} + {% endwith %} +
    diff --git a/BaCa2/templates/widget_templates/text_displayer.html b/BaCa2/templates/widget_templates/text_displayer.html new file mode 100644 index 00000000..2dfbd830 --- /dev/null +++ b/BaCa2/templates/widget_templates/text_displayer.html @@ -0,0 +1,11 @@ +{% if displayer.displayer_type == 'markup' %} + {% with displayer.displayer as displayer %} + {% include 'widget_templates/markup_displayer.html' %} + {% endwith %} +{% elif displayer.displayer_type == 'pdf' %} + {% with displayer.displayer as displayer %} + {% include 'widget_templates/pdf_displayer.html' %} + {% endwith %} +{% else %} + {{ displayer.displayer.message }} +{% endif %} \ No newline at end of file diff --git a/BaCa2/util/__init__.py b/BaCa2/util/__init__.py new file mode 100644 index 00000000..38681d1d --- /dev/null +++ b/BaCa2/util/__init__.py @@ -0,0 +1 @@ +from .other import * diff --git a/BaCa2/util/context_processors.py b/BaCa2/util/context_processors.py new file mode 100644 index 00000000..588aa877 --- /dev/null +++ b/BaCa2/util/context_processors.py @@ -0,0 +1,5 @@ +from importlib.metadata import version + + +def version_tag(request): + return {'version': version('baca2')} diff --git a/BaCa2/util/models.py b/BaCa2/util/models.py new file mode 100644 index 00000000..3e7028eb --- /dev/null +++ b/BaCa2/util/models.py @@ -0,0 +1,112 @@ +from importlib import import_module +from typing import Dict, List, Type, TypeVar, Union + +from django.contrib.auth.models import ContentType, Group, Permission +from django.db import models +from django.db.models.query import QuerySet + +from core.choices import BasicModelAction + +model_cls = TypeVar('model_cls', bound=Type[models.Model]) + + +def get_all_permissions_for_model(model: model_cls) -> Union[QuerySet, List[Permission]]: + """ + Returns all permissions for given model. + + :param model: Model to get permissions for. + :type model: Type[models.Model] + + :return: List of permissions for given model. + :rtype: QuerySet[Permission] + """ + return Permission.objects.filter( + content_type=ContentType.objects.get_for_model(model).id + ) + + +def get_all_models_from_app(app_label: str) -> Dict[str, model_cls]: + """ + Returns all models from given app. App has to have the `__all__` variable defined in models.py. + + :param app_label: Name of the app. + :type app_label: str + + :return: Dictionary of models from given app. Keys are model names, values are model classes. + :rtype: Dict[str, Type[models.Model]] + """ + module = import_module(f'{app_label}.models') + return {model: getattr(module, model) for model in module.__all__} + + +def get_model_permission_by_label(model: model_cls, perm_label: str) -> Permission: + """ + Returns permission object for a given model and permission label. + + :param model: Model to get permission for. + :type model: Type[models.Model] + :param perm_label: Permission label (e.g. 'add', 'change', 'view', 'delete'). + :type perm_label: str + + :return: Permission object with codename equal to '`label`' + '_' + '`model name`'. + :rtype: Permission + """ + return Permission.objects.get(codename=f'{perm_label}_{model._meta.model_name}') + + +def get_model_permissions( + model: model_cls, + permissions: BasicModelAction | List[BasicModelAction] = 'all' +) -> QuerySet[Permission]: + """ + Returns QuerySet of basic permissions objects for given model. If permissions is set to 'all' + (default), all basic permissions for given model are returned, otherwise only specified + permissions are returned. + + Basic permissions are the permissions automatically created by Django for each model (add, + change, view, delete). + + :param model: Model to get permissions for. + :type model: Type[models.Model] + :param permissions: List of permissions to get. If set to 'all' (default), all basic permissions + are returned. Can be set to a list of BasicPermissionAction or a single BasicModelAction. + :type permissions: BasicModelAction | List[BasicPermissionType] + + :return: QuerySet of basic permissions for given model. + :rtype: QuerySet[Permission] + """ + if permissions == 'all': + permissions = [p for p in BasicModelAction] + elif isinstance(permissions, BasicModelAction): + permissions = [f'{permissions.label}_{model._meta.model_name}'] + elif isinstance(permissions, List): + permissions = [f'{p.label}_{model._meta.model_name}' for p in permissions] + + return Permission.objects.filter(codename__in=permissions) + + +def delete_populated_group(group: Group) -> None: + """ + Deletes a group along with all its user and permission assignments (does not delete the users or + permissions themselves). + + :param group: Group to delete. + :type group: Group + """ + + group.user_set.clear() + group.permissions.clear() + group.delete(using='default') + + +def delete_populated_groups(groups: List[Group]) -> None: + """ + Deletes a list of groups along with all their user and permission assignments (does not + delete the users or permissions themselves). + + :param groups: List of groups to delete. + :type groups: List[Group] + """ + + for group in groups: + delete_populated_group(group) diff --git a/BaCa2/util/models_registry.py b/BaCa2/util/models_registry.py new file mode 100644 index 00000000..3eec7cdc --- /dev/null +++ b/BaCa2/util/models_registry.py @@ -0,0 +1,693 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, List + +from django.db.models import QuerySet + +from course.routing import OptionalInCourse + +if TYPE_CHECKING: + from django.contrib.auth.models import Group, Permission + + from core.choices import TaskJudgingMode + from course.models import Round, Submit, Task, Test, TestSet + from main.models import Course, Role, RolePreset, User + from package.models import PackageInstance, PackageSource + + +class ModelsRegistry: + """ + Helper class used to retrieve models from the database using different possible parameters. + It stores in one place all logic necessary to allow methods across the project to accept + different types of parameters which can be used as univocal identifiers of a model instance or + instances. + """ + + # ------------------------------- django.contrib.auth models ------------------------------- # + + @staticmethod + def get_group(group: str | int | Group) -> Group: + """ + Returns a Group model instance from the database using its name or id as a reference. + It can also be used to return the same instance if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param group: Group model instance, its name or id. + :type group: str | int | Group + + :return: Group model instance. + :rtype: Group + """ + from django.contrib.auth.models import Group + + if isinstance(group, str): + return Group.objects.get(name=group) + if isinstance(group, int): + return Group.objects.get(id=group) + return group + + @staticmethod + def get_group_id(group: str | int | Group) -> int: + """ + Returns a group's id from the database using a model instance or its name as a reference. + It can also be used to return the same id if it is passed as the parameter (for ease of use + in case of methods which accept both model instances and their identifiers). + + :param group: Group model instance, its name or id. + :type group: str | int | Group + + :return: Given group's id. + :rtype: int + """ + from django.contrib.auth.models import Group + + if isinstance(group, str): + return Group.objects.get(name=group).id + if isinstance(group, Group): + return group.id + return group + + @staticmethod + def get_groups(groups: List[str] | List[int] | List[Group]) -> QuerySet[Group] | List[Group]: + """ + Returns a QuerySet of groups using a list of their names or ids as a reference. + It can also be used to return a list of Group model instances if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param groups: List of Group model instances, their names or ids. + :type groups: List[Group] | List[str] | List[int] + + :return: QuerySet of Group model instances or list of Group model instances. + :rtype: QuerySet[Group] | List[Group] + """ + from django.contrib.auth.models import Group + + if isinstance(groups[0], str): + return Group.objects.filter(name__in=groups) + if isinstance(groups[0], int): + return Group.objects.filter(id__in=groups) + return groups + + @staticmethod + def get_permission(permission: str | int | Permission) -> Permission: + """ + Returns a Permission model instance from the database using its codename or id as a + reference. It can also be used to return the same instance if it is passed as the parameter + (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param permission: Permission model instance, its codename or id. + :type permission: str | int | Permission + + :return: Permission model instance. + :rtype: Permission + """ + from django.contrib.auth.models import Permission + + if isinstance(permission, str): + return Permission.objects.get(codename=permission) + if isinstance(permission, int): + return Permission.objects.get(id=permission) + return permission + + @staticmethod + def get_permission_id(permission: str | int | Permission) -> int: + """ + Returns a permission's id from the database using a model instance or its codename as a + reference. It can also be used to return the same id if it is passed as the parameter (for + ease of use in case of methods which accept both model instances and their identifiers). + + :param permission: Permission model instance, its codename or id. + :type permission: str | int | Permission + + :return: Given permission's id. + :rtype: int + """ + from django.contrib.auth.models import Permission + + if isinstance(permission, str): + return Permission.objects.get(codename=permission).id + if isinstance(permission, Permission): + return permission.id + return permission + + @staticmethod + def get_permissions(permissions: List[str] | List[int] | List[Permission] + ) -> QuerySet[Permission] | List[Permission]: + """ + Returns a QuerySet of permissions using a list of their codenames or ids as a reference. + It can also be used to return a list of Permission model instances if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param permissions: List of Permission model instances, their codenames or ids. + :type permissions: List[Permission] | List[str] | List[int] + + :return: QuerySet of Permission model instances or list of Permission model instances. + :rtype: QuerySet[Permission] | List[Permission] + """ + from django.contrib.auth.models import Permission + + if isinstance(permissions[0], str): + return Permission.objects.filter(codename__in=permissions) + if isinstance(permissions[0], int): + return Permission.objects.filter(id__in=permissions) + return permissions + + # --------------------------------------- main models -------------------------------------- # + + @staticmethod + def get_user(user: str | int | User) -> User: + """ + Returns a User model instance from the database using its email or id as a reference. + It can also be used to return the same instance if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param user: User model instance, its email or id. + :type user: str | int | User + + :return: User model instance. + :rtype: User + """ + from main.models import User + + if isinstance(user, str): + return User.objects.get(email=user) + if isinstance(user, int): + return User.objects.get(id=user) + return user + + @staticmethod + def get_user_id(user: str | int | User) -> int: + """ + Returns a user's id from the database using a model instance or its email as a reference. + It can also be used to return the same id if it is passed as the parameter (for ease of use + in case of methods which accept both model instances and their identifiers). + + :param user: User model instance, its email or id. + :type user: str | int | User + + :return: Given user's id. + :rtype: int + """ + from main.models import User + + if isinstance(user, str): + return User.objects.get(email=user).id + if isinstance(user, User): + return user.id + return user + + @staticmethod + def get_users(users: List[str] | List[int] | List[User]) -> QuerySet[User] | List[User]: + """ + Returns a QuerySet of users using a list of their emails or ids as a reference. + It can also be used to return a list of User model instances if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param users: List of User model instances, their emails or ids. + :type users: List[User] | List[str] | List[int] + + :return: QuerySet of User model instances or list of User model instances. + :rtype: QuerySet[User] | List[User] + """ + from main.models import User + + if isinstance(users[0], str): + return User.objects.filter(email__in=users) + if isinstance(users[0], int): + return User.objects.filter(id__in=users) + return users + + @staticmethod + def get_course(course: str | int | Course) -> Course: + """ + Returns a Course model instance from the database using its short name or id as a reference. + It can also be used to return the same instance if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param course: Course model instance, its short name or id. + :type course: str | int | Course + + :return: Course model instance. + :rtype: Course + """ + from main.models import Course + + if isinstance(course, str): + return Course.objects.get(short_name=course) + if isinstance(course, int): + return Course.objects.get(id=course) + return course + + @staticmethod + def get_course_id(course: str | int | Course) -> int: + """ + Returns a course's id from the database using a model instance or its short name as a + reference. It can also be used to return the same id if it is passed as the parameter (for + ease of use in case of methods which accept both model instances and their identifiers). + + :param course: Course model instance, its short name or id. + :type course: str | int | Course + + :return: Given course's id. + :rtype: int + """ + from main.models import Course + + if isinstance(course, str): + return Course.objects.get(short_name=course).id + if isinstance(course, Course): + return course.id + return course + + @staticmethod + def get_courses(courses: List[str] | List[int] | List[Course] + ) -> QuerySet[Course] | List[Course]: + """ + Returns a QuerySet of courses using a list of their short names or ids as a reference. + It can also be used to return a list of Course model instances if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param courses: List of Course model instances, their short names or ids. + :type courses: List[Course] | List[str] | List[int] + + :return: QuerySet of Course model instances or list of Course model instances. + :rtype: QuerySet[Course] | List[Course] + """ + from main.models import Course + + if isinstance(courses[0], str): + return Course.objects.filter(short_name__in=courses) + if isinstance(courses[0], int): + return Course.objects.filter(id__in=courses) + return courses + + @staticmethod + def get_role(role: str | int | Role, course: str | int | Course = None) -> Role: + """ + Returns a Role model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param role: Role name, id or model instance. + :type role: str | int | Role + :param course: Course short name, id or model instance. + :type course: str | int | Course + + :return: Role model instance. + :rtype: Role + + :raises ValueError: If the role name is passed as a parameter but its course is not. + """ + from main.models import Role + + if isinstance(role, str): + if not course: + raise ValueError('If the role name is passed as a parameter, its course name must ' + 'be passed as well. A role name is only unique within a course.') + return Role.objects.get(name=role, course=ModelsRegistry.get_course(course)) + + if isinstance(role, int): + return Role.objects.get(id=role) + return role + + @staticmethod + def get_roles(roles: List[str] | List[int] | List[Role], + course: str | int | Course = None) -> QuerySet[Role] | List[Role]: + """ + Returns a QuerySet of roles using a list of their ids or names and course as a reference. + It can also be used to return a list of Role model instances if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param roles: List of Role names, ids or model instances. + :type roles: List[str] | List[Role] | List[int] + :param course: Course short name, id or model instance. + :type course: str | int | Course + + :return: QuerySet of Role model instances or list of Role model instances. + :rtype: QuerySet[Role] | List[Role] + + :raises ValueError: If the role names are passed as a parameter but their course is not. + """ + from main.models import Role + + if isinstance(roles[0], str): + if not course: + raise ValueError('If the role names are passed as a parameter, their course name ' + 'must be passed as well. A role name is only unique within a ' + 'course.') + return Role.objects.filter(name__in=roles, course=ModelsRegistry.get_course(course)) + + if isinstance(roles[0], int): + return Role.objects.filter(id__in=roles) + return roles + + @staticmethod + def get_role_preset(preset: int | RolePreset) -> RolePreset: + """ + Returns a RolePreset model instance from the database using its id as a reference. It can + also be used to return the same instance if it is passed as the parameter (for ease of use + in case of methods which accept both model instances and their identifiers). + + :param preset: RolePreset id or model instance. + :type preset: int | RolePreset + + :return: RolePreset model instance. + :rtype: RolePreset + """ + from main.models import RolePreset + + if isinstance(preset, int): + return RolePreset.objects.get(id=preset) + return preset + + # ------------------------------------package models --------------------------------------- # + + @staticmethod + def get_package_source(pkg_source: str | int | PackageSource) -> PackageSource: + """ + Returns a PackageSource model instance from the database using its name or id as a + reference. It can also be used to return the same instance if it is passed as the parameter + (for ease of use in case of methods which accept both model instances and their + identifiers). + + :param pkg_source: PackageSource model instance, its name or id. + :type pkg_source: str | int | PackageSource + + :return: PackageSource model instance. + :rtype: PackageSource + """ + from package.models import PackageSource + + if isinstance(pkg_source, str): + return PackageSource.objects.get(name=pkg_source) + if isinstance(pkg_source, int): + return PackageSource.objects.get(id=pkg_source) + return pkg_source + + @staticmethod + def get_package_instance(pkg_instance: int | PackageInstance) -> PackageInstance: + """ + Returns a PackageInstance model instance from the database using its id as a reference. It + can also be used to return the same instance if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param pkg_instance: PackageInstance id or model instance. + :type pkg_instance: int | PackageInstance + + :return: PackageInstance model instance. + :rtype: PackageInstance + """ + from package.models import PackageInstance + + if isinstance(pkg_instance, int): + return PackageInstance.objects.get(id=pkg_instance) + return pkg_instance + + # -------------------------------------- course models ------------------------------------- # + + @staticmethod + def get_round(round_: int | Round, course: str | int | Course = None) -> Round: + """ + Returns a Round model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in context ( + in that case this method should be used inside ``with InCourse():``). + + :param round_: Round name, id or model instance. + :type round_: str | int | Round + :param course: Course short name, id or model instance. + :type course: str | int | Course + + :return: Round model instance. + :rtype: Round + """ + from course.models import Round + + with OptionalInCourse(course): + if isinstance(round_, int): + return Round.objects.get(id=round_) + return round_ + + @staticmethod + def get_rounds( + rounds: List[int] | List[Round], + course: str | int | Course = None, + return_queryset: bool = False + ) -> QuerySet[Round] | List[Round]: + """ + Returns a QuerySet of rounds using a list of their ids or model instances and course as a + reference. It can also be used to return a list of Round model instances if it is passed as + the parameter (for ease of use in case of methods which accept both model instances and + their identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param rounds: List of Round names, ids or model instances. + :type rounds: List[int] | List[Round] + :param course: Course short name, id or model instance. + :type course: str | int | Course + :param return_queryset: If True, returns a QuerySet of Round model instances. Otherwise, + returns a list of Round model instances. + :type return_queryset: bool + + :return: QuerySet of Round model instances or list of Round model instances. + :rtype: QuerySet[Round] | List[Round] + """ + from course.models import Round + + with OptionalInCourse(course): + if isinstance(rounds[0], int): + rounds = Round.objects.filter(id__in=rounds) + if return_queryset: + return rounds + else: + return list(rounds) + return rounds + + @staticmethod + def get_task(task: int | Task, course: str | int | Course = None) -> Task: + """ + Returns a Task model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param task: Task name, id or model instance. + :type task: str | int | Task + :param course: Course short name, id or model instance. + :type course: str | int | Course + + :return: Task model instance. + :rtype: Task + """ + from course.models import Task + + with OptionalInCourse(course): + if isinstance(task, int): + return Task.objects.get(id=task) + return task + + @staticmethod + def get_tasks( + tasks: List[int] | List[Task], + course: str | int | Course = None, + return_queryset: bool = False + ) -> QuerySet[Task] | List[Task]: + """ + Returns a QuerySet of tasks using a list of their ids or model instances and course as a + reference. It can also be used to return a list of Task model instances if it is passed as + the parameter (for ease of use in case of methods which accept both model instances and + their identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param tasks: List of Task names, ids or model instances. + :type tasks: List[int] | List[Task] + :param course: Course short name, id or model instance. + :type course: str | int | Course + :param return_queryset: If True, returns a QuerySet of Task model instances. Otherwise, + returns a list of Task model instances. + :type return_queryset: bool + + :return: QuerySet of Task model instances or list of Task model instances. + :rtype: QuerySet[Task] | List[Task] + """ + from course.models import Task + + with OptionalInCourse(course): + if isinstance(tasks[0], int): + tasks = Task.objects.filter(id__in=tasks) + if return_queryset: + return tasks + else: + return list(tasks) + return tasks + + @staticmethod + def get_submit(submit: int | Submit, course_: str | int | Course = None) -> Submit: + """ + Returns a Submit model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param submit: Submit name, id or model instance. + :type submit: str | int | Submit + :param course_: Course short name, id or model instance. + :type course_: str | int | Course + + :return: Submit model instance. + :rtype: Submit + """ + from course.models import Submit + + with OptionalInCourse(course_): + if isinstance(submit, int): + return Submit.objects.get(id=submit) + return submit + + @staticmethod + def get_task_judging_mode(judging_mode: str | TaskJudgingMode) -> TaskJudgingMode: + """ + Returns a TaskJudgingMode model instance from the database using its name as a reference. + It can also be used to return the same instance if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param judging_mode: TaskJudgingMode name or model instance. + :type judging_mode: str | TaskJudgingMode + + :return: TaskJudgingMode model instance. + :rtype: TaskJudgingMode + """ + from core.choices import TaskJudgingMode + + if isinstance(judging_mode, str): + judging_mode = judging_mode.upper() + for mode in list(TaskJudgingMode): + if mode.value == judging_mode: + return mode + return judging_mode + + @staticmethod + def get_test_set(test_set: int | TestSet, course: str | int | Course = None) -> TestSet: + """ + Returns a TestSet model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param test_set: TestSet name, id or model instance. + :type test_set: str | int | TestSet + :param course: Course short name, id or model instance. + :type course: str | int | Course + + :return: TestSet model instance. + :rtype: TestSet + """ + from course.models import TestSet + + with OptionalInCourse(course): + if isinstance(test_set, int): + return TestSet.objects.get(id=test_set) + return test_set + + @staticmethod + def get_test(test: int | Test, course: str | int | Test = None) -> Test: + """ + Returns a Test model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param test: Test name, id or model instance. + :type test: str | int | Test + :param course: Course short name, id or model instance. + :type course: str | int | Test + + :return: Test model instance. + :rtype: Test + """ + from course.models import Test + + with OptionalInCourse(course): + if isinstance(test, int): + return Test.objects.get(id=test) + return test + + @staticmethod + def get_result(result: int | Test, course: str | int | Test = None) -> Test: + """ + Returns a Result model instance from the database using its id or name and course as + a reference. It can also be used to return the same instance if it is passed as the + parameter (for ease of use in case of methods which accept both model instances and their + identifiers). If course is not passed as a parameter, it has to be available in + context (in that case this method should be used inside ``with InCourse():``). + + :param result: Result name, id or model instance. + :type result: str | int | Result + :param course: Course short name, id or model instance. + :type course: str | int | Result + + :return: Result model instance. + :rtype: Result + """ + from course.models import Result + + with OptionalInCourse(course): + if isinstance(result, int): + return Result.objects.get(id=result) + return result + + @staticmethod + def get_source_code(src: str | Path) -> Path: + """ + Returns a Path object to the source code file. + :param src: Path to the source code file or its name. + :return: Path to the source code file. + """ + from django.conf import settings + if isinstance(src, str): + path = Path(src) + path = path.absolute() + if not path.is_relative_to(settings.SUBMITS_DIR): + path = settings.SUBMITS_DIR / src + else: + path = src + if not path.exists(): + raise FileNotFoundError(f'File {path} does not exist.') + if not path.is_relative_to(settings.SUBMITS_DIR): + raise FileNotFoundError(f'Path {path} is not a file.') + return path + + @staticmethod + def get_result_status(status: str) -> str: + """ + Returns a result status from the database using its name as a reference. + It can also be used to return the same status if it is passed as the parameter (for ease + of use in case of methods which accept both model instances and their identifiers). + + :param status: Result status name or model instance. + :type status: str | ResultStatus + + :return: ResultStatus model instance. + :rtype: ResultStatus + """ + from core.choices import ResultStatus + + if isinstance(status, str): + status = status.upper() + for result_status in list(ResultStatus): + if result_status.value == status: + return result_status + return status diff --git a/BaCa2/util/other.py b/BaCa2/util/other.py new file mode 100644 index 00000000..02f28641 --- /dev/null +++ b/BaCa2/util/other.py @@ -0,0 +1,126 @@ +def normalize_string_to_python(string: str) -> str | bool | int | None | list: + """ + Converts a string to None, False or True if the value matches python or javascript literals + for those keywords. If the string is a number, it is converted to an int. If the string starts + and ends with square brackets, it is converted to a list by splitting it at the commas and + normalizing the values. + + :param string: The string to convert + :type string: str + :return: The converted string + :rtype: str | bool | None + """ + if string == 'None' or string == 'null': + return None + elif string == 'False' or string == 'false': + return False + elif string == 'True' or string == 'true': + return True + elif string.isdigit(): + return int(string) + elif string.startswith('[') and string.endswith(']'): + return decode_list(string) + else: + return string + + +def add_kwargs_to_url(url: str, kwargs: dict) -> str: + """ + Adds the given kwargs to the given url as query_result parameters. + + :param url: The url to add the kwargs to + :type url: str + :param kwargs: The kwargs to add + :type kwargs: dict + :return: The url with the kwargs added + :rtype: str + """ + if len(kwargs) == 0: + return url + else: + kwargs = '&'.join([f'{key}={value}' for key, value in kwargs.items()]) + if '?' in url: + return url + '&' + kwargs + return url + '?' + kwargs + + +def replace_special_symbols(string: str, replacement: str = '_') -> str: + """ + Replaces all special symbols in a string with a given replacement. + + :param string: String to replace special symbols in. + :type string: str + :param replacement: Replacement for special symbols. + :type replacement: str + + :return: String with special symbols replaced. + :rtype: str + """ + for i in range(len(string)): + if not string[i].isalnum(): + string = string[:i] + f'{replacement}' + string[i + 1:] + return string + + +def encode_dict_to_url(name: str, dictionary: dict) -> str: + """ + Encodes a dictionary to a string that can be used in a url. Supports dictionaries containing + strings, integers, booleans, None and non-nested lists of those types. + + :param name: The name of the dictionary, the encoded dict can be retrieved from url + query parameters using this name. + :type name: str + :param dictionary: The dictionary to encode. + :type dictionary: dict + :return: The encoded dictionary. + :rtype: str + + See also: + - :func:`decode_url_to_dict` + - :func:`encode_value` + """ + items = '|'.join([f'{key}={encode_value(value)}' for key, value in dictionary.items()]) + return f'{name}={items}' + + +def encode_value(value: str | int | bool | None | list) -> str: + """ + Encodes a value to a string that can be used in a url. If the value is a list, it is encoded as + a string in the format '[item1,item2,...]'. Does not support nested lists. + + :param value: The value to encode. + :type value: str | int | bool | None | list + :return: The encoded value. + :rtype: str + """ + if not isinstance(value, list): + return f'{value}' + + return f'[{",".join([encode_value(item) for item in value])}]' + + +def decode_list(string: str) -> list: + """ + :param string: list encoded as string using :func:`encode_value`. + :type string: str + :return: The decoded list of integers, booleans, strings or None. + :rtype: list + """ + return [normalize_string_to_python(item) for item in string[1:-1].split(',')] + + +def decode_url_to_dict(encoded_dict: str) -> dict: + """ + Decodes a string encoded using :func:`encode_dict_to_url` to a dictionary normalizing values + to python. + + :param encoded_dict: The encoded dictionary. + :type encoded_dict: str + :return: The decoded dictionary. + :rtype: dict + + See also: + - :func:`encode_dict_to_url` + """ + items = encoded_dict.split('|') + return {item.split('=')[0]: normalize_string_to_python(item.split('=')[1]) for item in items} diff --git a/BaCa2/util/responses.py b/BaCa2/util/responses.py new file mode 100644 index 00000000..1e47e7ba --- /dev/null +++ b/BaCa2/util/responses.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from enum import Enum + +from django.http import JsonResponse + +from core.choices import ModelAction +from util.models import model_cls + + +class BaCa2JsonResponse(JsonResponse): + """ + Base class for all JSON responses returned as a result of an AJAX POST or GET request sent in + the BaCa2 web app. Contains basic fields required by views to handle the response along with + predefined values for the status field. + + See Also: + :class:`util.views.BaCa2ModelView` + :class:`BaCa2Form` + :class:`FormWidget` + """ + + class Status(Enum): + """ + Enum used to indicate the possible outcomes of an AJAX POST or GET request sent in + the BaCa2 web app. + """ + #: Indicates that the request was successful. + SUCCESS = 'success' + #: Indicates that the request was unsuccessful due to invalid data. + INVALID = 'invalid' + #: Indicates that the request was unsuccessful due to the sender's lack of necessary + #: permissions. + IMPERMISSIBLE = 'impermissible' + #: Indicates that the request was unsuccessful due to an internal error. + ERROR = 'error' + + def __init__(self, status: Status, message: str = '', **kwargs: dict) -> None: + """ + :param status: Status of the response. + :type status: :class:`BaCa2JsonResponse.Status` + :param message: Message accompanying the response. + :type message: str + :param kwargs: Additional fields to be included in the response. + :type kwargs: dict + """ + super().__init__({'status': status.value, 'message': message} | kwargs) + + +class BaCa2ModelResponse(BaCa2JsonResponse): + """ + Base class for all JSON responses returned as a result of an AJAX POST request sent by a model + form or GET request sent to a model view in the BaCa2 web app. + + See Also: + :class:`util.views.BaCa2ModelView` + :class:`BaCa2ModelForm` + :class:`BaCa2JsonResponse` + """ + + def __init__(self, + model: model_cls, + action: ModelAction, + status: BaCa2JsonResponse.Status, + message: str = '', + **kwargs: dict) -> None: + """ + :param model: Model class which instances the request pertains to. + :type model: Type[Model] + :param action: Action the request pertains to. + :type action: :class:`ModelAction` + :param status: Status of the response. + :type status: :class:`BaCa2JsonResponse.Status` + :param message: Message accompanying the response. If no message is provided, a default + message will be generated based on the status of the response, the model and the action + performed. + :type message: str + :param kwargs: Additional fields to be included in the response. + :type kwargs: dict + """ + if not message: + message = self.generate_response_message(status, model, action) + super().__init__(status, message, **kwargs) + + @staticmethod + def generate_response_message(status, model, action) -> str: + """ + Generates a response message based on the status of the response, the model and the action + performed. + + :param status: Status of the response. + :type status: :class:`BaCa2JsonResponse.Status` + :param model: Model class which instances the request pertains to. + :type model: Type[Model] + :param action: Action the request pertains to. + :type action: :class:`ModelAction` + :return: Response message. + :rtype: str + """ + model_name = model._meta.verbose_name + + if status == BaCa2JsonResponse.Status.SUCCESS: + return f'successfully performed {action.label} on {model_name}' + + message = f'failed to perform {action.label} on {model_name}' + + if status == BaCa2JsonResponse.Status.INVALID: + message += ' due to invalid form data. Please correct the following errors:' + elif status == BaCa2JsonResponse.Status.IMPERMISSIBLE: + message += ' due to insufficient permissions.' + elif status == BaCa2JsonResponse.Status.ERROR: + message += ' due an error.' + + return message diff --git a/BaCa2/package/packages/1/tests/set1/1.out b/BaCa2/util/templatetags/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set1/1.out rename to BaCa2/util/templatetags/__init__.py diff --git a/BaCa2/util/templatetags/custom_tags.py b/BaCa2/util/templatetags/custom_tags.py new file mode 100644 index 00000000..f8d54ce5 --- /dev/null +++ b/BaCa2/util/templatetags/custom_tags.py @@ -0,0 +1,35 @@ +import importlib +from typing import Any + +from django import template + +register = template.Library() + + +@register.filter +def get_item(dictionary: dict, key: Any): + return dictionary.get(key) + + +@register.filter +def is_instance_of(value, class_path): + path = class_path.split('.') + module_path = '.'.join(path[:-1]) + class_name = path[-1] + + try: + module = importlib.import_module(module_path) + class_obj = getattr(module, class_name) + return isinstance(value, class_obj) + except (ImportError, AttributeError): + return False + + +@register.simple_tag +def validation_status(field, value): + return field.validation_status(value) + + +@register.filter +def get_form_field(form, field_name): + return form[field_name] diff --git a/BaCa2/util/usos/__init__.py b/BaCa2/util/usos/__init__.py new file mode 100644 index 00000000..b911be6a --- /dev/null +++ b/BaCa2/util/usos/__init__.py @@ -0,0 +1,12 @@ +from core.settings import ( + BASE_DIR, + USOS_CONSUMER_KEY, + USOS_CONSUMER_SECRET, + USOS_GATEWAY, + USOS_SCOPES +) + +from .communicator import USOS, RegisterUSOS + +__all__ = ['RegisterUSOS', 'USOS', 'BASE_DIR', 'USOS_CONSUMER_KEY', 'USOS_CONSUMER_SECRET', + 'USOS_GATEWAY', 'USOS_SCOPES'] diff --git a/BaCa2/util/usos/communicator.py b/BaCa2/util/usos/communicator.py new file mode 100644 index 00000000..0dbb4242 --- /dev/null +++ b/BaCa2/util/usos/communicator.py @@ -0,0 +1,88 @@ +from typing import Any, Dict, Iterable + +import usosapi + +from . import * + + +class RegisterUSOS: + def __init__(self): + self.connection = usosapi.USOSAPIConnection( + api_base_address=USOS_GATEWAY, + consumer_key=USOS_CONSUMER_KEY, + consumer_secret=USOS_CONSUMER_SECRET + ) + self.token_key = None + self.token_secret = None + self.scopes = '|'.join([str(scope) for scope in USOS_SCOPES]) + + @property + def authorization_url(self): + return self.connection.get_authorization_url() + + def authorize_with_pin(self, pin): + self.connection.authorize_with_pin(pin) + self.token_key, self.token_secret = self.connection.get_access_data() + + @property + def token(self): + return self.token_key, self.token_secret + + +class USOS: + def __init__(self, token_key, token_secret): + self.connection = usosapi.USOSAPIConnection( + api_base_address=USOS_GATEWAY, + consumer_key=USOS_CONSUMER_KEY, + consumer_secret=USOS_CONSUMER_SECRET + ) + self.connection.set_access_data(token_key, token_secret) + + def get_user_data(self, + user_id=None, + fields: Iterable[str] = ('id', + 'first_name', + 'last_name', + 'email', + 'sex', + 'titles', + 'student_number')) -> Dict[str, Any]: + """ + It gets user data from USOS API. If user_id is None, it gets data of the user who is + logged in. Fields are specified in the fields parameter. If fields is None, it gets only + ``id``, ``first_name`` and ``last_name``. + + Possible fields are described in the `USOS API documentation + `_. + + :param user_id: USOS user id (if None, it gets data of the user who is logged in) + :param fields: fields to get + :type fields: Iterable[str] + + :return: user data + :rtype: Dict[str, Any] + """ + fields_str = '|'.join(fields) + if user_id: + return self.connection.get('services/users/user', + user_id=user_id, + fields=fields_str) + else: + return self.connection.get('services/users/user', + fields=fields_str) + + def get_user_courses(self): + """ + It gets courses of the user. If user_id is None, it gets courses of the user who is + logged in. It gets only active courses by default, but you can change it by setting + active_terms_only to False. + + :param user_id: USOS user id (if None, it gets courses of the user who is logged in) + :param active_terms_only: if True, it gets only active courses + :type active_terms_only: bool + + :return: courses of the user + """ + courses = self.connection.get('services/courses/user') + + return courses diff --git a/BaCa2/util/views.py b/BaCa2/util/views.py new file mode 100644 index 00000000..97ca6b6b --- /dev/null +++ b/BaCa2/util/views.py @@ -0,0 +1,653 @@ +from abc import ABC +from enum import Enum +from typing import Any, Callable, Dict, List, Optional, Type + +import django.db.models +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import JsonResponse +from django.utils.translation import gettext_lazy as _ +from django.views import View +from django.views.generic import TemplateView + +from core.choices import BasicModelAction +from util import ( + add_kwargs_to_url, + decode_url_to_dict, + encode_dict_to_url, + normalize_string_to_python +) +from util.models import model_cls +from util.responses import BaCa2JsonResponse, BaCa2ModelResponse +from widgets.base import Widget +from widgets.brief_result_summary import BriefResultSummary +from widgets.code_block import CodeBlock +from widgets.forms import FormWidget +from widgets.forms.fields.validation import get_field_validation_status +from widgets.listing import TableWidget +from widgets.navigation import NavBar, SideNav +from widgets.text_display import MarkupDisplayer, PDFDisplayer, TextDisplayer + + +class BaCa2ContextMixin: + """ + Context mixin for all BaCa2 views used to establish shared context dictionary structure and + context gathering logic between different views. Provides a preliminary setup for the context + dictionary and a method for adding widgets to it. The :py:meth:`get_context_data` method of + this mixin calls on the :py:meth:`get_context_data` method of the other following ancestor in + the MRO chain and as such it should precede in the parent list of the view class any other mixin + or view which calls on that method. + """ + + class WidgetException(Exception): + """ + Exception raised when widget-related error occurs. + """ + pass + + #: List of all widget types used in BaCa2 views. + WIDGET_TYPES = [ + FormWidget, + NavBar, + SideNav, + TableWidget, + TextDisplayer, + MarkupDisplayer, + PDFDisplayer, + CodeBlock, + BriefResultSummary, + ] + #: List of all widgets which are unique (i.e. there can only be one instance of each widget type + #: can exist in the context dictionary). + UNIQUE_WIDGETS = [NavBar, SideNav] + #: Default theme for users who are not logged in. + DEFAULT_THEME = 'dark' + + def get_context_data(self, **kwargs) -> Dict[str, Any]: + """ + Returns a dictionary containing all the data required by the template to render the view. + Calls on the `get_context_data` method of the other following ancestor (should there be + one) in the MRO chain (As such, if this mixin precedes in the parent list of the view + class an ancestor view whose context gathering is also necessary, it is enough to call on + the `get_context_data` once through the super() method). + + :param kwargs: Keyword arguments passed to the `get_context_data` method of the other + following ancestor in the MRO chain (should there be one). + :type kwargs: dict + + :return: A dictionary containing data required by the template to render the view. + :rtype: Dict[str, Any] + + :raises Exception: If no request object is found. + """ + super_context = getattr(super(), 'get_context_data', None) + if super_context and callable(super_context): + context = super_context(**kwargs) + else: + context = {} + + context['widgets'] = {widget_type.__name__: {} for widget_type in + BaCa2ContextMixin.WIDGET_TYPES} + + request = getattr(self, 'request', None) + if request: + if request.user.is_authenticated: + context['data_bs_theme'] = request.user.user_settings.theme + context['display_navbar'] = True + self.add_widget(context, NavBar(request)) + else: + context['data_bs_theme'] = BaCa2ContextMixin.DEFAULT_THEME + context['display_navbar'] = False + else: + raise Exception('No request object found. Remember that BaCa2ContextMixin should only ' + 'be used as a view mixin.') + + context['display_sidenav'] = False + context['page_title'] = 'BaCa²' + context['request'] = request + + return context + + @staticmethod + def add_widget(context: Dict[str, Any], widget: Widget) -> None: + """ + Add a widget to the context dictionary. The widget is added to its corresponding widget + type dictionary (in the `widgets` dictionary of the main context dict) under its name as a + key. + + :param context: Context dictionary to which the widget is to be added. + :type context: Dict[str, Any] + + :param widget: Widget to be added to the context dictionary. + :type widget: Widget + + :raises WidgetException: If the widget type is not recognized or if it is unique and + already exists in the context dictionary. + """ + widget_type = BaCa2ContextMixin.get_type(type(widget)) + + if not widget_type: + raise BaCa2ContextMixin.WidgetException(f'Widget type not recognized: {widget_type}.') + + if all((BaCa2ContextMixin.has_widget_type(context, widget_type), + widget_type in BaCa2ContextMixin.UNIQUE_WIDGETS)): + raise BaCa2ContextMixin.WidgetException(f'Widget of type {widget_type} already ' + f'exists in the context dictionary.') + + if widget_type == SideNav: + context['display_sidenav'] = True + + context['widgets'][widget_type.__name__][widget.name] = widget.get_context() + + @staticmethod + def has_widget_type(context: Dict[str, Any], widget_type: Type[Widget]) -> bool: + """ + Checks if the context dictionary contains at least one widget of the specified type. + + :param context: Context dictionary to check. + :type context: Dict[str, Any] + :param widget_type: Widget type to check for. + :type widget_type: Type[Widget] + + :return: `True` if the context dictionary contains at least one widget of the specified + type, `False` otherwise. + :rtype: bool + + :raises WidgetException: If the widget type is not recognized. + """ + if widget_type not in BaCa2ContextMixin.WIDGET_TYPES: + widget_type = BaCa2ContextMixin.get_type(widget_type) + if not widget_type: + raise BaCa2ContextMixin.WidgetException(f'Widget type not recognized: {widget_type}.') + if context['widgets'][widget_type.__name__]: + return True + return False + + @staticmethod + def has_widget(context: Dict[str, Any], widget_type: Type[Widget], widget_name: str) -> bool: + """ + Checks if the context dictionary contains a widget of the specified type and name. + + :param context: Context dictionary to check. + :type context: Dict[str, Any] + :param widget_type: Widget type to check for. + :type widget_type: Type[Widget] + :param widget_name: Widget name to check for. + :type widget_name: str + + :return: `True` if the context dictionary contains a widget of the specified type and name, + `False` otherwise. + :rtype: bool + + :raises WidgetException: If the widget type is not recognized. + """ + if widget_type not in BaCa2ContextMixin.WIDGET_TYPES: + raise BaCa2ContextMixin.WidgetException(f'Widget type not recognized: {widget_type}.') + if context['widgets'][widget_type.__name__].get(widget_name, None): + return True + return False + + @staticmethod + def get_type(widget_cls: Type[Widget]) -> Type[Widget] | None: + """ + Returns the type of given widget class if it is recognized, `None` otherwise. + + :param widget_cls: Widget class to check. + :type widget_cls: Type[Widget] + + :return: Type of the widget class if recognized, `None` otherwise. + :rtype: Type[Widget] | None + """ + for widget_type in BaCa2ContextMixin.WIDGET_TYPES: + if issubclass(widget_cls, widget_type): + return widget_type + return None + + +class BaCa2LoggedInView(LoginRequiredMixin, BaCa2ContextMixin, TemplateView): + """ + Base view for all views which require the user to be logged in. Inherits from BaCa2ContextMixin + providing a navbar widget and a preliminary setup for the context dictionary. + """ + + #: Baca2LoggedInView is a base, abstract class and as such does not have a template. All + #: subclasses should provide their own template. + template_name = None + + +class FieldValidationView(LoginRequiredMixin, View): + """ + View used for live field validation. Its :meth:`get` method is called whenever the value of a + field with live validation enabled changes. The method returns a JSON response containing the + validation status of the field and any potential error messages to be displayed. + + See also: + - :meth:`widgets.forms.fields.validation.get_field_validation_status` + - :class:`widgets.forms.base.FormWidget` + """ + + class ValidationRequestException(Exception): + """ + Exception raised when a validation request does not contain the required data. + """ + pass + + @staticmethod + def get(request, *args, **kwargs) -> JsonResponse: + """ + Parses the request for the required data and returns a JSON response containing the + validation status of the field and any potential error messages to be displayed. + + :return: JSON response with validation status and error messages. + :rtype: JsonResponse + :raises ValidationRequestException: If the request does not contain the required data. + """ + form_cls_name = request.GET.get('formCls', None) + field_name = request.GET.get('fieldName', None) + + if not form_cls_name or not field_name: + raise FieldValidationView.ValidationRequestException( + 'Validation request does contain the required data.' + ) + + return JsonResponse( + get_field_validation_status( + request=request, + form_cls=form_cls_name, + field_name=field_name, + value=request.GET.get('value'), + min_length=normalize_string_to_python(request.GET.get('minLength', None)) + ) + ) + + +class BaCa2ModelView(LoginRequiredMixin, View, ABC): + """ + Base class for all views used to manage models and retrieve their data from the front-end. GET + requests directed at this view are used to retrieve serialized model data while POST requests + are handled in accordance with the particular model form from which they originate. + + The view itself does not interface with model managers directly outside of retrieving data. + Instead, it acts as an interface between POST requests and form classes which handle them. + + Course db models are managed by views extending the inheriting `CourseModelView` class. + + see: + - :class:`widgets.forms.base.BaCa2ModelForm` + - :class:`widgets.forms.base.BaCa2ModelResponse` + - :class:`course.views.CourseModelView` + """ + + class GetMode(Enum): + """ + Enum containing available get modes for retrieving model data from the view. The get mode + defines which method is used to construct the QuerySet to be serialized and returned to the + front-end. + """ + #: Retrieve data for all model instances. + ALL = 'all' + #: Retrieve data for model instances matching the specified filter and/or exclude + #: parameters. + FILTER = 'filter' + + class ModelViewException(Exception): + """ + Exception raised when an error not related to request arguments or requesting user + permissions occurs while processing a GET or POST request. + """ + pass + + #: Model class which the view manages. Should be set by inheriting classes. + MODEL: model_cls = None + + #: Serialization method for the model class instances, where `:meth:get_data` is not specified + #: in the model class. + GET_DATA_METHOD: Callable[[model_cls, Optional[Dict[str, Any]]], Dict[str, Any]] = None + + # -------------------------------------- get methods --------------------------------------- # + + @classmethod + def get_data_method(cls) -> Callable[[model_cls, Optional[Dict[str, Any]]], Dict[str, Any]]: + """ + :return: Serialization method for the model class instances from which the view retrieves + data. + :rtype: Callable[[model_cls, Optional[Dict[str, Any]]], Dict[str, Any]] + :raises ModelViewException: If no get_data method is defined in the model class and the + `GET_DATA_METHOD` is not specified. + """ + in_class_method = getattr(cls.MODEL, 'get_data', None) + if in_class_method: + return in_class_method + if cls.GET_DATA_METHOD: + return cls.GET_DATA_METHOD + raise BaCa2ModelView.ModelViewException( + f'No get_data method found for model {cls.MODEL.__name__}.' + ) + + def get(self, request, *args, **kwargs) -> BaCa2ModelResponse: + """ + Retrieves data for model instances in accordance with the specified get mode and query + parameters. + + - Get mode 'ALL' - Retrieves data for all model instances. + - Get mode 'FILTER' - Retrieves data for model instances matching the filter and/or exclude + parameters encoded in the request url. + + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: JSON response with the result of the action in the form of status and message + string (and data if the action was successful). + :rtype: :class:`BaCa2ModelResponse` + + See also: + - :class:`BaCa2ModelView.GetMode` + - :meth:`BaCa2ModelView.get_all` + - :meth:`BaCa2ModelView.get_filtered` + - :meth:`decode_url_to_dict` + """ + get_params = request.GET.dict() + mode = get_params.get('mode') + + if not mode: + return self.get_request_response( + status=BaCa2JsonResponse.Status.INVALID, + message=_('Failed to retrieve data for model instances due to missing mode param.') + ) + + if get_params.get('filter_params'): + filter_params = decode_url_to_dict(get_params.get('filter_params')) + else: + filter_params = {} + + if get_params.get('exclude_params'): + exclude_params = decode_url_to_dict(get_params.get('exclude_params')) + else: + exclude_params = {} + + if get_params.get('serialize_kwargs'): + serialize_kwargs = decode_url_to_dict(get_params.get('serialize_kwargs')) + else: + serialize_kwargs = {} + + if mode == self.GetMode.ALL.value: + if filter_params or exclude_params: + return self.get_request_response( + status=BaCa2JsonResponse.Status.INVALID, + message=_('Get all retrieval mode does not accept filter or exclude ' + 'parameters.') + ) + + return self.get_all(serialize_kwargs=serialize_kwargs, request=request, **kwargs) + + if mode == self.GetMode.FILTER.value: + return self.get_filtered(filter_params=filter_params, + exclude_params=exclude_params, + serialize_kwargs=serialize_kwargs, + request=request, + **kwargs) + + return self.get_request_response( + status=BaCa2JsonResponse.Status.INVALID, + message=_('Failed to retrieve data for model instances due to invalid get mode ' + 'parameter.') + ) + + def get_all(self, serialize_kwargs: dict, request, **kwargs) -> BaCa2ModelResponse: + """ + :param serialize_kwargs: Kwargs to pass to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: JSON response containing serialized data for all model instances of the model + class managed by the view (provided the user has permission to view them). + :rtype: :class:`BaCa2ModelResponse` + + See also: + - :meth:`BaCa2ModelView.check_get_all_permission` + - :meth:`BaCa2ModelView.get` + - :class:`BaCa2ModelResponse` + """ + if not self.check_get_all_permission(request, serialize_kwargs, **kwargs): + return self.get_request_response( + status=BaCa2JsonResponse.Status.IMPERMISSIBLE, + message=_('Permission denied.') + ) + + try: + return self.get_request_response( + status=BaCa2JsonResponse.Status.SUCCESS, + message=_('Successfully retrieved data for all model instances'), + data=[self.get_data_method()(instance, **serialize_kwargs) + for instance in self.MODEL.objects.all()] + ) + except Exception as e: + return self.get_request_response( + status=BaCa2JsonResponse.Status.ERROR, + message=_('An error occurred while retrieving data for all model instances.'), + data=[str(e)] + ) + + def get_filtered(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + request, + **kwargs) -> BaCa2ModelResponse: + """ + :param filter_params: Query parameters used to construct the filter for the retrieved query + set. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set. + :type exclude_params: dict + :param serialize_kwargs: Kwargs to pass to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: JSON response containing data for model instances matching the specified filter + parameters (provided the user has permission to view them). + :rtype: :class:`BaCa2ModelResponse` + + See also: + - :meth:`BaCa2ModelView.check_get_filtered_permission` + - :meth:`BaCa2ModelView.get` + - :class:`BaCa2ModelResponse` + """ + query_set = self.MODEL.objects.filter(**filter_params).exclude(**exclude_params) + query_result = [obj for obj in query_set] + + if not self.check_get_all_permission(request, serialize_kwargs, **kwargs): + if not self.check_get_filtered_permission(filter_params=filter_params, + exclude_params=exclude_params, + serialize_kwargs=serialize_kwargs, + query_result=query_result, + request=request, + **kwargs): + return self.get_request_response( + status=BaCa2JsonResponse.Status.IMPERMISSIBLE, + message=_('Permission denied.') + ) + + try: + return self.get_request_response( + status=BaCa2JsonResponse.Status.SUCCESS, + message=_('Successfully retrieved data for model instances matching the specified ' + 'filter parameters.'), + data=[self.get_data_method()(obj, **serialize_kwargs) + for obj in query_result] + ) + except Exception as e: + return self.get_request_response( + status=BaCa2JsonResponse.Status.ERROR, + message=_('An error occurred while retrieving data for model instances matching ' + 'the specified filter parameters.'), + data=[str(e)] + ) + + # --------------------------------- get permission checks ---------------------------------- # + + def check_get_all_permission(self, request, serialize_kwargs, **kwargs) -> bool: + """ + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :return: `True` if the user has the 'view' permission for the model class managed by the + view, `False` otherwise. + :rtype: bool + """ + return request.user.has_basic_model_permissions(self.MODEL, BasicModelAction.VIEW) + + def check_get_filtered_permission(self, + filter_params: dict, + exclude_params: dict, + serialize_kwargs: dict, + query_result: List[django.db.models.Model], + request, + **kwargs) -> bool: + """ + Method used to evaluate requesting user's permission to view the model instances matching + the specified query parameters retrieved by the view if the user does not possess the 'view' + permission for all model instances. + + By default, returns `False`. Inheriting classes should override this method if the view + should allow the user to view model instances matching the specified query parameters under + certain conditions. + + :param filter_params: Query parameters used to construct the filter for the retrieved query + set. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set. + :type exclude_params: dict + :param serialize_kwargs: Kwargs passed to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param query_result: Query set retrieved using the specified query parameters evaluated to a + list. + :type query_result: List[django.db.models.Model] + :param request: HTTP GET request object received by the view. + :type request: HttpRequest + :return: `True` if the user has permission to view the model instances matching the query + parameters, `False` otherwise. + :rtype: bool + """ + return False + + # -------------------------------------- post methods -------------------------------------- # + + def post(self, request, **kwargs) -> BaCa2JsonResponse: + """ + Inheriting model views should implement this method to handle post requests from model forms + if necessary. The method should call on the handle_post_request method of the model form + class to validate and process the request. + + :param request: HTTP POST request object received by the view. + :type: HttpRequest + :return: JSON response with the result of the action in the form of status and message + string + :rtype: :class:`BaCa2JsonResponse` + """ + return BaCa2JsonResponse(status=BaCa2JsonResponse.Status.INVALID, + message=_('This view does not handle post requests.')) + + @classmethod + def handle_unknown_form(cls, request, **kwargs) -> BaCa2ModelResponse: + """ + Generates a JSON response returned when the post request contains an unknown form name. + + :return: Error JSON response. + :rtype: :class:`BaCa2ModelResponse` + """ + return BaCa2ModelResponse( + model=cls.MODEL, + action=request.POST.get('action', ''), + status=BaCa2JsonResponse.Status.ERROR, + message=_(f'Unknown form: {request.POST.get("form_name")}') + ) + + # ----------------------------------- auxiliary methods ------------------------------------ # + + def get_request_response(self, + status: BaCa2JsonResponse.Status, + message: str, + data: list = None) -> BaCa2ModelResponse: + """ + :param status: Status of the action. + :type status: :class:`BaCa2JsonResponse.Status` + :param message: Message describing the result of the action. + :type message: str + :param data: Data retrieved by the action (if any). + :type data: list + :return: JSON response with the result of the action in the form of status and message + string (and data if the action was successful). + :rtype: :class:`BaCa2ModelResponse` + """ + return BaCa2ModelResponse(model=self.MODEL, + action=BasicModelAction.VIEW, + status=status, + message=message, + **{'data': data}) + + @classmethod + def _url(cls, **kwargs) -> str: + """ + :return: Base url for the view. Used by :meth:`get_url` method. + """ + return f'/{cls.MODEL._meta.app_label}/models/{cls.MODEL._meta.model_name}/' + + @classmethod + def get_url(cls, + *, + mode: GetMode = GetMode.ALL, + filter_params: dict = None, + exclude_params: dict = None, + serialize_kwargs: dict = None, + **kwargs) -> str: + """ + Returns a URL used to retrieve data from the view in accordance with the specified get mode + and query parameters. + + :param mode: Get mode to use when retrieving data. + :type mode: :class:`BaCa2ModelView.GetMode` + :param filter_params: Query parameters used to construct the filter for the retrieved query + set if the get mode is 'FILTER'. + :type filter_params: dict + :param exclude_params: Query parameters used to construct the exclude filter for the + retrieved query set if the get mode is 'FILTER'. + :type exclude_params: dict + :param serialize_kwargs: Kwargs to pass to the serialization method of the model class + instances retrieved by the view when the JSON response is generated. + :type serialize_kwargs: dict + :param kwargs: Additional keyword arguments to be added to the URL. + :type kwargs: dict + """ + url = cls._url(**kwargs) + + if mode == cls.GetMode.FILTER and not any([filter_params, exclude_params]): + raise BaCa2ModelView.ModelViewException('Query parameters must be specified when ' + 'using filter get mode.') + + if any([filter_params, serialize_kwargs, exclude_params]): + url += '?' + if filter_params: + url += encode_dict_to_url('filter_params', filter_params) + if exclude_params: + url += f'&{encode_dict_to_url("exclude_params", exclude_params)}' + if serialize_kwargs: + url += f'&{encode_dict_to_url("serialize_kwargs", serialize_kwargs)}' + + url_kwargs = {'mode': mode.value} | kwargs + return add_kwargs_to_url(url, url_kwargs) + + @classmethod + def post_url(cls, **kwargs) -> str: + """ + Returns a URL used to send a post request to the view. + + :param kwargs: Additional keyword arguments to be added to the URL. + :type kwargs: dict + """ + return f'{cls._url(**kwargs)}' diff --git a/BaCa2/package/packages/1/tests/set1/2.in b/BaCa2/widgets/__init__.py similarity index 100% rename from BaCa2/package/packages/1/tests/set1/2.in rename to BaCa2/widgets/__init__.py diff --git a/BaCa2/widgets/attachment.py b/BaCa2/widgets/attachment.py new file mode 100644 index 00000000..bb20545b --- /dev/null +++ b/BaCa2/widgets/attachment.py @@ -0,0 +1,47 @@ +from enum import Enum + +from widgets.base import Widget + + +class Attachment(Widget): + class SourceType(Enum): + PAGE = 'page' + FILE = 'file' + + class ContentType(Enum): + UNSPECIFIED_PAGE = 'unspecified_page', 'bi bi-box-arrow-up-right' + UNSPECIFIED_FILE = 'unspecified_file', 'bi bi-download' + + def __init__(self, *, + name: str, + link: str, + title: str = '', + source_type: 'Attachment.SourceType' = None, + content_type: 'Attachment.ContentType' = None) -> None: + super().__init__(name=name) + self.link = link + + if not title: + title = name + self.title = title + + if not source_type: + source_type = self.SourceType.FILE + self.source_type = source_type + + if not content_type: + if source_type == self.SourceType.PAGE: + content_type = self.ContentType.UNSPECIFIED_PAGE + else: + content_type = self.ContentType.UNSPECIFIED_FILE + self.content_type = content_type + self.icon = content_type.value[1] + + def get_context(self) -> dict: + return super().get_context() | { + 'link': self.link, + 'title': self.title, + 'source_type': self.source_type.value, + 'content_type': self.content_type.value[0], + 'icon': self.icon + } diff --git a/BaCa2/widgets/base.py b/BaCa2/widgets/base.py new file mode 100644 index 00000000..22a896b9 --- /dev/null +++ b/BaCa2/widgets/base.py @@ -0,0 +1,52 @@ +from abc import ABC +from typing import Any, Dict + +from django.http import HttpRequest + + +class Widget(ABC): # noqa: B024 + """ + Base abstract class from which all widgets inherit. Contains any shared logic and methods which + all widgets have to implement. + """ + + class WidgetParameterError(Exception): + """ + Exception raised when a widget receives an invalid parameter or combination of parameters. + """ + pass + + def __init__(self, name: str, request: HttpRequest = None, widget_class: str = '') -> None: + """ + Initializes the widget with a name. Name is used to distinguish between widgets of the same + type within a single HTML template. + + :param name: Name of the widget. + :type name: str + :param request: HTTP request object received by the view this widget is rendered in. + :type request: HttpRequest + :param widget_class: CSS class applied to the widget. + :type widget_class: str + """ + self.name = name + self.request = request + self.widget_class = widget_class + + def add_class(self, widget_class: str) -> None: + """ + Adds a CSS class to the widget. + + :param widget_class: CSS class to add. + :type widget_class: str + """ + self.widget_class += ' ' + widget_class + + def get_context(self) -> Dict[str, Any]: + """ + Returns a dictionary containing all the data needed by the template to render the widget. + (All boolean values should be converted to JSON strings.) + + :return: A dictionary containing all the data needed to render the widget. + :rtype: Dict[str, Any] + """ + return {'name': self.name, 'widget_class': self.widget_class} diff --git a/BaCa2/widgets/brief_result_summary.py b/BaCa2/widgets/brief_result_summary.py new file mode 100644 index 00000000..bd3f1244 --- /dev/null +++ b/BaCa2/widgets/brief_result_summary.py @@ -0,0 +1,69 @@ +from django.utils.translation import gettext_lazy as _ + +from course.models import Result +from widgets.base import Widget + + +class BriefResultSummary(Widget): + + def __init__(self, + set_name: str, + test_name: str, + result: Result, + include_time: bool, + include_memory: bool, + show_compile_log: bool = True, + show_checker_log: bool = False, ): + name = f'{set_name}_{test_name}' + super().__init__(name=name) + self.set_name = set_name + self.test_name = test_name + + self.result = result + self.include_time = include_time + self.include_memory = include_memory + self.show_compile_log = show_compile_log + self.show_checker_log = show_checker_log + + def get_result_data(self) -> dict: + from .code_block import CodeBlock + + result_data = self.result.get_data(include_time=self.include_time, + include_memory=self.include_memory, + add_compile_log=self.show_compile_log, + add_checker_log=self.show_checker_log) + if result_data['compile_log']: + compile_log_widget = CodeBlock( + name=f'{self.set_name}_{self.test_name}_compile_log_widget', + code=result_data['compile_log'], + language='log', + title=_('Compile log'), + show_line_numbers=False, + display_wrapper=False + ) + result_data['compile_log_widget'] = compile_log_widget.get_context() + result_data['logs_present'] = True + + if result_data['checker_log']: + checker_log_widget = CodeBlock( + name=f'{self.set_name}_{self.test_name}_checker_log_widget', + code=result_data['checker_log'], + language='log', + title=_('Checker log'), + show_line_numbers=False, + display_wrapper=False + ) + result_data['checker_log_widget'] = checker_log_widget.get_context() + result_data['logs_present'] = True + + if result_data['compile_log'] and result_data['checker_log']: + result_data['multiple_logs'] = True + + return result_data + + def get_context(self) -> dict: + return super().get_context() | { + 'set_name': self.set_name, + 'test_name': self.test_name, + 'result': self.get_result_data() + } diff --git a/BaCa2/widgets/code_block.py b/BaCa2/widgets/code_block.py new file mode 100644 index 00000000..130de091 --- /dev/null +++ b/BaCa2/widgets/code_block.py @@ -0,0 +1,62 @@ +import logging +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + +from widgets.base import Widget + +logger = logging.getLogger(__name__) + + +class CodeBlock(Widget): + class UnknownLanguageError(Exception): + pass + + def __init__(self, + name: str, + code: str | Path, + language: str = None, + title: str = None, + show_line_numbers: bool = True, + wrap_lines: bool = True, + display_wrapper: bool = True): + super().__init__(name=name) + + if not title: + title = _('Code block') + self.title = title + + if isinstance(code, Path): + try: + with open(code, 'r', encoding='utf-8') as f: + self.code = f.read() + self.language = language or code.suffix[1:] + if language: + self.language = language + except Exception as e: + logger.warning(f'Failed to read file {code}: {e.__class__.__name__}: {e}') + self.code = _('ERROR: Failed to read file.\n ' + 'Make sure you have sent valid file as solution.') + self.language = 'log' + else: + self.code = code + self.language = language + + if not self.language: + raise self.UnknownLanguageError('Language must be provided if code is a string.') + + self.show_line_numbers = show_line_numbers + self.display_wrapper = display_wrapper + + if wrap_lines: + self.add_class('wrap-lines') + + def get_context(self) -> dict: + return super().get_context() | { + 'name': self.name, + 'title': self.title, + 'language': self.language, + 'code': self.code, + 'show_line_numbers': self.show_line_numbers, + 'display_wrapper': self.display_wrapper, + } diff --git a/BaCa2/widgets/forms/__init__.py b/BaCa2/widgets/forms/__init__.py new file mode 100644 index 00000000..92923e74 --- /dev/null +++ b/BaCa2/widgets/forms/__init__.py @@ -0,0 +1 @@ +from .base import BaCa2Form, BaCa2ModelForm, FormWidget diff --git a/BaCa2/widgets/forms/base.py b/BaCa2/widgets/forms/base.py new file mode 100644 index 00000000..7d26e38b --- /dev/null +++ b/BaCa2/widgets/forms/base.py @@ -0,0 +1,831 @@ +from __future__ import annotations + +import inspect +from abc import ABC, ABCMeta, abstractmethod +from enum import Enum +from typing import Any, Dict, List, Self + +from django import forms +from django.forms.forms import DeclarativeFieldsMetaclass +from django.utils.translation import gettext_lazy as _ + +from core.choices import ModelAction +from util.models import model_cls +from util.responses import BaCa2JsonResponse, BaCa2ModelResponse +from widgets.base import Widget +from widgets.popups.forms import SubmitConfirmationPopup, SubmitFailurePopup, SubmitSuccessPopup + +# --------------------------------------- baca2 form meta -------------------------------------- # + +class BaCa2FormMeta(DeclarativeFieldsMetaclass, ABCMeta): + """ + Metaclass for all forms inheriting from the :class:`BaCa2Form` base class. Used to handle the + initialization and reconstruction of dynamic form instances from the session data. Reinforces + the requirement for all non-abstract form classes to have a FORM_NAME attribute set and a + request parameter in their __init__ method if they have custom init parameters. + """ + + class SessionDataError(Exception): + """ + Exception raised when an error occurs while attempting to reconstruct a form from the + session data. + """ + pass + + def __init__(cls, name, bases, attrs) -> None: + """ + Initializes the form class. Checks if the form class has appropriate attributes and init + signature. + + :raises ValueError: If the form class is not abstract and does not have a FORM_NAME + attribute set, if a request parameter is not present in its __init__ method when it has + custom init parameters, or if it does not have a **kwargs parameter in its __init__ + """ + super().__init__(name, bases, attrs) + + if not issubclass(cls, ABC) and not hasattr(cls, 'FORM_NAME'): + raise ValueError('All non-abstract form classes must have a FORM_NAME attribute set to ' + 'be used with the BaCa2FormMeta metaclass.') + + signature = inspect.signature(cls.__init__) + param_names = list(signature.parameters.keys()) + + if 'kwargs' not in param_names: + raise ValueError('BaCa2Form classes must have a **kwargs parameter in their __init__ ' + 'method to enable the form to be reconstructed with bound data from a ' + 'post request.') + + non_default_params = [param for param in param_names if param + not in ['self', 'args', 'kwargs', 'form_instance_id', 'request']] + + if non_default_params and 'request' not in param_names: + raise ValueError('BaCa2Form classes with custom init parameters must have a request ' + 'parameter in their __init__ method to enable the form to be saved in ' + 'the session and used to recreate the form upon receiving a post or ' + 'validation request.') + + def __call__(cls, *args, **kwargs) -> Self: + """ + Initializes a new form instance. If the form is instantiated with custom init parameters, + saves the parameters to the session and returns the form instance. If the form is not + instantiated with custom init parameters, returns the form instance as usual. + + :raises BaCa2FormMeta.SessionDataError: If the form is instantiated with custom init + parameters and no request object is passed along with them. + """ + if not args and not kwargs: + return super().__call__() + + signature = inspect.signature(cls.__init__) + param_names = list(signature.parameters.keys()) + named_args = dict(zip(param_names[1:], args)) + named_args.update(kwargs) + request = named_args.pop('request', None) + name = getattr(cls, 'FORM_NAME', None) + + if not request: + raise BaCa2FormMeta.SessionDataError( + 'If a BaCa2Form is instantiated with custom init parameters, a request object must ' + 'be passed along with them so that the parameters can be saved in the session and ' + 'used to recreate the form upon receiving a post or validation request.' + ) + + if len(named_args) == 0: + return cls.reconstruct_from_session(request) + + request.session.setdefault('form_init_params', {}) + form_init_params = request.session.get('form_init_params') + form_init_params.setdefault(name, {}) + form_init_params = form_init_params.get(name) + + form_instance = super().__call__(*args, **kwargs) + instance_id = form_instance.instance_id + form_init_params[instance_id] = named_args + + request.session.save() + + return form_instance + + def reconstruct_from_session(cls, request) -> Self: + """ + Reconstructs a form instance from the session data based on a request object. + + :raises BaCa2FormMeta.SessionDataError: If the form instance ID is not found in the request + or no init parameters are found in the session for the form with the specified name and + instance ID. + """ + name = getattr(cls, 'FORM_NAME', None) + instance_id = request.POST.get('form_instance_id') + + if not instance_id: + instance_id = request.GET.get('form_instance_id') + + if not instance_id: + raise BaCa2FormMeta.SessionDataError('No form instance ID found in the request.') + + init_params = request.session.get('form_init_params', {}).get(name, {}).get(instance_id) + + if not init_params: + raise BaCa2FormMeta.SessionDataError('No init parameters found in the session for the ' + 'form with specified name and instance ID.') + + init_params['data'] = request.POST + init_params['files'] = request.FILES + init_params['request'] = request + + return super().__call__(**init_params) + + def reconstruct(cls, request) -> Self: + """ + Attempts to reconstruct a form instance from the session data based on a request object. If + the reconstruction fails, returns a new form instance based on the request's POST data. + """ + try: + return cls.reconstruct_from_session(request) + except BaCa2FormMeta.SessionDataError: + return super().__call__(data=request.POST, files=request.FILES) + + +class BaCa2ModelFormMeta(BaCa2FormMeta): + """ + Metaclass for all forms inheriting from the :class:`BaCa2ModelForm` base class. Used to set the + FORM_NAME attribute based on the ACTION attribute during the initialization of the form class. + """ + + def __init__(cls, name, bases, attrs) -> None: + """ + Initializes the form class. Sets the FORM_NAME attribute based on the ACTION attribute if + present. + """ + super().__init__(name, bases, attrs) + + action = getattr(cls, 'ACTION', None) + + if action: + cls.FORM_NAME = f'{action.label}_form' + + +# -------------------------------------- base form classes ------------------------------------- # + +class BaCa2Form(forms.Form, ABC, metaclass=BaCa2FormMeta): + """ + Base form for all forms in the BaCa2 system. Contains shared, hidden fields common to all forms. + + See Also: + :class:`BaCa2ModelForm` + :class:`FormWidget` + """ + + #: Name of the form. Used to identify the form class when receiving a POST request and to + #: reconstruct the form from (and save its init parameters to) the session. + FORM_NAME = None + + form_name = forms.CharField( + label=_('Form name'), + max_length=100, + widget=forms.HiddenInput(), + required=True, + initial='form' + ) + form_instance_id = forms.IntegerField( + label=_('Form instance'), + widget=forms.HiddenInput(), + required=True + ) + action = forms.CharField( + label=_('Action'), + max_length=100, + widget=forms.HiddenInput(), + initial='', + ) + + def __init__(self, *, form_instance_id: int = 0, request=None, **kwargs) -> None: + """ + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the parent class constructor. + :type kwargs: dict + """ + + super().__init__(**kwargs) + self.fields['form_name'].initial = self.FORM_NAME + self.instance_id = form_instance_id + self.fields['form_instance_id'].initial = form_instance_id + self.request = request + + def __repr__(self) -> str: + """ + :return: String representation of the form instance containing information about the form's + bound status, validity and fields. + :rtype: str + """ + if self.errors is None: + is_valid = 'Unknown' + else: + is_valid = self.is_bound and not self.errors + return '<%(cls)s bound=%(bound)s, valid=%(valid)s, fields=(%(fields)s)>' % { + 'cls': self.__class__.__name__, + 'bound': self.is_bound, + 'valid': is_valid, + 'fields': ';'.join(self.fields), + } + + def fill_with_data(self, data: Dict[str, str]) -> Self: + """ + Fills the form with the specified data. + + :param data: Dictionary containing the data to be used to fill the form. + :type data: dict + """ + for field in self.fields: + if field in data.keys(): + self.fields[field].initial = data[field] + return self + + +class BaCa2ModelForm(BaCa2Form, ABC, metaclass=BaCa2ModelFormMeta): + """ + Base class for all forms in the BaCa2 app which are used to create, delete or modify model + objects. + + See Also: + :class:`BaCa2Form` + """ + + #: Model class which instances are affected by the form. + MODEL: model_cls = None + #: Action which should be performed using the form data. + ACTION: ModelAction = None + + def __init__(self, *, form_instance_id: int = 0, request=None, **kwargs): + """ + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the parent class constructor. + :type kwargs: dict + """ + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + self.fields['action'].initial = self.ACTION.value + + @classmethod + def handle_post_request(cls, request) -> BaCa2ModelResponse: + """ + Handles the POST request received by the view this form's data was posted to. Based on the + user's permissions and the validity of the form data, returns a JSON response containing + information about the status of the request and a message accompanying it, along with any + additional data needed to process the response. + + :param request: Request object. + :type request: HttpRequest + :return: JSON response to the request. + :rtype: :class:`BaCa2ModelResponse` + """ + if not cls.is_permissible(request): + return BaCa2ModelResponse( + model=cls.MODEL, + action=cls.ACTION, + status=BaCa2JsonResponse.Status.IMPERMISSIBLE, + **cls.handle_impermissible_request(request) + ) + + form = cls.reconstruct(request) + + if form.is_valid(): + try: + return BaCa2ModelResponse( + model=cls.MODEL, + action=cls.ACTION, + status=BaCa2JsonResponse.Status.SUCCESS, + **cls.handle_valid_request(request) + ) + except Exception as e: + messages = [arg for arg in e.args if arg] + + if not messages: + messages = [str(e)] + + return BaCa2ModelResponse( + model=cls.MODEL, + action=cls.ACTION, + status=BaCa2JsonResponse.Status.ERROR, + **cls.handle_error(request, e) | {'errors': messages} + ) + + validation_errors = form.errors + + return BaCa2ModelResponse( + model=cls.MODEL, + action=cls.ACTION, + status=BaCa2JsonResponse.Status.INVALID, + **cls.handle_invalid_request(request, validation_errors) | {'errors': validation_errors} + ) + + @classmethod + @abstractmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + is permissible and the form data is valid. + + :param request: Request object. + :type request: HttpRequest + :return: Dictionary containing a success message and any additional data to be included in + the response. + :rtype: Dict[str, Any] + """ + raise NotImplementedError('This method has to be implemented by inheriting classes.') + + @classmethod + def handle_invalid_request(cls, request, errors: dict) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + is permissible but the form data is invalid. + + :param request: Request object. + :type request: HttpRequest + :param errors: Dictionary containing information about the errors found in the form data. + :type errors: dict + :return: Dictionary containing a failure message. If the form widget has a submit failure + popup, the message will be displayed in the popup followed by information about the + errors found in the form data. + :rtype: Dict[str, Any] + """ + return {'message': _('Invalid form data. Please correct the following errors:')} + + @classmethod + def handle_impermissible_request(cls, request) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + is impermissible. + + :param request: Request object. + :type request: HttpRequest + :return: Dictionary containing a failure message. + :rtype: Dict[str, Any] + """ + return {'message': _('Request failed due to insufficient permissions.')} + + @classmethod + def handle_error(cls, request, error: Exception) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + resulted in an error unrelated to form validation or the user's permissions. + + :param request: Request object. + :type request: HttpRequest + :param error: Error which occurred while processing the request. + :type error: Exception + :return: Dictionary containing a failure message. If the form widget has a submit failure + popup, the message will be displayed in the popup followed by information about the + error which occurred. + :rtype: Dict[str, Any] + """ + return {'message': _('Following error occurred while processing the request:')} + + @classmethod + def is_permissible(cls, request) -> bool: + """ + Checks whether the user has the permission to perform the action specified by the form. + + :param request: Request object. + :type request: HttpRequest + + :return: `True` if the user has the permission to perform the action specified by the form, + `False` otherwise. + :rtype: bool + """ + return request.user.has_permission(cls.ACTION.label) + + +# ----------------------------------------- form widget ---------------------------------------- # + +class FormWidget(Widget): + """ + Base :class:`Widget` class for all form widgets. Responsible for generating the context + dictionary needed to render a form in accordance with the specified parameters. + + Templates used for rendering forms are located in the `BaCa2/templates/widget_templates/forms` + directory. The default template used for rendering forms is `default.html`. Any custom form + templates should extend the `base.html` template. + + See Also: + - :class:`FormPostTarget` + - :class:`FormElementGroup` + - :class:`SubmitConfirmationPopup` + - :class:`FormSuccessPopup` + - :class:`FormFailurePopup` + """ + + def __init__(self, + *, + request, + form: forms.Form, + post_target_url: str = '', + name: str = '', + button_text: str = None, + refresh_button: bool = True, + display_non_field_validation: bool = True, + display_field_errors: bool = True, + floating_labels: bool = True, + element_groups: FormElementGroup | List[FormElementGroup] = None, + toggleable_fields: List[str] = None, + toggleable_params: Dict[str, Dict[str, str]] = None, + live_validation: bool = True, + form_observer: FormObserver = None, + submit_confirmation_popup: SubmitConfirmationPopup = None, + submit_success_popup: SubmitSuccessPopup = None, + submit_failure_popup: SubmitFailurePopup = None) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param form: Form to be rendered. + :type form: forms.Form + :param post_target_url: Target URL for the form's POST request. If no target is specified, + the form will be posted to the same URL as the page it is rendered on. + :type post_target_url: str + :param name: Name of the widget. If no name is specified, the name of the form will be used + to generate the widget name. + :type name: str + :param button_text: Text displayed on the submit button. + :type button_text: str + :param refresh_button: Determines whether the form should have a refresh button. + :type refresh_button: bool + :param display_non_field_validation: Determines whether non-field validation errors should + be displayed. + :type display_non_field_validation: bool + :param display_field_errors: Determines whether field errors should be displayed. + :type display_field_errors: bool + :param floating_labels: Determines whether the form should use floating labels. + :type floating_labels: bool + :param element_groups: Groups of form elements. Used to create more complex form layouts or + assign certain behaviors to groups of fields. + :type element_groups: :class:`FormElementGroup` | List[:class:`FormElementGroup`] + :param toggleable_fields: List of names of fields which should be rendered as toggleable. + :type toggleable_fields: List[str] + :param toggleable_params: Parameters for toggleable fields. Each field name should be a key + in the dictionary. The value of each key should be a dictionary containing the + 'button_text_on' and 'button_text_off' keys. The values of these keys will be used as + the text displayed on the toggle button when the field is enabled and disabled + respectively. + :type toggleable_params: Dict[str, Dict[str, str]] + :param live_validation: Determines whether the form should use live validation. Password + fields will always be excluded from live validation. + :type live_validation: bool + :param form_observer: Determines the rendering and behavior of the form observer. If no + observer is specified, no observer will be rendered. + :type form_observer: :class:`FormObserver` + :param submit_confirmation_popup: Determines the rendering of the confirmation popup shown + before submitting the form. If no popup is specified, no popup will be shown and the + form will be submitted immediately upon clicking the submit button. + :type submit_confirmation_popup: :class:`SubmitConfirmationPopup` + :param submit_success_popup: Determines the rendering of the success popup shown after + submitting the form and receiving a successful response. If no popup is specified, no + popup will be shown. + :type submit_success_popup: :class:`SubmitSuccessPopup` + :param submit_failure_popup: Determines the rendering of the failure popup shown after + submitting the form and receiving an unsuccessful response. If no popup is specified, no + popup will be shown. + :type submit_failure_popup: :class:`SubmitFailurePopup` + :raises Widget.WidgetParameterError: If no name is specified and the form passed to the + widget does not have a name. If submit success popup or submit failure popup is + specified without the other. + """ + if button_text is None: + button_text = _('Submit') + if submit_success_popup is None: + submit_success_popup = SubmitSuccessPopup() + if submit_failure_popup is None: + submit_failure_popup = SubmitFailurePopup() + + if not name: + form_name = getattr(form, 'form_name', False) + if form_name: + name = f'{form_name}_widget' + else: + raise Widget.WidgetParameterError( + 'Cannot create form widget for an unnamed form without specifying the widget ' + 'name.' + ) + + if (submit_success_popup is None) ^ (submit_failure_popup is None): + raise Widget.WidgetParameterError( + 'Both submit success popup and submit failure popup must be specified or neither.' + ) + + super().__init__(name=name, request=request) + self.form = form + self.form_cls = form.__class__.__name__ + self.post_url = post_target_url + self.button_text = button_text + self.refresh_button = refresh_button + self.display_non_field_validation = display_non_field_validation + self.display_field_errors = display_field_errors + self.floating_labels = floating_labels + self.live_validation = live_validation + self.show_response_popups = False + + if form_observer: + form_observer.name = f'{self.name}_observer' + form_observer = form_observer.get_context() + self.form_observer = form_observer + + if submit_confirmation_popup: + submit_confirmation_popup.name = f'{self.name}_confirmation_popup' + submit_confirmation_popup.request = request + submit_confirmation_popup = submit_confirmation_popup.get_context() + self.submit_confirmation_popup = submit_confirmation_popup + + if submit_success_popup: + self.show_response_popups = True + submit_success_popup.name = f'{self.name}_success_popup' + submit_success_popup.request = request + submit_success_popup = submit_success_popup.get_context() + self.submit_success_popup = submit_success_popup + + if submit_failure_popup: + submit_failure_popup.name = f'{self.name}_failure_popup' + submit_failure_popup.request = request + submit_failure_popup = submit_failure_popup.get_context() + self.submit_failure_popup = submit_failure_popup + + if not element_groups: + element_groups = [] + if not isinstance(element_groups, list): + element_groups = [element_groups] + elements = [] + included_fields = {field.name: False for field in form} + + for field in form: + if included_fields[field.name]: + continue + + for group in element_groups: + group.request = request + if group.field_in_group(field.name): + elements.append(group) + included_fields.update({field_name: True for field_name in group.fields()}) + break + + if not included_fields[field.name]: + elements.append(field.name) + included_fields[field.name] = True + + self.elements = FormElementGroup(elements=elements, name='form_elements') + + if not toggleable_fields: + toggleable_fields = [] + self.toggleable_fields = toggleable_fields + + if not toggleable_params: + toggleable_params = {} + self.toggleable_params = toggleable_params + + for field in toggleable_fields: + if field not in toggleable_params.keys(): + toggleable_params[field] = {} + for params in toggleable_params.values(): + if 'button_text_on' not in params.keys(): + params['button_text_on'] = _('Generate automatically') + if 'button_text_off' not in params.keys(): + params['button_text_off'] = _('Enter manually') + + self.field_classes = { + field_name: self.form.fields[field_name].__class__.__name__ + for field_name in self.form.fields.keys() + } + + self.field_required = { + field_name: self.form.fields[field_name].required + for field_name in self.form.fields.keys() + } + + self.field_min_length = {} + for field_name in self.form.fields.keys(): + if hasattr(self.form.fields[field_name], 'min_length'): + self.field_min_length[field_name] = self.form.fields[field_name].min_length + else: + self.field_min_length[field_name] = False + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'form': self.form, + 'form_cls': self.form_cls, + 'post_url': self.post_url, + 'elements': self.elements, + 'button_text': self.button_text, + 'refresh_button': self.refresh_button, + 'display_non_field_errors': self.display_non_field_validation, + 'display_field_errors': self.display_field_errors, + 'floating_labels': self.floating_labels, + 'toggleable_fields': self.toggleable_fields, + 'toggleable_params': self.toggleable_params, + 'field_classes': self.field_classes, + 'field_required': self.field_required, + 'field_min_length': self.field_min_length, + 'live_validation': self.live_validation, + 'form_observer': self.form_observer, + 'submit_confirmation_popup': self.submit_confirmation_popup, + 'show_response_popups': self.show_response_popups, + 'submit_failure_popup': self.submit_failure_popup, + 'submit_success_popup': self.submit_success_popup, + } + + +# -------------------------------------- form element group ------------------------------------ # + +class FormElementGroup(Widget): + """ + Class used to group form elements together. Used to dictate the layout or assign certain + behaviors to groups of fields. More complex form layouts can be created by nesting multiple + form element groups. + + See Also: + - :class:`FormWidget` + """ + + class FormElementsLayout(Enum): + """ + Enum used to specify the layout of form elements in a group. + """ + #: Form elements will be displayed in a single row. + HORIZONTAL = 'horizontal' + #: Form elements will be displayed in a single column. + VERTICAL = 'vertical' + + def __init__(self, + *, + elements: List[str | FormElementGroup], + name: str, + title: str = '', + display_title: bool = False, + request=None, + layout: FormElementsLayout = FormElementsLayout.VERTICAL, + toggleable: bool = False, + toggleable_params: Dict[str, str] = None, + frame: bool = False) -> None: + """ + :param elements: Form elements to be included in the group, specified as a list of field + names or other form element groups. + :type elements: List[str | :class:`FormElementGroup`] + :param name: Name of the group. + :type name: str + :param title: Title of the group. If no title is specified, the group will not have a title. + :type title: str + :param display_title: Determines whether the title should be displayed above the group. + :type display_title: bool + :param request: HTTP request object received by the parent form widget. + :type request: HttpRequest + :param layout: Layout of the form elements in the group. + :type layout: :class:`FormElementGroup.FormElementsLayout` + :param toggleable: Determines whether the group should be toggleable. + :type toggleable: bool + :param toggleable_params: Parameters for the toggleable group. Should contain the + 'button_text_on' and 'button_text_off' keys. The values of these keys will be used as + the text displayed on the toggle button when the group is enabled and disabled + respectively. If no parameters are specified, default values will be used. + :type toggleable_params: Dict[str, str] + :param frame: Determines whether the group should be displayed in a frame. + :type frame: bool + :raises ValueError: If no title is specified and the display_title parameter is set to True. + """ + super().__init__(name=name, request=request) + + if not title and display_title: + raise ValueError('A title must be specified if the display_title parameter is set ' + 'to True.') + + self.title = title + self.display_title = display_title + self.elements = elements + self.toggleable = toggleable + self.layout = layout.value + self.frame = frame + + if not toggleable_params: + toggleable_params = {} + self.toggleable_params = toggleable_params + + if toggleable: + if 'button_text_on' not in toggleable_params.keys(): + toggleable_params['button_text_on'] = _('Generate automatically') + if 'button_text_off' not in toggleable_params.keys(): + toggleable_params['button_text_off'] = _('Enter manually') + + def field_in_group(self, field_name: str) -> bool: + """ + Checks whether the specified field is included in the group. + + :param field_name: Name of the field to check. + :type field_name: str + :return: `True` if the field is included in the group, `False` otherwise. + :rtype: bool + """ + if field_name in self.elements: + return True + for element in self.elements: + if isinstance(element, FormElementGroup): + if element.field_in_group(field_name): + return True + return False + + def fields(self) -> List[str]: + """ + Returns a list containing the names of all fields included in the group. + + :return: List of field names. + :rtype: List[str] + """ + fields = [] + for element in self.elements: + if isinstance(element, FormElementGroup): + fields.extend(element.fields()) + else: + fields.append(element) + return fields + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'title': self.title, + 'display_title': self.display_title, + 'elements': self.elements, + 'layout': self.layout, + 'toggleable': self.toggleable, + 'toggleable_params': self.toggleable_params, + 'frame': self.frame + } + + +# ------------------------------------ form observer widget ------------------------------------ # + +class FormObserver(Widget): + class Position(Enum): + TOP = 'top' + BOTTOM = 'bottom' + + def __init__(self, *, + name: str = '', + title: str = '', + placeholder_text: str = '', + display_element_group_titles: bool = True, + tabs: List[FormObserverTab] = None, + position: FormObserver.Position = None) -> None: + super().__init__(name=name) + + if not title: + title = _('Summary of changes') + self.title = title + + if not placeholder_text: + placeholder_text = _('No changes made') + self.placeholder_text = placeholder_text + + self.display_element_group_titles = display_element_group_titles + + if not tabs: + tabs = [] + self.tabs = tabs + + if not position: + position = FormObserver.Position.TOP + self.position = position.value + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'title': self.title, + 'placeholder_text': self.placeholder_text, + 'element_group_titles': self.display_element_group_titles, + 'position': self.position, + 'tabs': [tab.get_context() for tab in self.tabs] + } + + +class FormObserverTab: + def __init__(self, *, name: str, title: str = '', fields: List[str]) -> None: + if not fields: + raise ValueError('At least one field must be specified for the tab.') + + self.name = name + self.fields = fields + + if not title: + title = name + self.title = title + + def get_context(self) -> Dict[str, str]: + return { + 'name': self.name, + 'title': self.title, + 'fields': ' '.join(self.fields) + } diff --git a/BaCa2/widgets/forms/course.py b/BaCa2/widgets/forms/course.py new file mode 100644 index 00000000..107bfb25 --- /dev/null +++ b/BaCa2/widgets/forms/course.py @@ -0,0 +1,2394 @@ +import logging +from abc import ABC, abstractmethod +from typing import Any, Dict, List + +from django import forms +from django.conf import settings +from django.utils.translation import gettext_lazy as _ + +from core.choices import FallOffPolicy, ResultStatus, ScoreSelectionPolicy, TaskJudgingMode +from core.tools.files import CsvFileHandler, FileHandler +from core.tools.mailer import TemplateMailer +from course.models import Round, Submit, Task +from course.routing import InCourse +from main.models import Course, Role, User +from package.models import PackageInstance +from util.models_registry import ModelsRegistry +from widgets.forms.base import ( + BaCa2ModelForm, + FormElementGroup, + FormObserver, + FormObserverTab, + FormWidget +) +from widgets.forms.fields import ( + AlphanumericStringField, + ChoiceField, + DateTimeField, + FileUploadField, + ModelChoiceField +) +from widgets.forms.fields.course import CourseName, CourseShortName, USOSCode +from widgets.forms.fields.table_select import TableSelectField +from widgets.listing.columns import TextColumn +from widgets.navigation import SideNav +from widgets.popups.forms import SubmitConfirmationPopup, SubmitFailurePopup, SubmitSuccessPopup + +logger = logging.getLogger(__name__) + + +# ---------------------------------- course form base classes ---------------------------------- # + +class CourseModelForm(BaCa2ModelForm): + """ + Base class for all forms in the BaCa2 app which are used to create, delete or modify course + database model objects. + + See also: + - :class:`BaCa2ModelForm` + - :class:`CourseActionForm` + """ + + @classmethod + @abstractmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + is permissible and the form data is valid. + + :param request: Request object. + :type request: HttpRequest + :return: Dictionary containing a success message and any additional data to be included in + the response. + :rtype: Dict[str, Any] + """ + raise NotImplementedError('This method has to be implemented by inheriting classes.') + + @classmethod + def is_permissible(cls, request) -> bool: + """ + Checks if the user making the request has the permission to perform the action specified by + the form within the scope of the current course. + """ + return request.user.has_course_permission(cls.ACTION.label, InCourse.get_context_course()) + + +class CourseActionForm(BaCa2ModelForm, ABC): + """ + Base class for all forms in the BaCa2 app which are used to perform course-specific actions + on default database model objects (such as adding or removing members, creating roles, etc.). + All form widgets wrapping this form should post to the course model view url with a specified + `course_id` parameter. The form's `is_permissible` checks if the requesting user has the + permission to perform its action within the scope of the course with the given ID. + + See also: + - :class:`BaCa2ModelForm` + - :class:`Course.CourseAction` + """ + + #: Actions performed by this type of form always concern the :class:`Course` model. + MODEL = Course + #: Course action to be performed by the form. + ACTION: Course.CourseAction = None + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Handles the POST request received by the view this form's data was posted to if the request + is permissible and the form data is valid. + + :param request: Request object. + :type request: HttpRequest + :return: Dictionary containing a success message and any additional data to be included in + the response. + :rtype: Dict[str, Any] + """ + raise NotImplementedError('This method has to be implemented by inheriting classes.') + + @staticmethod + def get_context_course(request) -> Course: + """ + :return: Course object matching the course ID provided in the request. + :rtype: Course + :raises ValueError: If the request object does not have a course attribute or the course ID + is not provided in the request. + """ + if not hasattr(request, 'course'): + raise ValueError('Request object does not have a course attribute') + + course_id = request.course.get('course_id') + + if not course_id: + raise ValueError('Course ID not provided in the request') + + return ModelsRegistry.get_course(int(course_id)) + + @classmethod + def is_permissible(cls, request) -> bool: + """ + :param request: Request object. + :type request: HttpRequest + :return: `True` if the user has the permission to perform the action specified by the form + within the scope of the course with the ID provided in the request, `False` otherwise. + :rtype: bool + """ + course = cls.get_context_course(request) + return request.user.has_course_permission(cls.ACTION.label, course) + + +# =========================================== COURSE =========================================== # + +# ---------------------------------------- create course --------------------------------------- # + +class CreateCourseForm(BaCa2ModelForm): + """ + Form for creating new :class:`main.Course` records. + """ + + MODEL = Course + ACTION = Course.BasicAction.ADD + + #: New course's name. + course_name = CourseName(label=_('Course name'), required=True) + + #: New course's short name. + short_name = CourseShortName() + + #: Subject code of the course in the USOS system. + USOS_course_code = USOSCode( + label=_('USOS course code'), + max_length=Course._meta.get_field('USOS_course_code').max_length, + required=False + ) + + #: Term code of the course in the USOS system. + USOS_term_code = USOSCode( + label=_('USOS term code'), + max_length=Course._meta.get_field('USOS_term_code').max_length, + required=False + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Creates a new :class:`Course` object based on the data provided in the request. + + :param request: POST request containing the course data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + Course.objects.create_course( + name=request.POST.get('course_name'), + short_name=request.POST.get('short_name'), + usos_course_code=request.POST.get('USOS_course_code'), + usos_term_code=request.POST.get('USOS_term_code') + ) + + message = _('Course ') + request.POST.get('course_name') + _(' created successfully') + return {'message': message} + + +class CreateCourseFormWidget(FormWidget): + """ + Form widget for the :class:`CreateCourseForm`. + """ + + def __init__(self, + request, + form: CreateCourseForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param form: Form to be base the widget on. If not provided, a new form will be created. + :type form: :class:`CreateCourseForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = CreateCourseForm() + + super().__init__( + name='create_course_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(), + button_text=_('Add course'), + toggleable_fields=['short_name'], + element_groups=FormElementGroup( + elements=['USOS_course_code', 'USOS_term_code'], + name='USOS_data', + toggleable=True, + toggleable_params={'button_text_off': _('Add USOS data'), + 'button_text_on': _('Create without USOS data')}, + frame=True, + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ), + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm course creation'), + message=_( + 'Are you sure you want to create a new course with the following data?' + ), + confirm_button_text=_('Create course'), + input_summary=True, + input_summary_fields=['course_name', + 'short_name', + 'USOS_course_code', + 'USOS_term_code'], + ), + **kwargs + ) + + +# ---------------------------------------- delete course --------------------------------------- # + +class DeleteCourseForm(BaCa2ModelForm): + """ + Form for deleting :class:`main.Course` records. + """ + + MODEL = Course + ACTION = Course.BasicAction.DEL + + #: ID of the course to be deleted. + course_id = forms.IntegerField( + label=_('Course ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True, + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Deletes the course with the ID provided in the request. + + :param request: POST request containing the course ID. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + Course.objects.delete_course(int(request.POST.get('course_id'))) + return {'message': _('Course deleted successfully')} + + +class DeleteCourseFormWidget(FormWidget): + """ + Form widget for the :class:`DeleteCourseForm`. + """ + + def __init__(self, + request, + form: DeleteCourseForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param form: Form to be base the widget on. If not provided, a new form will be created. + :type form: :class:`DeleteCourseForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = DeleteCourseForm() + + super().__init__( + name='delete_course_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(), + button_text=_('Delete course'), + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm course deletion'), + message=_( + 'Are you sure you want to delete this course? This action cannot be undone.' + ), + confirm_button_text=_('Delete course'), + ), + **kwargs + ) + + +# =========================================== MEMBERS ========================================== # + +# ----------------------------------------- add members ---------------------------------------- # + +class AddMemberForm(CourseActionForm): + """ + Form for adding members to a course with a specified role. + """ + + ACTION = Course.CourseAction.ADD_MEMBER + + #: Users to be added to the course. + email = forms.EmailField( + label=_('Email'), + required=True, + help_text=_('Email from UJ domain, or already registered in the system.') + ) + + #: Role to be assigned to the newly added members. + role = ModelChoiceField( + label=_('Role'), + data_source_url='', + label_format_string='[[name]]', + required=True, + ) + + def __init__(self, + *, + course_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseActionForm` + super constructor. + :type kwargs: dict + """ + from main.views import RoleModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['role'].data_source_url = RoleModelView.get_url( + mode=RoleModelView.GetMode.FILTER, + filter_params={'course': course_id} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Adds the users selected in the form to the course with the role specified in the form. + + :param request: POST request containing the users and role data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + course = cls.get_context_course(request) + email = request.POST.get('email') + user = User.objects.get_or_create_if_allowed(email=email) + role = int(request.POST.get('role')) + + if role == course.admin_role.id: + course.add_admin(user) + else: + course.add_member(user, role) + + mailer = TemplateMailer( + mail_to=user.email, + subject=_('You have been added to a course'), + template='add_to_course', + context={'course_name': course.name} + ) + mailer.send() + + return {'message': _('Member added successfully')} + + @classmethod + def is_permissible(cls, request) -> bool: + """ + :param request: Request object. + :type request: HttpRequest + :return: `True` if the requesting user has the permission to add members to the course + specified in the request, `False` otherwise. If the role specified in the request is the + admin role, the user must have the `ADD_ADMIN` permission. + :rtype: bool + """ + course = cls.get_context_course(request) + role = int(request.POST.get('role')) + + if role == course.admin_role.id: + return request.user.has_course_permission(Course.CourseAction.ADD_ADMIN.label, course) + + return request.user.has_course_permission(cls.ACTION.label, course) + + +class AddMemberFormWidget(FormWidget): + """ + Form widget for the :class:`AddMemberForm`. + """ + + def __init__(self, + request, + course_id: int, + form: AddMemberForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: AddMemberForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`AddMembers` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = AddMemberForm(course_id=course_id, request=request) + + super().__init__( + name='add_member_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Add new member'), + **kwargs + ) + + +class AddMembersFromCSVForm(CourseActionForm): + """ + Form for adding members from a CSV file to a course. + """ + + ACTION = Course.CourseAction.ADD_MEMBERS_CSV + + #: CSV file containing the members to be added to the course. + members_csv = FileUploadField(label=_('Members CSV file'), + allowed_extensions=['csv'], + required=True) + + #: Role to be assigned to the newly added members. + role = ModelChoiceField( + label=_('Role'), + data_source_url='', + label_format_string='[[name]]', + required=True, + ) + + def __init__(self, *, + course_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseActionForm` + super constructor. + :type kwargs: dict + """ + from main.views import RoleModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['role'].data_source_url = RoleModelView.get_url( + mode=RoleModelView.GetMode.FILTER, + filter_params={'course': course_id} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Adds the members from the CSV file to the course. + + :param request: POST request containing the CSV file. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, Any] + """ + course = cls.get_context_course(request) + + role = int(request.POST.get('role')) + + fieldnames = ['Imię', 'Nazwisko', 'E-mail'] + file = CsvFileHandler(path=settings.UPLOAD_DIR, + file_data=request.FILES['members_csv'], + fieldnames=fieldnames) + file.save() + _fieldnames, members_to_add = file.read_csv(force_fieldnames=fieldnames, + ignore_first_line=True) + file.validate() + + users_to_add = [] + for member in members_to_add: + users_to_add.append(User.objects.get_or_create( + email=member['E-mail'], + first_name=member['Imię'], + last_name=member['Nazwisko'] + )) + course.add_members(users_to_add, + role, + ignore_errors=True) + + mailer = TemplateMailer( + mail_to=[user.email for user in users_to_add], + subject=_('You have been added to a course'), + template='add_to_course', + context={'course_name': course.name} + ) + mailer.send() + + return {'message': _('Members added successfully')} + + @classmethod + def is_permissible(cls, request) -> bool: + """ + :param request: Request object. + :type request: HttpRequest + :return: `True` if the requesting user has the permission to add members to the course + specified in the request, `False` otherwise. If the role specified in the request is the + admin role, the user must have the `ADD_ADMIN` permission. + :rtype: bool + """ + course = cls.get_context_course(request) + role = int(request.POST.get('role')) + + if role == course.admin_role.id: + return request.user.has_course_permission(Course.CourseAction.ADD_ADMIN.label, course) + + return request.user.has_course_permission(cls.ACTION.label, course) + + +class AddMembersFromCSVFormWidget(FormWidget): + """ + Form widget for the :class:`AddMembersFromCSVForm`. + """ + + def __init__(self, + request, + course_id: int, + form: AddMembersFromCSVForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: AddMembersFromCSVForm to be base the widget on. If not provided, a new form + will be created. + :type form: :class:`AddMembersFromCSVForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = AddMembersFromCSVForm(course_id=course_id, request=request) + + super().__init__( + name='add_members_from_csv_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Add members from CSV'), + **kwargs + ) + + +# --------------------------------------- remove members --------------------------------------- # + + +class RemoveMembersForm(CourseActionForm): + """ + Form for removing members from a course. + """ + + ACTION = Course.CourseAction.DEL_MEMBER + + #: Users to be removed from the course. + members = TableSelectField( + label=_('Choose members to remove'), + table_widget_name='remove_members_table_widget', + data_source_url='', + cols=[TextColumn(name='email', header=_('Email')), + TextColumn(name='first_name', header=_('First name')), + TextColumn(name='last_name', header=_('Last name')), + TextColumn(name='user_role', header=_('Role'))], + ) + + def __init__(self, + *, + course_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseActionForm` + super constructor. + :type kwargs: dict + """ + from main.views import UserModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['members'].data_source_url = UserModelView.get_url( + mode=UserModelView.GetMode.FILTER, + filter_params={'roles__course': course_id}, + exclude_params={'id': request.user.id}, + serialize_kwargs={'course': course_id} + ) # TODO: exclude admins + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Removes the users selected in the form from the course. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + :raises forms.ValidationError: If the request contains an attempt to remove an admin or the + requesting user. + """ + + course = cls.get_context_course(request) + users = TableSelectField.parse_value(request.POST.get('members')) + + if any([course.user_is_admin(user) for user in users]): + raise forms.ValidationError(_('You cannot remove an admin from the course')) + if any([user == request.user for user in users]): + raise forms.ValidationError(_('You cannot remove yourself from the course')) + + course.remove_members(users) + return {'message': _('Members removed successfully')} + + +class RemoveMembersFormWidget(FormWidget): + """ + Form widget for the :class:`RemoveMembersForm`. + """ + + def __init__(self, + request, + course_id: int, + form: RemoveMembersForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: RemoveMembersForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`RemoveMembersForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = RemoveMembersForm(course_id=course_id, request=request) + + super().__init__( + name='remove_members_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Remove members'), + **kwargs + ) + + +# ============================================ ROLE ============================================ # + +# ----------------------------------------- create role ---------------------------------------- # + +class AddRoleForm(CourseActionForm): + """ + Form for adding new :class:`main.Role` objects to a course. + """ + + ACTION = Course.CourseAction.ADD_ROLE + + #: Name of the new role. + role_name = AlphanumericStringField(label=_('Role name'), + required=True, + min_length=4, + max_length=Role._meta.get_field('name').max_length) + + #: Description of the new role. + role_description = forms.CharField(label=_('Description'), + required=False, + widget=forms.Textarea(attrs={'rows': 3})) + + #: Permissions to be assigned to the new role. + role_permissions = TableSelectField(label=_('Choose role permissions'), + table_widget_name='role_permissions_table_widget', + data_source_url='', + cols=[TextColumn(name='codename', header=_('Codename')), + TextColumn(name='name', header=_('Description'))], + table_widget_kwargs={'table_height': 35}) + + def __init__(self, **kwargs) -> None: + from main.views import PermissionModelView + + super().__init__(**kwargs) + + self.fields['role_permissions'].data_source_url = PermissionModelView.get_url( + mode=PermissionModelView.GetMode.FILTER, + filter_params={'codename__in': Course.CourseAction.labels} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Creates a new :class:`main.Role` object based on the data provided in the request and adds + it to the course. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + course = cls.get_context_course(request) + role_name = request.POST.get('role_name') + role_description = request.POST.get('role_description', '') + permissions = TableSelectField.parse_value(request.POST.get('role_permissions')) + + course.create_role(name=role_name, description=role_description, permissions=permissions) + + return {'message': _('Role ') + role_name + _(' created successfully')} + + +class AddRoleFormWidget(FormWidget): + """ + Form widget for the :class:`AddRoleForm`. + """ + + def __init__(self, + request, + course_id: int, + form: AddRoleForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: AddRoleForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`AddRoleForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from main.views import CourseModelView + + if not form: + form = AddRoleForm() + + super().__init__( + name='add_role_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Add role'), + **kwargs + ) + + +# ----------------------------------------- delete role ---------------------------------------- # + +class DeleteRoleForm(CourseActionForm): + """ + Form for deleting :class:`main.Role` records. + """ + + ACTION = Course.CourseAction.DEL_ROLE + + #: ID of the role to be deleted. + role_id = forms.IntegerField( + label=_('Role ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Deletes the role with the ID provided in the request. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + course = cls.get_context_course(request) + role = ModelsRegistry.get_role(int(request.POST.get('role_id'))) + role_name = role.name + course.remove_role(role) + return {'message': _('Role ') + role_name + _(' deleted successfully')} + + +# ------------------------------------ add role permissions ------------------------------------ # + +class AddRolePermissionsForm(CourseActionForm): + ACTION = Course.CourseAction.EDIT_ROLE + + role_id = forms.IntegerField(label=_('Task ID'), + widget=forms.HiddenInput(), + required=True) + + # noinspection PyTypeChecker + permissions_to_add = TableSelectField( + label=_('Choose permissions to add'), + table_widget_name='permissions_to_add_table_widget', + data_source_url='', + cols=[TextColumn(name='codename', header=_('Codename')), + TextColumn(name='name', header=_('Description'))], + table_widget_kwargs={'table_height': 35} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + role = ModelsRegistry.get_role(int(request.POST.get('role_id'))) + perms = TableSelectField.parse_value(request.POST.get('permissions_to_add')) + role.add_permissions(perms) + return {'message': _('Permissions added successfully')} + + +class AddRolePermissionsFormWidget(FormWidget): + def __init__(self, + *, + request, + course_id: int, + role_id: int, + form: AddRolePermissionsForm = None, + **kwargs) -> None: + from main.views import CourseModelView, PermissionModelView + + if not form: + form = AddRolePermissionsForm() + + codenames = Course.CourseAction.labels + + form.fields['permissions_to_add'].data_source_url = PermissionModelView.get_url( + mode=PermissionModelView.GetMode.FILTER, + filter_params={'codename__in': codenames}, + exclude_params={'role': role_id} + ) + form.fields['role_id'].initial = role_id + + super().__init__( + name='add_role_permissions_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Add permissions'), + **kwargs + ) + + +# ----------------------------------- remove role permissions ---------------------------------- # + +class RemoveRolePermissionsForm(CourseActionForm): + ACTION = Course.CourseAction.EDIT_ROLE + + role_id = forms.IntegerField(label=_('Task ID'), + widget=forms.HiddenInput(), + required=True) + + permissions_to_remove = TableSelectField( + label=_('Choose permissions to remove'), + table_widget_name='permissions_to_remove_table_widget', + data_source_url='', + cols=[TextColumn(name='codename', header=_('Codename')), + TextColumn(name='name', header=_('Description'))], + table_widget_kwargs={'table_height': 35} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + role = ModelsRegistry.get_role(int(request.POST.get('role_id'))) + perms = TableSelectField.parse_value(request.POST.get('permissions_to_remove')) + role.remove_permissions(perms) + return {'message': _('Permissions removed successfully')} + + +class RemoveRolePermissionsFormWidget(FormWidget): + def __init__(self, + *, + request, + course_id: int, + role_id: int, + form: RemoveRolePermissionsForm = None, + **kwargs) -> None: + from main.views import CourseModelView, PermissionModelView + + if not form: + form = RemoveRolePermissionsForm() + + form.fields['permissions_to_remove'].data_source_url = PermissionModelView.get_url( + mode=PermissionModelView.GetMode.FILTER, + filter_params={'role': role_id} + ) + form.fields['role_id'].initial = role_id + + super().__init__( + name='remove_role_permissions_form_widget', + request=request, + form=form, + post_target_url=CourseModelView.post_url(**{'course_id': course_id}), + button_text=_('Remove permissions'), + **kwargs + ) + + +# ============================================ ROUND =========================================== # + +# ---------------------------------------- create round ---------------------------------------- # + +class CreateRoundForm(CourseModelForm): + """ + Form for creating new :class:`course.Round` records. + """ + + MODEL = Round + ACTION = Round.BasicAction.ADD + + #: Name of the new round. + round_name = AlphanumericStringField(label=_('Round name'), required=True) + #: Score selection policy of the new round. + score_selection_policy = ChoiceField(label=_('Score selection policy'), + choices=ScoreSelectionPolicy.choices, + required=True, + placeholder_default_option=False) + fall_off_policy = ChoiceField(label=_('Fall-off policy'), + choices=FallOffPolicy.choices, + required=True, + placeholder_default_option=False, + initial=FallOffPolicy.SQUARE) + #: Start date of the new round. + start_date = DateTimeField(label=_('Start date'), required=True) + #: End date of the new round. + end_date = DateTimeField(label=_('End date'), required=False) + #: Deadline date of the new round. + deadline_date = DateTimeField(label=_('Deadline date'), required=True) + #: Reveal date of the new round. + reveal_date = DateTimeField(label=_('Reveal date'), required=False) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Creates a new :class:`Round` record based on the data provided in the request. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + end_date = request.POST.get('end_date') + reveal_date = request.POST.get('reveal_date') + + if not end_date: + end_date = None + if not reveal_date: + reveal_date = None + + score_selection_policy = request.POST.get('score_selection_policy') + score_selection_policy = ScoreSelectionPolicy[score_selection_policy] + + fall_off_policy = request.POST.get('fall_off_policy') + fall_off_policy = FallOffPolicy[fall_off_policy] + + Round.objects.create_round( + name=request.POST.get('round_name'), + start_date=request.POST.get('start_date'), + end_date=end_date, + deadline_date=request.POST.get('deadline_date'), + reveal_date=reveal_date, + score_selection_policy=score_selection_policy, + fall_off_policy=fall_off_policy, + ) + + message = _('Round ') + request.POST.get('round_name') + _(' created successfully') + return {'message': message} + + +class CreateRoundFormWidget(FormWidget): + """ + Form widget for the :class:`CreateRoundForm`. + """ + + def __init__(self, + request, + course_id: int, + form: CreateRoundForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: CreateRoundForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`CreateRoundForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import RoundModelView + + if not form: + form = CreateRoundForm() + + super().__init__( + name='create_round_form_widget', + request=request, + form=form, + post_target_url=RoundModelView.post_url(**{'course_id': course_id}), + button_text=_('Add round'), + element_groups=[ + FormElementGroup(name='round_name_gr', + elements=['round_name'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='round_settings', + elements=['fall_off_policy', 'score_selection_policy'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='start_dates', + elements=['start_date', 'reveal_date'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='end_dates', + elements=['end_date', 'deadline_date'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL) + ], + **kwargs + ) + + +# ----------------------------------------- edit round ----------------------------------------- # + +class EditRoundForm(CourseModelForm): + """ + Form for editing :class:`course.Round` records. + """ + + MODEL = Round + ACTION = Round.BasicAction.EDIT + + #: Name of the round to be edited. + round_name = AlphanumericStringField(label=_('Round name'), required=True) + #: Score selection policy of the round to be edited. + score_selection_policy = ChoiceField(label=_('Score selection policy'), + choices=ScoreSelectionPolicy.choices, + required=True, + placeholder_default_option=False) + fall_off_policy = ChoiceField(label=_('Fall-off policy'), + choices=FallOffPolicy.choices, + required=True, + placeholder_default_option=False, ) + #: Start date of the round to be edited. + start_date = DateTimeField(label=_('Start date'), required=True) + #: End date of the round to be edited. + end_date = DateTimeField(label=_('End date'), required=False) + #: Deadline date of the round to be edited. + deadline_date = DateTimeField(label=_('Deadline date'), required=True) + #: Reveal date of the round to be edited. + reveal_date = DateTimeField(label=_('Reveal date'), required=False) + #: ID of the round to be edited. + round_id = forms.IntegerField(widget=forms.HiddenInput()) + + def __init__(self, + *, + course_id: int, + round_: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param round_: ID of the round to be edited. + :type round_: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseModelForm` + super constructor. + :type kwargs: dict + """ + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + round_obj = ModelsRegistry.get_round(round_, course_id) + + self.fields['round_name'].initial = round_obj.name + self.fields['score_selection_policy'].initial = round_obj.score_selection_policy + self.fields['fall_off_policy'].initial = round_obj.fall_off_policy + self.fields['start_date'].initial = round_obj.start_date + self.fields['end_date'].initial = round_obj.end_date + self.fields['deadline_date'].initial = round_obj.deadline_date + self.fields['reveal_date'].initial = round_obj.reveal_date + self.fields['round_id'].initial = round_obj.pk + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Updates the :class:`Round` record with the ID provided in the request based on the submitted + form data. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + round_ = ModelsRegistry.get_round(int(request.POST.get('round_id'))) + end_date = request.POST.get('end_date') + reveal_date = request.POST.get('reveal_date') + + if not end_date: + end_date = None + if not reveal_date: + reveal_date = None + + score_selection_policy = request.POST.get('score_selection_policy') + score_selection_policy = ScoreSelectionPolicy[score_selection_policy] + fall_off_policy = request.POST.get('fall_off_policy') + fall_off_policy = FallOffPolicy[fall_off_policy] + + round_.update( + name=request.POST.get('round_name'), + score_selection_policy=score_selection_policy, + fall_off_policy=fall_off_policy, + start_date=request.POST.get('start_date'), + end_date=end_date, + deadline_date=request.POST.get('deadline_date'), + reveal_date=reveal_date + ) + + message = _('Round ') + request.POST.get('round_name') + _(' edited successfully') + return {'message': message} + + +class EditRoundFormWidget(FormWidget): + """ + Form widget for the :class:`EditRoundForm`. + """ + + def __init__(self, + request, + course_id: int, + round_: int | Round, + form: EditRoundForm = None, + form_instance_id: int = 0, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param round_: ID of the round to be edited. + :type round_: int + :param form: EditRoundForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`EditRoundForm` + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import RoundModelView + + if isinstance(round_, Round): + round_pk = round_.pk + else: + round_pk = round_ + + if not form: + form = EditRoundForm(course_id=course_id, + round_=round_pk, + form_instance_id=form_instance_id, + request=request) + + round_obj = ModelsRegistry.get_round(round_, course_id) + + super().__init__( + name=f'edit_round{round_obj.pk}_form_widget', + request=request, + form=form, + post_target_url=RoundModelView.post_url(**{'course_id': course_id}), + button_text=f"{_('Edit round')} {round_obj.name}", + element_groups=[ + FormElementGroup(name='round_name_gr', + elements=['round_name'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='round_settings', + elements=['fall_off_policy', 'score_selection_policy'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='start_dates', + elements=['start_date', 'reveal_date'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + FormElementGroup(name='end_dates', + elements=['end_date', 'deadline_date'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL) + ], + **kwargs + ) + + +# ---------------------------------------- delete round ---------------------------------------- # + +class DeleteRoundForm(CourseModelForm): + """ + Form for deleting existing :class:`course.Round` objects. + """ + + MODEL = Round + ACTION = Round.BasicAction.DEL + + #: ID of the round to be deleted. + round_id = forms.IntegerField( + label=_('Round ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True, + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Deletes the round with the ID provided in the request. + + :param request: POST request containing the round ID. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, Any] + """ + Round.objects.delete_round(int(request.POST.get('round_id'))) + return {'message': _('Round deleted successfully')} + + +# ============================================ TASK ============================================ # + +# ----------------------------------------- create task ---------------------------------------- # + +class CreateTaskForm(CourseModelForm): + """ + Form for creating new :class:`course.Task` records. + """ + + MODEL = Task + ACTION = Task.BasicAction.ADD + + #: Name of the new task. + task_name = AlphanumericStringField( + label=_('Task name'), + help_text=_('If not provided - task name will be taken from package.'), + required=False, + ) + + #: Round the new task is assigned to. + round_ = ModelChoiceField( + data_source_url='', + label_format_string='[[name]] ([[f_start_date]] - [[f_deadline_date]])', + label=_('Round'), + required=True, + ) + + #: Point value of the new task. + points = forms.FloatField(label=_('Points'), + min_value=0, + required=False, + help_text=_('If not provided - points will be taken from package.')) + + # noinspection PyTypeChecker + #: Package containing the new task's definition. + package = FileUploadField(label=_('Task package'), + allowed_extensions=['zip'], + required=True, + help_text=_('Only .zip files are allowed')) + + #: Judging mode of the new task. + judge_mode = ChoiceField(label=_('Judge mode'), + choices=TaskJudgingMode, + required=True, + placeholder_default_option=False) + + def __init__(self, + *, + course_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseModelForm` + super constructor. + :type kwargs: dict + """ + from course.views import RoundModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['round_'].data_source_url = RoundModelView.get_url( + serialize_kwargs={'add_formatted_dates': True}, + **{'course_id': course_id} + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Creates a new :class:`Task` object based on the data provided in the request. + Also creates a new :class:`PackageSource` and :class:`PackageInstance` objects based on the + uploaded package. + + :param request: POST request containing the task data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + from package.models import PackageSource + + task_name = request.POST.get('task_name') + round_id = int(request.POST.get('round_')) + points = request.POST.get('points') + judge_mode = request.POST.get('judge_mode') + if judge_mode is None: + judge_mode = TaskJudgingMode.LIN + else: + judge_mode = TaskJudgingMode[judge_mode] + file = FileHandler(settings.UPLOAD_DIR, 'zip', request.FILES['package']) + file.save() + try: + package_instance = PackageSource.objects.create_package_source_from_zip( + name=task_name, + zip_file=file.path, + creator=request.user, + return_package_instance=True + ) + Task.objects.create_task( + package_instance=package_instance, + round_=round_id, + task_name=task_name, + points=points, + judging_mode=judge_mode, + ) + except Exception as e: + file.delete() + raise e + file.delete() + return {'message': _('Task ') + task_name + _(' created successfully')} + + +class CreateTaskFormWidget(FormWidget): + """ + Form widget for the :class:`CreateTaskForm`. + """ + + def __init__(self, + request, + course_id: int, + form: CreateTaskForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: CreateTaskForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`CreateTaskForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import TaskModelView + + if not form: + form = CreateTaskForm(course_id=course_id, request=request) + + super().__init__( + name='create_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + button_text=_('Add task'), + element_groups=FormElementGroup(name='grading', + elements=['points', 'judge_mode'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + **kwargs + ) + + +class SimpleEditTaskForm(CourseModelForm): + MODEL = Task + ACTION = Task.BasicAction.EDIT + + #: Name of the new task. + task_name = AlphanumericStringField( + label=_('Task name'), + required=False, + ) + + #: Round the new task is assigned to. + round_ = ModelChoiceField( + data_source_url='', + label_format_string='[[name]] ([[f_start_date]] - [[f_deadline_date]])', + label=_('Round'), + required=True, + ) + + #: Point value of the new task. + points = forms.FloatField(label=_('Points'), + min_value=0, + required=False, ) + + #: Judging mode of the new task. + judge_mode = ChoiceField(label=_('Judge mode'), + choices=TaskJudgingMode, + required=True, + placeholder_default_option=False) + + task_id = forms.IntegerField(label=_('Task ID'), + widget=forms.HiddenInput(), + required=True) + + def __init__(self, + *, + course_id: int, + task_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + from course.views import RoundModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + crs = ModelsRegistry.get_course(course_id) + + task = crs.get_task(task_id) + self.fields['round_'].data_source_url = RoundModelView.get_url( + serialize_kwargs={'add_formatted_dates': True}, + **{'course_id': course_id} + ) + self.fields['task_name'].initial = task.task_name + # TODO: fix after creating initial handling for ModelChoiceField + # self.fields['round_'].initial = task.round_id + self.fields['points'].initial = task.points + self.fields['judge_mode'].initial = task.judging_mode + self.fields['task_id'].initial = task_id + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Updates the :class:`Task` record with the ID provided in the hidden field. + + :param request: POST request containing the form data. + :type request: HttpRequest + + :return: Dictionary containing a success message. + :rtype: Dict[str, Any] + """ + task = ModelsRegistry.get_task(int(request.POST.get('task_id'))) + task_name = request.POST.get('task_name') + round_id = int(request.POST.get('round_')) + points = request.POST.get('points') + judge_mode = request.POST.get('judge_mode') + judge_mode = TaskJudgingMode[judge_mode] + task.update_data( + task_name=task_name, + round=ModelsRegistry.get_round(round_id), + points=points, + judging_mode=judge_mode, + ) + return {'message': f'Task {task_name} edited successfully'} + + +class SimpleEditTaskFormWidget(FormWidget): + """ + Form widget for the :class:`CreateTaskForm`. + """ + + def __init__(self, + request, + course_id: int, + task_id: int, + form: CreateTaskForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: CreateTaskForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`CreateTaskForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import TaskModelView + + if not form: + form = SimpleEditTaskForm( + course_id=course_id, + task_id=task_id, + request=request + ) + + super().__init__( + name='simple_edit_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + button_text=_('Edit task'), + element_groups=FormElementGroup(name='grading', + elements=['points', 'judge_mode'], + layout=FormElementGroup.FormElementsLayout.HORIZONTAL), + **kwargs + ) + + +# ---------------------------------------- Reupload task --------------------------------------- # + +class ReuploadTaskForm(CourseModelForm): + MODEL = Task + ACTION = Course.CourseAction.REUPLOAD_TASK + + task_id = forms.IntegerField( + label=_('Task ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id'}), + required=True, + ) + + package = FileUploadField( + label=_('Task package'), + allowed_extensions=['zip'], + required=True, + help_text=_('Only .zip files are allowed') + ) + + def __init__(self, *, + task_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + self.fields['task_id'].initial = task_id + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + task_id = int(request.POST.get('task_id')) + course = InCourse.get_context_course() + task = course.get_task(task_id) + file = FileHandler(settings.UPLOAD_DIR, 'zip', request.FILES['package']) + file.save() + new_package_instance = None + new_task = None + try: + new_package_instance = PackageInstance.objects.create_package_instance_from_zip( + package_source=task.package_instance.package_source, + zip_file=file.path, + permissions_from_instance=task.package_instance, + creator=request.user, + ) + new_task = Task.objects.update_task(task, + new_package_instance=new_package_instance) + except Exception as e: + file.delete() + if new_package_instance is not None: + new_package_instance.delete(delete_files=True) + if new_task is not None: + new_task.delete() + raise e + file.delete() + return {'message': _('Task re-uploaded successfully')} + + +class ReuploadTaskFormWidget(FormWidget): + def __init__(self, + request, + course_id: int, + task_id: int, + form: ReuploadTaskForm = None, + **kwargs) -> None: + from course.views import TaskModelView + + if not form: + form = ReuploadTaskForm(task_id=task_id, request=request) + + super().__init__( + name='reupload_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + button_text=_('Re-upload task'), + **kwargs + ) + + +# ----------------------------------------- delete task ---------------------------------------- # + + +class DeleteTaskForm(CourseModelForm): + """ + Form for deleting :class:`course.Task` records. + """ + + MODEL = Task + ACTION = Task.BasicAction.DEL + + #: ID of the task to be deleted. + task_id = forms.IntegerField( + label=_('Task ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True, + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Deletes the task with the ID provided in the request. + + :param request: POST request containing the task ID. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + Task.objects.delete_task(int(request.POST.get('task_id'))) + return {'message': _('Task deleted successfully')} + + +class DeleteTaskFormWidget(FormWidget): + """ + Form widget for deleting tasks. + + See also: + - :class:`FormWidget` + - :class:`DeleteTaskForm` + """ + + def __init__(self, + request, + course_id: int, + form: DeleteTaskForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param form: Form to be base the widget on. If not provided, a new form will be created. + :type form: :class:`DeleteTaskForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import TaskModelView + + if not form: + form = DeleteTaskForm() + + super().__init__( + name='delete_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + button_text=_('Delete task'), + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm task deletion'), + message=_( + 'Are you sure you want to delete this task? This action cannot be undone.' + ), + confirm_button_text=_('Delete task'), + ), + **kwargs + ) + + +# ----------------------------------------- edit task ------------------------------------------ # + +class EditTaskForm(CourseModelForm): + """ + Form for editing :class:`course.Task` records. + """ + + MODEL = Task + # TODO: This action is already used in SimpleEditTaskForm + ACTION = Task.BasicAction.EDIT + + task_title = AlphanumericStringField(label=_('Task title'), required=True) + round_ = ModelChoiceField( + label_format_string='[[name]] ([[f_start_date]] - [[f_deadline_date]])', + label=_('Round'), + required=True, + ) + task_judge_mode = ChoiceField(label=_('Task judge mode'), + choices=TaskJudgingMode, + required=True) + + # package settings + task_points = forms.FloatField(label=_('Task points'), required=False) + memory_limit = forms.CharField(label=_('Memory limit'), required=False) + # TODO: add memory amount field (low prio) + time_limit = forms.FloatField(label=_('Time limit [s]'), required=False) + + # TODO: multi select field + allowed_extensions = forms.CharField(label=_('Allowed extensions'), required=False) + cpus = forms.IntegerField(label=_('CPUs'), required=False) + + def add_field(self, fields: List[str], field: forms.Field, name: str) -> None: + fields.append(name) + self.fields[name] = field + + def __init__(self, *, + course_id: int, + task_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + from course.views import RoundModelView + + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['round_'].data_source_url = RoundModelView.get_url( + serialize_kwargs={'add_formatted_dates': True}, + **{'course_id': course_id} + ) + course = ModelsRegistry.get_course(course_id) + task = course.get_task(task_id) + package = task.package_instance.package + + # initials + self.fields['task_title'].initial = task.task_name + # TODO: model choice field need to support initial value + self.fields['task_judge_mode'].initial = task.judging_mode + self.fields['task_points'].initial = task.points + self.fields['memory_limit'].initial = package['memory_limit'] + self.fields['time_limit'].initial = package['time_limit'] + self.fields['allowed_extensions'].initial = ', '.join(package['allowedExtensions']) + self.fields['cpus'].initial = package['cpus'] + + self.general_fields = list(self.fields.keys()) + + self.set_groups = [] + # test sets + for t_set in task.sets: + package_ts = package.sets(t_set.short_name) + set_fields = [] + set_group = {'name': t_set.short_name} + + t_set_name = AlphanumericStringField( + label=_('Test set name'), + required=True, + initial=t_set.short_name + ) + self.add_field(fields=set_fields, field=t_set_name, name=f'ts_{t_set.pk}_name') + + t_set_weight = forms.FloatField( + label=_('Test set weight'), + required=True, + initial=t_set.weight + ) + self.add_field(fields=set_fields, field=t_set_weight, name=f'ts_{t_set.pk}_weight') + + # TODO: add memory amount field (low prio) + t_set_memory_limit = forms.CharField( + label=_('Memory limit'), + required=True, + initial=package_ts['memory_limit'] + ) + self.add_field(fields=set_fields, field=t_set_memory_limit, + name=f'ts_{t_set.pk}_memory_limit') + + t_set_time_limit = forms.FloatField( + label=_('Time limit [s]'), + required=True, + initial=package_ts['time_limit'] + ) + self.add_field(fields=set_fields, field=t_set_time_limit, + name=f'ts_{t_set.pk}_time_limit') + + set_group['fields'] = set_fields + test_groups = [] + + # single tests + for test in t_set.tests: + package_ts_t = package_ts.tests(test.short_name) + test_group = {'name': test.short_name} + test_fields = [] + + t_set_t_name = AlphanumericStringField( + label=_('Test name'), + required=True, + initial=test.short_name + ) + self.add_field(fields=test_fields, field=t_set_t_name, + name=f'ts_{t_set.pk}_t_{test.pk}_name') + + # TODO: add memory amount field (low prio) + t_set_t_memory_limit = forms.CharField( + label=_('Memory limit'), + required=True, + initial=package_ts_t['memory_limit'] + ) + self.add_field(fields=test_fields, field=t_set_t_memory_limit, + name=f'ts_{t_set.pk}_t_{test.pk}_memory_limit') + + t_set_t_time_limit = forms.FloatField( + label=_('Time limit [s]'), + required=True, + initial=package_ts_t['time_limit'] + ) + self.add_field(fields=test_fields, field=t_set_t_time_limit, + name=f'ts_{t_set.pk}_t_{test.pk}_time_limit') + + t_set_t_input = FileUploadField( + label=_('Change input file'), + allowed_extensions=['in'], + required=False, + ) + self.add_field(fields=test_fields, field=t_set_t_input, + name=f'ts_{t_set.pk}_t_{test.pk}_input') + + t_set_t_output = FileUploadField( + label=_('Change output file'), + allowed_extensions=['out'], + required=False, + ) + self.add_field(fields=test_fields, field=t_set_t_output, + name=f'ts_{t_set.pk}_t_{test.pk}_output') + + test_group['fields'] = test_fields + test_groups.append(test_group) + + set_group['test_groups'] = test_groups + self.set_groups.append(set_group) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + logger.error(f'Not implemented. Request.POST: \n{request.POST}') + raise NotImplementedError('Not implemented') + + +class EditTaskFormWidget(FormWidget): + """ + This class is a form widget for the :class:`EditTaskForm`. + """ + + def __init__(self, + request, + course_id: int, + task_id: int | None = None, + form: EditTaskForm = None, + **kwargs) -> None: + """ + :param request: The HTTP request object received by the view this form widget + is rendered in. + :type request: HttpRequest + :param course_id: The ID of the course the view this form widget is rendered in + is associated with. + :type course_id: int + :param task_id: The ID of the task to be edited. If not provided, + a new form will be created. + :type task_id: int, optional + :param form: The EditTaskForm to be base the widget on. If not provided, + a new form will be created. + :type form: EditTaskForm, optional + """ + from course.views import TaskModelView + + if not form: + if not task_id: + raise ValueError('Task ID must be provided when creating a new task form') + + form = EditTaskForm(course_id=course_id, + request=request, + task_id=task_id) + + element_groups = self._create_element_groups(form) + + super().__init__( + name='edit_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + element_groups=element_groups, + button_text=_('Edit task'), + form_observer=self._create_form_observer(form), + **kwargs + ) + + def _create_element_groups(self, form: EditTaskForm) -> List[FormElementGroup]: + general_fields = form.general_fields.copy() + judge_mode_field = self._extract_field_name(general_fields, 'task_judge_mode') + points_field = self._extract_field_name(general_fields, 'task_points') + memory_limit_field = self._extract_field_name(general_fields, 'memory_limit') + time_limit_field = self._extract_field_name(general_fields, 'time_limit') + cpus_field = self._extract_field_name(general_fields, 'cpus') + extension_field = self._extract_field_name(general_fields, 'allowed_extensions') + + grading_group = FormElementGroup( + elements=[judge_mode_field, points_field], + name='grading-settings', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + limit_group = FormElementGroup( + elements=[memory_limit_field, time_limit_field, cpus_field], + name='limits-settings', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + general_elements = general_fields + [grading_group, limit_group, extension_field] + element_groups = [FormElementGroup(elements=general_elements, + name='general-settings', + title=_('General settings'), + layout=FormElementGroup.FormElementsLayout.VERTICAL)] + + for set_group in form.set_groups: + set_group_fields = set_group['fields'].copy() + name_field = self._extract_field_name(set_group_fields, 'name') + weight_field = self._extract_field_name(set_group_fields, 'weight') + memory_limit_field = self._extract_field_name(set_group_fields, 'memory_limit') + time_limit_field = self._extract_field_name(set_group_fields, 'time_limit') + + base_group = FormElementGroup( + elements=[name_field, weight_field], + name=f'{set_group["name"]}-base', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + limit_group = FormElementGroup( + elements=[memory_limit_field, time_limit_field], + name=f'{set_group["name"]}-limits', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + element_groups.append( + FormElementGroup(elements=set_group_fields + [base_group, limit_group], + name=f'{set_group["name"]}-settings', + title=set_group['name'], + layout=FormElementGroup.FormElementsLayout.VERTICAL, + frame=True) + ) + + for test_group in set_group['test_groups']: + test_group_fields = test_group['fields'].copy() + memory_limit_field = self._extract_field_name(test_group_fields, 'memory_limit') + time_limit_field = self._extract_field_name(test_group_fields, 'time_limit') + input_field = self._extract_field_name(test_group_fields, 'input') + output_field = self._extract_field_name(test_group_fields, 'output') + + limit_group = FormElementGroup( + elements=[memory_limit_field, time_limit_field], + name=f'{test_group["name"]}-limits', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + file_group = FormElementGroup( + elements=[input_field, output_field], + name=f'{test_group["name"]}-files', + layout=FormElementGroup.FormElementsLayout.HORIZONTAL + ) + + element_groups.append( + FormElementGroup(elements=test_group_fields + [limit_group, file_group], + name=test_group['name'], + title=test_group['name'], + layout=FormElementGroup.FormElementsLayout.VERTICAL, + frame=True) + ) + + return element_groups + + @staticmethod + def _create_form_observer(form: EditTaskForm) -> FormObserver: + tabs = [] + + for set_group in form.set_groups: + fields = set_group['fields'] + + for test_group in set_group['test_groups']: + fields += test_group['fields'] + + tabs.append(FormObserverTab(name=set_group['name'], + title=set_group['name'], + fields=fields)) + + return FormObserver(tabs=tabs) + + @staticmethod + def _extract_field_name(fields: List[str], name: str) -> str: + field_name = next((f for f in fields if name in f), None) + + if field_name is None: + raise ValueError(f'Field with name {name} not found in provided field names list') + + fields.remove(field_name) + return field_name + + def get_sidenav(self, request) -> SideNav: + sidenav = SideNav(request=request, + collapsed=True, + tabs=['General settings'], + toggle_button=True) + set_groups = getattr(self.form, 'set_groups', []) + + for test_set in set_groups: + sidenav.add_tab( + tab_name=test_set['name'], + sub_tabs=[f'{test_set["name"]} settings'] + + [test_set_test['name'] for test_set_test in test_set['test_groups']] + ) + + return sidenav + + +class RejudgeTaskForm(CourseModelForm): + """ + Form for rejudging :class:`course.Task`. Rejudging means that all submissions for the task are + updated using :meth:`course.Submit.update`. + """ + + MODEL = Task + ACTION = Course.CourseAction.REJUDGE_TASK + + task_id = forms.IntegerField( + label=_('Task ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True, + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + """ + Rejudges the task with the ID provided in the request. + + :param request: POST request containing the task ID. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, Any] + """ + task_id = int(request.POST.get('task_id')) + course = InCourse.get_context_course() + task = course.get_task(task_id) + submits_to_rejudge = task.legacy_submits_amount + task.update_submits() + return { + 'message': _(f'Task rejudged successfully - {submits_to_rejudge} submits affected.')} + + +class RejudgeTaskFormWidget(FormWidget): + """ + Form widget for rejudging tasks. + + See also: + - :class:`FormWidget` + - :class:`RejudgeTaskForm` + """ + + def __init__(self, + request, + course_id: int, + form: RejudgeTaskForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param task_id: ID of the task to be rejudged. + :type task_id: int + :param form: Form to be base the widget on. If not provided, a new form will be created. + :type form: :class:`RejudgeTaskForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + """ + from course.views import TaskModelView + + if not form: + form = RejudgeTaskForm() + + super().__init__( + name='rejudge_task_form_widget', + request=request, + form=form, + post_target_url=TaskModelView.post_url(**{'course_id': course_id}), + button_text=_('Rejudge task'), + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm task rejudging'), + message=_('Are you sure you want to rejudge this task and all its submits? ' + 'This may take a while.'), + confirm_button_text=_('Rejudge task'), + ), + submit_success_popup=SubmitSuccessPopup( + title=_('Task rejudged'), + message=_('Task rejudged successfully.'), + ), + submit_failure_popup=SubmitFailurePopup( + title=_('Task rejudging failed'), + message=_('Task rejudging failed.'), + ), + **kwargs + ) + + +# ========================================= SUBMISSION ========================================= # + +# -------------------------------------- create submission ------------------------------------- # + +class CreateSubmitForm(CourseModelForm): + """ + Form for creating new :class:`course.Submit` records. + """ + + MODEL = Submit + ACTION = Submit.BasicAction.ADD + + # noinspection PyTypeChecker + #: Source code of the new submission. + source_code = FileUploadField(label=_('Source code'), required=True) + #: ID of the task the new submission is for. + task_id = forms.IntegerField(label=_('Task ID'), widget=forms.HiddenInput(), required=True) + + def __init__(self, + *, + course_id: int, + task_id: int, + form_instance_id: int = 0, + request=None, + **kwargs) -> None: + """ + :param course_id: ID of the course the form is associated with. + :type course_id: int + :param task_id: ID of the task the submission is for. + :type task_id: int + :param form_instance_id: ID of the form instance. Used to identify the form instance when + saving its init parameters to the session and to reconstruct the form from the session. + Defaults to 0. Should be set to a unique value when creating a new form instance within + a single view with multiple instances of the same form class. + :type form_instance_id: int + :param request: HTTP request object received by the view the form is rendered in. Should be + passed to the constructor if the form is instantiated with custom init parameters. + :type request: HttpRequest + :param kwargs: Additional keyword arguments to be passed to the :class:`CourseModelForm` + super constructor. + :type kwargs: dict + """ + super().__init__(form_instance_id=form_instance_id, request=request, **kwargs) + + self.fields['task_id'].initial = task_id + if settings.MOCK_BROKER: + self.fields['result_types'] = forms.MultipleChoiceField( + choices=ResultStatus.choices, + initial=[ResultStatus.OK, ResultStatus.ANS, ResultStatus.TLE, ResultStatus.MEM], + help_text=_( + 'Mocking broker is enabled. No submissions will be sent to the broker.'), + required=False + ) + course = ModelsRegistry.get_course(course_id) + task = course.get_task(task_id) + allowed_extensions = task.package_instance.package['allowedExtensions'] + self.fields['source_code'].help_text = ( + f'{_("Allowed extensions:")} [{", ".join(allowed_extensions)}] ' + f'{_("and zips containing these files")}' + ) + + @classmethod + def is_permissible(cls, request) -> bool: + """ + Checks if the user is allowed to create a new submit. + + :param request: HTTP request object containing the user. + :type request: HttpRequest + :return: True if the user is allowed to create a new submit, False otherwise. + :rtype: bool + """ + task_id = int(request.POST.get('task_id')) + task = ModelsRegistry.get_task(task_id) + if not task: + return False + return super().is_permissible(request) and task.can_submit(request.user) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + """ + Creates a new :class:`Submit` object based on the data provided in the request. + + :param request: POST request containing the form data. + :type request: HttpRequest + :return: Dictionary containing a success message. + :rtype: Dict[str, str] + """ + file_extension = request.FILES['source_code'].name.split('.')[-1] + source_code_file = FileHandler(settings.SUBMITS_DIR, + file_extension, + request.FILES['source_code']) + source_code_file.save() + task_id = int(request.POST.get('task_id')) + task = ModelsRegistry.get_task(task_id) + if not task: + source_code_file.delete() + raise ValueError('Task not found') + + task.package_instance.package.check_source(source_code_file.path) + + user = request.user + + available_statuses = None + if settings.MOCK_BROKER: + available_statuses = request.POST.getlist('result_types') + available_statuses = [ResultStatus[status] for status in available_statuses] + + try: + Submit.objects.create_submit( + source_code=source_code_file.path, + task=task_id, + user=user, + auto_send=True, + available_statuses=available_statuses, + ) + except Exception as e: + source_code_file.delete() + raise e + return {'message': _('Submit created successfully')} + + +class CreateSubmitFormWidget(FormWidget): + """ + Form widget for the :class:`CreateSubmitForm`. + """ + + def __init__(self, + request, + course_id: int, + task_id: int | None = None, + form: CreateTaskForm = None, + **kwargs) -> None: + """ + :param request: HTTP request object received by the view this form widget is rendered in. + :type request: HttpRequest + :param course_id: ID of the course the view this form widget is rendered in is associated + with. + :type course_id: int + :param task_id: ID of the task the submission is for. + :type task_id: int + :param form: CreateSubmitForm to be base the widget on. If not provided, a new form will be + created. + :type form: :class:`CreateSubmitForm` + :param kwargs: Additional keyword arguments to be passed to the :class:`FormWidget` + super constructor. + :type kwargs: dict + :raises ValueError: If the task ID is not provided when creating a new form. + """ + from course.views import SubmitModelView + + if not form and not task_id: + raise ValueError('Task ID must be provided when creating a new submit form') + + if not form: + form = CreateSubmitForm(course_id=course_id, + task_id=task_id, + request=request) + + super().__init__( + name='create_submit_form_widget', + request=request, + form=form, + post_target_url=SubmitModelView.post_url(**{'course_id': course_id}), + button_text=_('New submission'), + **kwargs + ) + + +class RejudgeSubmitForm(CourseModelForm): + MODEL = Submit + ACTION = Course.CourseAction.REJUDGE_SUBMIT + + submit_id = forms.IntegerField( + label=_('Submit ID'), + widget=forms.HiddenInput(attrs={'class': 'model-id', 'data-reset-on-refresh': 'true'}), + required=True, + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, str]: + submit_id = int(request.POST.get('submit_id')) + submit = Submit.objects.get(pk=submit_id) + submit.update() + return {'message': _('Submit rejudged successfully')} + + +class RejudgeSubmitFormWidget(FormWidget): + def __init__(self, + request, + course_id: int, + form: RejudgeSubmitForm = None, + **kwargs) -> None: + from course.views import SubmitModelView + + if not form: + form = RejudgeSubmitForm() + + super().__init__( + name='rejudge_submit_form_widget', + request=request, + form=form, + post_target_url=SubmitModelView.post_url(**{'course_id': course_id}), + button_text=_('Rejudge submit'), + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm submit rejudging'), + message=_('Are you sure you want to rejudge this submit?'), + confirm_button_text=_('Rejudge submit'), + ), + **kwargs + ) diff --git a/BaCa2/widgets/forms/fields/__init__.py b/BaCa2/widgets/forms/fields/__init__.py new file mode 100644 index 00000000..9b5ed21c --- /dev/null +++ b/BaCa2/widgets/forms/fields/__init__.py @@ -0,0 +1 @@ +from .base import * diff --git a/BaCa2/widgets/forms/fields/base.py b/BaCa2/widgets/forms/fields/base.py new file mode 100644 index 00000000..89fd3860 --- /dev/null +++ b/BaCa2/widgets/forms/fields/base.py @@ -0,0 +1,575 @@ +from __future__ import annotations + +import json +from abc import ABC +from typing import List + +from django import forms +from django.core.validators import FileExtensionValidator +from django.utils import timezone +from django.utils.translation import gettext_lazy as _ + +from course.routing import InCourse +from main.models import Course +from util.models import model_cls +from util.models_registry import ModelsRegistry + +# ----------------------------------- restricted char fields ----------------------------------- # + +class RestrictedCharField(forms.CharField, ABC): + """ + Base class for form fields which accept only strings consisting of a restricted set of + characters. + """ + + #: List of characters accepted by the field. + ACCEPTED_CHARS = [] + + def __init__(self, **kwargs) -> None: + if 'validators' in kwargs: + kwargs['validators'].append(self.validate_syntax) + else: + kwargs['validators'] = [self.validate_syntax] + + if not kwargs.get('strip', False): + kwargs['strip'] = False + + super().__init__( + **kwargs + ) + + @classmethod + def validate_syntax(cls, value: str) -> None: + """ + Checks if the value contains only characters accepted by the field. + + :param value: Value to be validated. + :type value: str + :raises: ValidationError if the value contains characters other than those accepted by the + field. + """ + if any(c not in cls.ACCEPTED_CHARS for c in value): + raise forms.ValidationError(cls.syntax_validation_error_message()) + + @classmethod + def syntax_validation_error_message(cls) -> str: + """ + Returns a validation error message for the field. + + :return: Error message for the field. + :rtype: str + """ + return (_('This field can only contain the following characters: ') + + f'{", ".join(cls.ACCEPTED_CHARS)}.') # noqa: Q000 + + +class AlphanumericField(RestrictedCharField): + """ + Form field which accepts only strings consisting of alphanumeric characters. + + See also: + - :class:`RestrictedCharField` + """ + + @classmethod + def validate_syntax(cls, value: str) -> None: + """ + Checks if the value contains only alphanumeric characters. + + :param value: Value to be validated. + :type value: str + :raises: ValidationError if the value contains characters other than alphanumeric + characters. + """ + if any(not (c.isalnum() or c in cls.ACCEPTED_CHARS) for c in value): + raise forms.ValidationError(cls.syntax_validation_error_message()) + + @classmethod + def syntax_validation_error_message(cls) -> str: + """ + Returns a validation error message for the field. + + :return: Error message for the field. + :rtype: str + """ + return _('This field can only contain alphanumeric characters.') + + +class AlphanumericStringField(AlphanumericField): + """ + Form field which accepts only strings consisting of alphanumeric characters and spaces. + + See also: + - :class:`AlphanumericField` + """ + + ACCEPTED_CHARS = [' '] + + def __init__(self, **kwargs) -> None: + super().__init__( + **kwargs, + validators=[AlphanumericStringField.validate_trailing_spaces, + AlphanumericStringField.validate_double_spaces] + ) + + @staticmethod + def validate_trailing_spaces(value: str) -> None: + """ + Checks if the value does not contain trailing spaces. + + :param value: Value to be validated. + :type value: str + :raises: ValidationError if the value contains trailing spaces. + """ + if value and (value[0] == ' ' or value[-1] == ' '): + raise forms.ValidationError(_('This field cannot contain trailing whitespaces.')) + + @staticmethod + def validate_double_spaces(value: str) -> None: + """ + Checks if the value does not contain double spaces. + + :param value: Value to be validated. + :type value: str + :raises: ValidationError if the value contains double spaces. + """ + if ' ' in value: + raise forms.ValidationError(_('This field cannot contain double spaces.')) + + @classmethod + def syntax_validation_error_message(cls) -> str: + """ + Returns a validation error message for the field. + + :return: Error message for the field. + :rtype: str + """ + return _('This field can only contain alphanumeric characters and spaces.') + + +# ---------------------------------------- array fields ---------------------------------------- # + +class CharArrayField(forms.CharField): + """ + Form field used to store a comma-separated list of strings. If provided with a queryset, the + field will validate that all strings in the list are present in it. It can also receive a list + of item validators which will be used to validate each string in the list. + + See also: + - :class:`IntegerArrayField` + """ + + def __init__(self, + queryset: List[str] = None, + item_validators: List[callable] = None, + **kwargs) -> None: + """ + :param queryset: List of strings which are accepted by the field. If not provided, all + strings are accepted. + :type queryset: List[str] + :param item_validators: List of validators which will be used to validate each string in + the list. + :type item_validators: List[callable] + :param kwargs: Additional keyword arguments for the field. + :type kwargs: dict + """ + super().__init__(**kwargs) + self.queryset = queryset + self.item_validators = item_validators or [] + + def to_python(self, value: str) -> List[str]: + """ + Converts the value to a list of strings. + + :param value: Value to be converted. + :type value: str + :return: List of strings. + :rtype: List[str] + :raises: ValidationError if the method encounters a value, type or attribute error while + converting the value. + """ + value = super().to_python(value) + if not value: + return [] + try: + return value.split(',') + except (ValueError, TypeError, AttributeError): + raise forms.ValidationError(_('This field must be a comma-separated list of strings.')) + + def validate(self, value: List[str]) -> None: + """ + Validates the value by running all item validators on each string in the list and checking + if all strings are present in the queryset. + + :param value: Value to be validated. + :type value: List[str] + :raises: ValidationError if the value does not pass validation. + """ + super().validate(value) + + for validator in self.item_validators: + for elem in value: + validator(elem) + + for elem in value: + if not elem: + raise forms.ValidationError(_('This field cannot contain empty strings.')) + + if self.queryset and any(elem not in self.queryset for elem in value): + raise forms.ValidationError(self.queryset_validation_error_message()) + + def queryset_validation_error_message(self) -> str: + """ + Returns a validation error message for the field when the value does not pass queryset + validation. + + :return: Error message for the field. + :rtype: str + """ + return _('This field must be a comma-separated list of strings from the following list: ' + f'{", ".join(self.queryset)}.') # noqa: Q000 + + +class IntegerArrayField(CharArrayField): + """ + Form field used to store a comma-separated list of integers. If provided with a queryset, the + field will validate that all integers in the list are present in it. It can also receive a list + of item validators which will be used to validate each integer in the list. + + See also: + - :class:`CharArrayField` + - :class:`ModelArrayField` + """ + + def __init__(self, + queryset: List[int] = None, + item_validators: List[callable] = None, + **kwargs) -> None: + """ + :param queryset: List of integers which are accepted by the field. If not provided, all + integers are accepted. + :type queryset: List[int] + :param item_validators: List of validators which will be used to validate each integer in + the list. + :type item_validators: List[callable] + :param kwargs: Additional keyword arguments for the field. + :type kwargs: dict + """ + super().__init__(queryset, item_validators, **kwargs) + + def to_python(self, value: str) -> List[int]: + """ + Converts the value to a list of integers. + + :param value: Value to be converted. + :type value: str + :return: List of integers. + :rtype: List[int] + :raises: ValidationError if the method encounters a value, type or overflow error while + converting the value. + """ + try: + return [int(elem) for elem in super().to_python(value)] + except (ValueError, TypeError, OverflowError): + raise forms.ValidationError(_('This field must be a comma-separated list of integers.')) + + def queryset_validation_error_message(self) -> str: + """ + Returns a validation error message for the field when the value does not pass queryset + validation. + + :return: Error message for the field. + :rtype: str + """ + return _('This field must be a comma-separated list of integers from the following list: ' + f'{", ".join([str(elem) for elem in self.queryset])}.') # noqa: Q000 + + +class ModelArrayField(IntegerArrayField): + """ + Form field used to store a comma-separated list of model instance IDs. + + See also: + - :class:`IntegerArrayField` + """ + + def __init__(self, + model: model_cls, + item_validators: List[callable] = None, + **kwargs) -> None: + """ + :param model: Model which instance IDs are accepted by the field. + :type model: Type[models.Model] + :param item_validators: List of validators which will be used to validate each model + instance in the list. + :type item_validators: List[callable] + :param kwargs: Additional keyword arguments for the field. + :type kwargs: dict + """ + super().__init__(item_validators=item_validators, **kwargs) + self.model = model + + def __getattribute__(self, item): + """ + Intercepts the `queryset` attribute access to dynamically generate the queryset of model + instance IDs. + """ + if item == 'queryset': + return [instance.id for instance in self.model.objects.all()] + return super().__getattribute__(item) + + def queryset_validation_error_message(self) -> str: + """ + Returns a validation error message for the field when the value does not pass queryset + validation. + + :return: Error message for the field. + :rtype: str + """ + return _(f'This field must be a comma-separated list of {self.model.__name__} IDs') + + +class CourseModelArrayField(ModelArrayField): + """ + Form field used to store a comma-separated list of model instance IDs for a course database + model class. + + See also: + - :class:`ModelArrayField` + """ + + def __init__(self, + model: model_cls, + course: int | str | Course = None, + item_validators: List[callable] = None, + **kwargs) -> None: + """ + :param model: Model which instance IDs are accepted by the field. + :type model: Type[models.Model] + :param course: Course to which the model instances belong. Can be specified as a course + instance, course ID or course short name. + :type course: int | str | Course + :param item_validators: List of validators which will be used to validate each model + instance in the list. + :type item_validators: List[callable] + :param kwargs: Additional keyword arguments for the field. + :type kwargs: dict + """ + super().__init__(model=model, item_validators=item_validators, **kwargs) + self.course = ModelsRegistry.get_course(course) + + def __getattribute__(self, item): + """ + Intercepts the `queryset` attribute access to dynamically generate the queryset of model + instance IDs. + """ + if item == 'queryset': + with InCourse(self.course.short_name): + return [instance.id for instance in self.model.objects.all()] + return super().__getattribute__(item) + + +# ----------------------------------------- file fields ---------------------------------------- # + +class FileUploadField(forms.FileField): + """ + Custom file upload field extending the Django `FileField`. The field can be configured to + accept only files with specific extensions. + """ + + def __init__(self, + allowed_extensions: List[str] = None, + **kwargs) -> None: + """ + :param allowed_extensions: List of allowed file extensions. If not provided, all extensions + are allowed. + :type allowed_extensions: List[str] + :param kwargs: Additional keyword arguments for the field. + :type kwargs: dict + """ + validators = kwargs.get('validators', []) + + if allowed_extensions: + self.allowed_extensions = allowed_extensions + validators.append(FileExtensionValidator(allowed_extensions)) + + kwargs['validators'] = validators + super().__init__(**kwargs) + + +# --------------------------------------- date-time fields ------------------------------------- # + +class DateTimeField(forms.DateTimeField): + """ + Custom date-time field extending the Django `DateTimeField` class to allow for proper rendering + with a date and/or time picker. The field can be configured to display a date picker, a time + picker or both. The time picker can be configured to have a custom time step. + """ + + def __init__(self, + *, + datepicker: bool = True, + timepicker: bool = True, + time_step: int = 30, + **kwargs) -> None: + """ + : param datepicker: Whether to display a date picker. + : type datepicker: bool + : param timepicker: Whether to display a time picker. + : type timepicker: bool + : param time_step: Time step for the time picker in minutes. Only used if the time picker is + enabled. Defaults to 30. + : type time_step: int + : param kwargs: Additional keyword arguments for the field. + : type kwargs: dict + """ + from django.conf import settings + + if not datepicker and not timepicker: + raise ValueError('At least one of datepicker and timepicker must be enabled.') + + self.special_field_type = 'datetime' + self.timepicker = json.dumps(timepicker) + self.datepicker = json.dumps(datepicker) + self.time_step = time_step + + if datepicker and timepicker: + kwargs.setdefault('input_formats', [settings.DATETIME_FORMAT]) + self.format = 'Y-m-d H:i' + elif datepicker: + kwargs.setdefault('input_formats', [settings.DATE_FORMAT]) + self.format = 'Y-m-d' + elif timepicker: + kwargs.setdefault('input_formats', [settings.TIME_FORMAT]) + self.format = 'H:i' + + super().__init__(**kwargs) + + def __setattr__(self, key, value): + """ + Intercepts the setting of the `initial` attribute to convert the value to a string using the + expected input format. + """ + if key == 'initial': + if value: + value = timezone.localtime(value) + value = value.strftime(self.input_formats[0]) + super().__setattr__(key, value) + + def widget_attrs(self, widget) -> dict: + """ + Sets the appropriate class attribute for the widget needed for proper rendering. + """ + attrs = super().widget_attrs(widget) + attrs['class'] = 'form-control date-field' + return attrs + + +# ---------------------------------------- choice fields --------------------------------------- # + +class ChoiceField(forms.ChoiceField): + """ + Custom choice field extending the Django `ChoiceField` class to allow for proper rendering. + Adds option to specify a default placeholder option which is displayed when no option is + selected. + """ + + def __init__(self, + placeholder_default_option: bool = True, + placeholder_option: str = '---', + **kwargs): + """ + :param placeholder_default_option: Whether to display the placeholder option when no option + is selected. If set to `False`, the placeholder option will not be added. + :type placeholder_default_option: bool + :param placeholder_option: Label of the placeholder option to be displayed when no option is + selected. If not provided defaults to `---`. + :type placeholder_option: str + """ + self.special_field_type = 'choice' + self.placeholder_default_option = placeholder_default_option + self.placeholder_option = placeholder_option + super().__init__(**kwargs) + + def widget_attrs(self, widget) -> dict: + """ + Sets the appropriate class attribute for the widget needed for proper rendering and adds + the placeholder option data attribute if necessary. + """ + attrs = super().widget_attrs(widget) + attrs['class'] = 'form-select choice-field' + if self.placeholder_default_option: + attrs['class'] += ' placeholder-option' + attrs['data-placeholder-option'] = self.placeholder_option + return attrs + + +class ModelChoiceField(ChoiceField): + """ + Form choice field which fetches its choices from a model view using the provided url. Allows to + specify custom label and value format strings for the choices, which can use the fields of + records fetched from the model view. The label format string is used to generate the option + labels, while the value format string is used to generate the associated values (sent in the + POST request). + """ + + def __init__(self, + label_format_string: str, + data_source_url: str = '', + value_format_string: str = '[[id]]', + placeholder_option: str = '---', + loading_placeholder_option: str = '', + **kwargs) -> None: + """ + :param data_source_url: URL to fetch the choice options from. Should return a JSON response + with a `data` array containing record dictionaries. Use the `get_url` method of the + model view associated with desired model to generate the URL. + :type data_source_url: str + :param label_format_string: Format string used to generate the option labels. Can use the + fields of records fetched from the model view with double square brackets notation + (e.g. `[[field_name]]`). + :type label_format_string: str + :param value_format_string: Format string used to generate the associated values. Can use + the fields of records fetched from the model view with double square brackets + notation. Defaults to `[[id]]`. + :type value_format_string: str + :param placeholder_option: Placeholder option label to be displayed when no option is + selected. If not provided defaults to `---`. + :type placeholder_option: str + :param loading_placeholder_option: Placeholder option label to be displayed while the + choices are being fetched. If not provided defaults to `Loading...`. + :type loading_placeholder_option: str + """ + self._data_source_url = data_source_url + self.label_format_string = label_format_string + self.value_format_string = value_format_string + if not loading_placeholder_option: + loading_placeholder_option = _('Loading...') + self.loading_placeholder_option = loading_placeholder_option + super().__init__(placeholder_default_option=True, + placeholder_option=placeholder_option, + **kwargs) + self.special_field_type = 'model_choice' + + @property + def data_source_url(self) -> str: + return self._data_source_url + + @data_source_url.setter + def data_source_url(self, value: str) -> None: + self._data_source_url = value + self.widget.attrs.update({'data-source-url': value}) + + def widget_attrs(self, widget) -> dict: + attrs = super().widget_attrs(widget) + attrs['class'] += ' model-choice-field' + attrs['data-loading-option'] = self.loading_placeholder_option + attrs['data-source-url'] = self._data_source_url + attrs['data-label-format-string'] = self.label_format_string + attrs['data-value-format-string'] = self.value_format_string + return attrs + + def validate(self, value): + if self.required and not value: + raise forms.ValidationError(_('This field is required.')) + # TODO: add proper validation for the field diff --git a/BaCa2/widgets/forms/fields/course.py b/BaCa2/widgets/forms/fields/course.py new file mode 100644 index 00000000..e7d0c5da --- /dev/null +++ b/BaCa2/widgets/forms/fields/course.py @@ -0,0 +1,102 @@ +from django import forms +from django.utils.translation import gettext_lazy as _ + +from main.models import Course +from widgets.forms.fields import AlphanumericStringField + + +class CourseName(AlphanumericStringField): + """ + Form field for :class:`main.Course` name. Its validators check if the course name contains only + the allowed characters, no double spaces and no trailing or leading spaces. + """ + + ACCEPTED_CHARS = [' ', '-', '+', '.', '/', '(', ')', ',', ':', '#'] + + def __init__(self, **kwargs) -> None: + super().__init__( + min_length=5, + max_length=Course._meta.get_field('name').max_length, + **kwargs + ) + + @classmethod + def syntax_validation_error_message(cls) -> str: + """ + :return: Error message for syntax validation. + :rtype: str + """ + return _('Course name can only contain alphanumeric characters and the following special ' + + 'characters: ') + ' '.join(cls.ACCEPTED_CHARS) + + +class CourseShortName(forms.CharField): + """ + Form field for :class:`main.Course` short name. Its validators check if the course code is + unique and if it contains only alphanumeric characters and underscores. + """ + + def __init__(self, **kwargs) -> None: + super().__init__( + label=_('Course code'), + min_length=3, + max_length=Course._meta.get_field('short_name').max_length, + validators=[CourseShortName.validate_uniqueness, CourseShortName.validate_syntax], + required=False, + **kwargs + ) + + @staticmethod + def validate_uniqueness(value: str) -> None: + """ + Checks if the course short name is unique. + + :param value: Course short name. + :type value: str + + :raises: ValidationError if the course short name is not unique. + """ + if Course.objects.filter(short_name=value.lower()).exists(): + raise forms.ValidationError(_('Course with this code already exists.')) + + @staticmethod + def validate_syntax(value: str) -> None: + """ + Checks if the course short name contains only alphanumeric characters and underscores. + + :param value: Course short name. + :type value: str + + :raises: ValidationError if the course short name contains characters other than + alphanumeric characters and. + """ + if any(not (c.isalnum() or c == '_') for c in value): + raise forms.ValidationError(_('Course code can only contain alphanumeric characters and' + 'underscores.')) + + +class USOSCode(forms.CharField): + """ + Form field for USOS subject and term codes of a course. + """ + + def __init__(self, **kwargs) -> None: + super().__init__( + min_length=1, + validators=[USOSCode.validate_syntax], + **kwargs + ) + + @staticmethod + def validate_syntax(value: str) -> None: + """ + Checks if the USOS code contains only alphanumeric characters, hyphens and dots. + + :param value: USOS code. + :type value: str + :raises: ValidationError if the USOS code contains characters other than alphanumeric + characters, hyphens and dots. + """ + if any(not c.isalnum() and c not in ['-', '+', '.', '/'] for c in value): + raise forms.ValidationError(_('USOS code can only contain alphanumeric characters and ' + + 'the following special characters: - + . /')) diff --git a/BaCa2/widgets/forms/fields/table_select.py b/BaCa2/widgets/forms/fields/table_select.py new file mode 100644 index 00000000..585a003a --- /dev/null +++ b/BaCa2/widgets/forms/fields/table_select.py @@ -0,0 +1,105 @@ +from typing import List + +from widgets.forms.fields import IntegerArrayField +from widgets.listing.columns import Column + + +class TableSelectField(IntegerArrayField): + """ + A field that allows the user to select multiple items from a table. The field value is posted + as an array of comma-separated primary keys of the selected records. + + See also: + - :class:`IntegerArrayField` + """ + + def __init__(self, + label: str, + table_widget_name: str, + data_source_url: str, + cols: List[Column], + allow_column_search: bool = True, + table_widget_kwargs: dict = None, + **kwargs) -> None: + """ + :param label: The label of the field. Will appear as the title of the table widget. + :type label: str + :param table_widget_name: The name of the table widget used by this field. + :type table_widget_name: str + :param data_source: The data source url for the table widget. + :type data_source: str + :param cols: The columns to display in the table widget. + :type cols: List[Column] + :param allow_column_search: Whether to display separate search fields for each searchable + column. + :type allow_column_search: bool + :param table_widget_kwargs: Additional keyword arguments to pass to the table widget + constructor. + :type table_widget_kwargs: dict + :param kwargs: Additional keyword arguments to pass to the superclass constructor of the + :class:`IntegerArrayField`. + :type kwargs: dict + """ + from widgets.listing import TableWidget + + table_widget_kwargs = { + 'name': table_widget_name, + 'title': label, + 'data_source': data_source_url, + 'cols': cols, + 'allow_select': True, + 'deselect_on_filter': False, + 'highlight_rows_on_hover': True, + 'refresh_button': True + } | (table_widget_kwargs or {}) + + self.table_widget = TableWidget(**table_widget_kwargs).get_context() + self.table_widget_id = table_widget_name + self.data_source_url = data_source_url + self.special_field_type = 'table_select' + + super().__init__(**kwargs) + + def widget_attrs(self, widget) -> dict: + """ + Adds the class `table-select-field` and the data attribute `data-table-id` to the widget + attributes. Required for the JavaScript and styling to work properly. + """ + attrs = super().widget_attrs(widget) + attrs['class'] = 'table-select-input' + attrs['data-table-id'] = self.table_widget_id + return attrs + + def __setattr__(self, key, value): + """ + Overrides the default __setattr__ method to update table widget data source url when the + `data_source_url` attribute is set. If the `initial` attribute is set as a list,it is + converted to a comma-separated string of primary keys. + + :raises ValueError: If the `initial` attribute is set as a list of non-integer values or + as a non-string, non-list value. + """ + if key == 'data_source_url': + self.table_widget['data_source_url'] = value + if key == 'initial' and value is not None: + if isinstance(value, list): + if not isinstance(value[0], int): + raise ValueError('The initial value of a TableSelectField must be a list of ' + 'integers (primary keys) or a comma-separated string of ' + 'integers.') + value = ','.join(str(pk) for pk in value) + elif not isinstance(value, str): + raise ValueError('The initial value of a TableSelectField must be a list of ' + 'integers (primary keys) or a comma-separated string of integers.') + + super().__setattr__(key, value) + + @staticmethod + def parse_value(value: str) -> List[int]: + """ + :param value: The field value as a comma-separated string of primary keys. + :type value: str + :return: The field value as a list of integers. + :rtype: List[int] + """ + return [int(pk) for pk in value.split(',')] if value else [] diff --git a/BaCa2/widgets/forms/fields/validation.py b/BaCa2/widgets/forms/fields/validation.py new file mode 100644 index 00000000..24fcdcc6 --- /dev/null +++ b/BaCa2/widgets/forms/fields/validation.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from typing import Dict, List + +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from widgets.forms.course import * +from widgets.forms.main import * + + +def get_field_validation_status(request: HttpRequest, + form_cls: str, + field_name: str, + value: str, + min_length: int | bool = False) -> Dict[str, str or List[str]]: + """ + Runs validators for a given field class and value and returns a dictionary containing the status + of the validation and a list of error messages if the validation has failed. + + :param request: The field validation request. + :type request: HttpRequest + :param form_cls: Name of the form class containing the field. + :type form_cls: str + :param field_name: Name of the field. + :type field_name: str + :param value: Value to be validated. + :type value: Any + :param min_length: Minimum length of the value, set to `False` if not defined. + :type min_length: int | bool + :return: Dictionary containing the status of the validation and a list of error messages if the + validation failed. + :rtype: Dict[str, str or List[str]] + """ + if value is None: + value = '' + if min_length is None: + min_length = False + + reconstruct_meth = getattr(eval(form_cls), 'reconstruct', None) + + if reconstruct_meth and callable(reconstruct_meth): + form = reconstruct_meth(request) + else: + form = eval(form_cls)(data=request.POST) + + field = form[field_name].field + min_length = int(min_length) if min_length else False + + if hasattr(field.widget, 'input_type') and field.widget.input_type == 'file': + return _get_file_field_validation_status(field, value) + + if hasattr(field, 'clean') and callable(field.clean): + try: + field.clean(value) + + if min_length and len(value) < min_length: + if min_length == 1: + return {'status': 'error', + 'messages': [_('This field cannot be empty.')]} + else: + if len(value) == 0 and not field.widget.is_required: + return {'status': 'ok'} + + return {'status': 'error', + 'messages': [_('This field must contain at least ' + f'{min_length} characters.')]} + + return {'status': 'ok'} + except forms.ValidationError as e: + return {'status': 'error', + 'messages': e.messages} + else: + return {'status': 'ok'} + + +def _get_file_field_validation_status(field: forms.FileField, + value: str) -> Dict[str, str or List[str]]: + if field.widget.is_required and not value: + return {'status': 'error', + 'messages': [_('This field is required.')]} + elif not value: + return {'status': 'ok'} + + validate_extensions = False + allowed_extensions = [] + + for validator in field.validators: + if hasattr(validator, 'allowed_extensions'): + validate_extensions = True + allowed_extensions = validator.allowed_extensions + + if validate_extensions: + extension = value.split('.')[-1] + if extension not in allowed_extensions: + return {'status': 'error', + 'messages': [_('This file type is not allowed. Supported file types: ') + + ', '.join(allowed_extensions)]} + + return {'status': 'ok'} diff --git a/BaCa2/widgets/forms/main.py b/BaCa2/widgets/forms/main.py new file mode 100644 index 00000000..ab1f237a --- /dev/null +++ b/BaCa2/widgets/forms/main.py @@ -0,0 +1,137 @@ +import logging +from typing import Any, Dict + +from django import forms +from django.utils.translation import gettext_lazy as _ + +from core.tools.mailer import TemplateMailer +from main.models import User +from widgets.forms import BaCa2ModelForm, FormWidget +from widgets.forms.fields import AlphanumericField + +logger = logging.getLogger(__name__) + + +class CreateUser(BaCa2ModelForm): + MODEL = User + ACTION = User.BasicAction.ADD + + email = forms.EmailField(required=True, + label=_('Email address'), + help_text=_('We will send an email with login and password to this ' + 'address.')) + first_name = forms.CharField(required=False, label=_('First name')) + last_name = forms.CharField(required=False, label=_('Last name')) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + email = request.POST.get('email') + first_name = request.POST.get('first_name') + last_name = request.POST.get('last_name') + + password = User.objects.make_random_password(length=20) + + try: + user = User.objects.get(email=email) + if user: + raise ValueError(_('User with this email already exists.')) + except User.DoesNotExist: + pass + + user = User.objects.create_user(email=email, + first_name=first_name, + last_name=last_name, + password=password) + user.save() + + try: + mailer = TemplateMailer(mail_to=email, + subject=_('Your new BaCa2 account'), + template='new_account', + context={'email': email, 'password': password}, ) + mailer.send() + except TemplateMailer.MailNotSent as e: + logger.error(f'Failed to send email to {email}') + user.delete() + raise e + + return {'message': _('User created and email sent.')} + + +class CreateUserWidget(FormWidget): + def __init__(self, + request, + form: CreateUser = None, + **kwargs) -> None: + from main.views import UserModelView + + if not form: + form = CreateUser() + + super().__init__( + name='create_user_form_widget', + request=request, + form=form, + post_target_url=UserModelView.post_url(), + button_text=_('Add new user'), + **kwargs + ) + + +# ------------------------------ Profile forms ------------------------------ # + +class ChangePersonalData(BaCa2ModelForm): + MODEL = User + + ACTION = User.BasicAction.EDIT + nickname = AlphanumericField( + min_length=3, + max_length=20, + required=False, + label=_('Nickname'), + ) + + @classmethod + def handle_valid_request(cls, request) -> Dict[str, Any]: + user = request.user + nickname = request.POST.get('nickname') + if not nickname: + user.nickname = None + user.save() + return {'message': _('Personal data changed.')} + if nickname == user.nickname: + return {'message': _('No changes to apply.')} + + if nickname: + nickname = nickname.strip() + if len(nickname) > 20: + raise ValueError(_('Nickname is too long.')) + if len(nickname) < 3: + raise ValueError(_('Nickname is too short.')) + user.nickname = nickname + + user.save() + return {'message': _('Personal data changed.')} + + +class ChangePersonalDataWidget(FormWidget): + def __init__(self, + request, + form: ChangePersonalData = None, + **kwargs) -> None: + from main.views import UserModelView + + if not form: + form = ChangePersonalData() + + if request.user.nickname: + form.fields['nickname'].initial = request.user.nickname + + super().__init__( + name='change_personal_data_form_widget', + request=request, + form=form, + post_target_url=UserModelView.post_url(), + button_text=_('Save changes'), + **kwargs + ) diff --git a/BaCa2/widgets/listing/__init__.py b/BaCa2/widgets/listing/__init__.py new file mode 100644 index 00000000..4d8305b5 --- /dev/null +++ b/BaCa2/widgets/listing/__init__.py @@ -0,0 +1 @@ +from .base import TableWidget, TableWidgetPaging diff --git a/BaCa2/widgets/listing/base.py b/BaCa2/widgets/listing/base.py new file mode 100644 index 00000000..065e5d1e --- /dev/null +++ b/BaCa2/widgets/listing/base.py @@ -0,0 +1,414 @@ +from __future__ import annotations + +import json +from typing import Any, Dict, List + +from django.http import HttpRequest +from django.utils.functional import Promise +from django.utils.translation import gettext_lazy as _ + +from widgets.base import Widget +from widgets.forms import BaCa2ModelForm, FormWidget +from widgets.listing.columns import Column, DeleteColumn, SelectColumn +from widgets.popups.forms import SubmitConfirmationPopup + + +class TableWidget(Widget): + """ + Widget used to display a table of data. Constructor arguments define the table's properties + such as title, columns, data source, search options, etc. + + The table widget is rendered with the help of the DataTables jQuery plugin using the + `templates/widget_templates/table.html` template. + + All table widgets used to display database records should fetch their data with an AJAX get + request to the appropriate model view. As such their data source should be set to the url of + the model view from which they receive their data (generated using the `get_url` method of the + model view class). The data source url should return a JSON object containing a 'data' key with + a list of dictionaries representing table rows. The keys of the dictionaries should + correspond to the names of the table columns. + + If the table widget is used to display static data, the data source should be provided as a + list of dictionaries representing table rows. The keys of the dictionaries should correspond to + the names of the table columns. All records in the table should have the same keys as well as + an 'id' key. If the provided dictionaries do not contain an 'id' key, the table widget will + automatically generate unique ids for the records. + + See also: + - :class:`Column` + - :class:`TableWidgetPaging` + - :class:`Widget` + """ + + LOCALISATION = { + 'pl': '//cdn.datatables.net/plug-ins/2.0.3/i18n/pl.json', + } + + def __init__(self, + *, + name: str, + data_source: str | List[Dict[str, Any]], + cols: List[Column], + request: HttpRequest | None = None, + title: str = '', + display_title: bool = True, + allow_global_search: bool = True, + allow_select: bool = False, + deselect_on_filter: bool = True, + allow_delete: bool = False, + delete_form: BaCa2ModelForm = None, + data_post_url: str = '', + paging: TableWidgetPaging = None, + table_height: int | None = None, + resizable_height: bool = False, + link_format_string: str = '', + refresh_button: bool = False, + refresh: bool = False, + refresh_interval: int = 30, + default_sorting: bool = True, + default_order_col: str = '', + default_order_asc: bool = True, + stripe_rows: bool = True, + highlight_rows_on_hover: bool = False, + hide_col_headers: bool = False, + language: str = 'pl', ) -> None: + """ + :param name: The name of the table widget. Names are used as ids for the HTML + elements of the rendered table widgets. + :type name: str + :param data_source: Data source used to populate the table. If the table is used to display + static data, the data source should be provided as a list of dictionaries representing + table rows. The keys of the dictionaries should correspond to the names of the table + columns. If the table is used to display database records, the data source should be + set to the url of the model view from which the table receives its data. The data source + should return a JSON object containing a 'data' key with a list of dictionaries + representing table rows. The keys of the dictionaries should correspond to the names of + the table columns. + :type data_source: str | List[Dict[str, Any]] + :param cols: List of columns to be displayed in the table. Each column object defines the + column's properties such as name, header, searchability, etc. + :type cols: List[:class:`Column`] + :param request: The HTTP request object received by the view this table widget is rendered + in. + :type request: HttpRequest + :param title: The title of the table. The title is displayed in the util header above the + table if display_title is True. + :type title: str + :param display_title: Whether to display the title in the util header above the table. + :type display_title: bool + :param allow_global_search: Whether to display a global search field in the util header + above the table. + :type allow_global_search: bool + :param allow_select: Whether to allow selecting rows in the table. + :type allow_select: bool + :param deselect_on_filter: Whether to deselect all selected filtered out rows when global + or column search is applied. + :type deselect_on_filter: bool + :param allow_delete: Whether to allow deleting records from the table. + :type allow_delete: bool + :param delete_form: The form used to delete database records represented by the table rows. + Only relevant if allow_delete is True. + :type delete_form: :class:`BaCa2ModelForm` + :param data_post_url: The url to which the data from the table delete form is posted. Only + relevant if allow_delete is True. + :type data_post_url: str + :param paging: Paging options for the table. If not set, paging is disabled. + :type paging: :class:`TableWidgetPaging` + :param table_height: The height of the table in percent of the viewport height. If not set, + the table height is not limited. + :type table_height: int | None + :param resizable_height: Whether to allow for dynamic resizing of the table height (through + dragging a handle at the bottom of the table). Only relevant if table_height is set. + :type resizable_height: bool + :param link_format_string: A format string used to generate links for the table rows. The + format string can reference the fields of database records represented by the table rows + using double square brackets. For example, if the format string is '/records/[[id]]', the + table rows will be linked to '/records/1', '/records/2', etc. + :type link_format_string: str + :param refresh_button: Whether to display a refresh button in the util header above the + table. Refreshing the table will reload the data from the data source. + :type refresh_button: bool + :param refresh: Whether to automatically refresh the table data at a given interval. + :type refresh: bool + :param refresh_interval: The interval in seconds at which the table data is refreshed. + :type refresh_interval: int + :param default_sorting: Whether to use default sorting for the table. + :type default_sorting: bool + :param default_order_col: The name of the column to use for default ordering. If not set, + the first column with sortable=True is used. + :type default_order_col: str + :param default_order_asc: Whether to use ascending or descending order for default + ordering. If not set, ascending order is used. + :type default_order_asc: bool + :param stripe_rows: Whether to stripe the table rows. + :type stripe_rows: bool + :param highlight_rows_on_hover: Whether to highlight the table rows on mouse hover. + :type highlight_rows_on_hover: bool + :param hide_col_headers: Whether to hide the column headers. + :type hide_col_headers: bool + :param language: The language of the table. The language is used to set the DataTables + language option. If not set, the language is set up from default django/user settings. + :type language: str + """ + super().__init__(name=name, request=request) + + if display_title and not title: + raise Widget.WidgetParameterError('Title must be set if display_title is True.') + + self.title = title + self.display_title = display_title + + if not default_order_col and default_sorting: + default_order_col = next(col.name for col in cols if getattr(col, 'sortable')) + + if allow_select: + cols.insert(0, SelectColumn()) + + if allow_delete: + if not delete_form: + raise Widget.WidgetParameterError('Delete form must be set if allow_delete is ' + 'True.') + if not data_post_url: + raise Widget.WidgetParameterError('Data post url must be set if allow_delete is ' + 'True.') + + delete_record_form_widget = DeleteRecordFormWidget( + request=request, + form=delete_form, + post_url=data_post_url, + name=f'{name}_delete_record_form' + ) + cols.append(DeleteColumn()) + else: + delete_record_form_widget = None + self.allow_delete = allow_delete + self.delete_record_form_widget = delete_record_form_widget + + if allow_select and allow_delete: + self.delete_button = True + else: + self.delete_button = False + + if stripe_rows: + self.add_class('stripe') + if highlight_rows_on_hover or link_format_string: + self.add_class('row-hover') + if link_format_string: + self.add_class('link-records') + if hide_col_headers: + self.add_class('no-header') + + for col in cols: + col.request = request + self.cols = cols + + if isinstance(data_source, str): + self.data_source_url = data_source + self.data_source = json.dumps([]) + self.ajax = True + else: + self.data_source_url = '' + self.data_source = json.dumps(self.parse_static_data(data_source, self.cols), + ensure_ascii=False) + self.ajax = False + + self.allow_global_search = allow_global_search + self.deselect_on_filter = deselect_on_filter + self.link_format_string = link_format_string + self.refresh_button = refresh_button + self.paging = paging + self.refresh = refresh + self.refresh_interval = refresh_interval * 1000 + + if self.delete_button or self.refresh_button: + self.table_buttons = True + else: + self.table_buttons = False + + self.default_sorting = default_sorting + self.default_order = 'asc' if default_order_asc else 'desc' + + if default_sorting: + self.default_order_col = self.get_default_order_col_index(default_order_col, self.cols) + else: + self.default_order_col = 0 + + if table_height: + self.limit_height = True + self.table_height = f'{table_height}vh' + self.resizable_height = resizable_height + else: + self.limit_height = False + self.table_height = '' + self.resizable_height = False + + self.language_cdn = '' # self.LOCALISATION.get(language) + # TODO: Localisation overwrites our table styling. For now it's disabled. BWA-65 + + @staticmethod + def get_default_order_col_index(default_order_col: str, cols: List[Column]) -> int: + """ + :param default_order_col: The name of the column to use for default ordering. + :type default_order_col: str + :param cols: List of columns to be displayed in the table. + :type cols: List[:class:`Column`] + :return: The index of the column to use for default ordering. + :rtype: int + """ + try: + return next(index for index, col in enumerate(cols) + if getattr(col, 'name') == default_order_col) + except StopIteration: + raise Widget.WidgetParameterError(f'Column {default_order_col} not in the table') + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'title': self.title, + 'display_util_header': self.display_util_header(), + 'display_title': self.display_title, + 'allow_global_search': json.dumps(self.allow_global_search), + 'deselect_on_filter': json.dumps(self.deselect_on_filter), + 'ajax': json.dumps(self.ajax), + 'data_source_url': self.data_source_url, + 'data_source': self.data_source, + 'link_format_string': self.link_format_string or json.dumps(False), + 'cols': [col.get_context() for col in self.cols], + 'DT_cols_data': [col.data_tables_context() for col in self.cols], + 'cols_num': len(self.cols), + 'table_buttons': self.table_buttons, + 'paging': self.paging.get_context() if self.paging else json.dumps(False), + 'resizable_height': self.resizable_height, + 'limit_height': json.dumps(self.limit_height), + 'table_height': self.table_height, + 'refresh': json.dumps(self.refresh), + 'refresh_interval': self.refresh_interval, + 'refresh_button': json.dumps(self.refresh_button), + 'default_sorting': json.dumps(self.default_sorting), + 'default_order_col': self.default_order_col, + 'default_order': self.default_order, + 'allow_delete': self.allow_delete, + 'delete_button': self.delete_button, + 'delete_record_form_widget': self.delete_record_form_widget.get_context() + if self.delete_record_form_widget else None, + 'localisation_cdn': self.language_cdn + } + + def display_util_header(self) -> bool: + """ + :return: Whether to display the util header above the table. + :rtype: bool + """ + return self.display_title or self.table_buttons or self.allow_global_search + + @staticmethod + def parse_static_data(data: List[Dict[str, Any]], cols: List[Column]) -> List[Dict[str, Any]]: + """ + :param data: List of dictionaries representing table rows. + :type data: List[Dict[str, Any]] + :param cols: List of columns to be displayed in the table. + :type cols: List[:class:`Column`] + :return: List of dictionaries representing table rows with unique ids for each record and + all columns present in each record (if not present, the column is set to a "---" + string). + :rtype: List[Dict[str, Any]] + """ + for col in cols: + for record in data: + if col.name not in record: + record[col.name] = '---' + + for record in data: + for key, value in record.items(): + if not str(value).strip(): + record[key] = '---' + if isinstance(value, Promise): + record[key] = str(value) + + for index, record in enumerate(data): + if 'id' not in record: + record['id'] = index + + return data + + +class TableWidgetPaging: + """ + Helper class for table widget used to define paging options. + + See also: + - :class:`TableWidget` + """ + + def __init__(self, + page_length: int = 10, + allow_length_change: bool = False, + length_change_options: List[int] = None, + deselect_on_page_change: bool = True) -> None: + """ + :param page_length: The number of records to display per page. + :type page_length: int + :param allow_length_change: Whether to allow changing the number of records displayed per + page. + :type allow_length_change: bool + :param length_change_options: List of options for the number of records displayed per page. + If not set, the options are generated automatically based on the default page length. + :type length_change_options: List[int] + :param deselect_on_page_change: Whether to deselect all selected rows when changing the + page. Only relevant if the table allows selecting rows. + :type deselect_on_page_change: bool + """ + self.page_length = page_length + self.allow_length_change = allow_length_change + self.deselect_on_page_change = deselect_on_page_change + + if not length_change_options: + if page_length == 10: + length_change_options = [10, 25, 50, -1] + else: + length_change_options = [page_length * i for i in range(1, 4)] + [-1] + self.length_change_options = length_change_options + + def get_context(self) -> Dict[str, Any]: + return { + 'page_length': self.page_length, + 'allow_length_change': json.dumps(self.allow_length_change), + 'length_change_options': self.length_change_options, + 'deselect_on_page_change': json.dumps(self.deselect_on_page_change) + } + + +class DeleteRecordFormWidget(FormWidget): + """ + Form widget class used to render a delete record form of the table widget if it allows for + record deletion. + + See also: + - :class:`TableWidget` + - :class:`FormWidget` + """ + + def __init__(self, + request: HttpRequest, + form: BaCa2ModelForm, + post_url: str, + name: str) -> None: + """ + :param request: The HTTP request object received by the view this form widget's parent + table widget is rendered in. + :type request: HttpRequest + :param form: The delete record form. + :type form: :class:`BaCa2ModelForm` + :param post_url: The url to which the form is posted. Should be the url of the model class + view from which the table widget receives its data. + :type post_url: str + :param name: The name of the delete record form widget. + :type name: str + """ + super().__init__(request=request, + form=form, + post_target_url=post_url, + name=name, + submit_confirmation_popup=SubmitConfirmationPopup( + title=_('Confirm record deletion'), + message=_('Are you sure you want to delete this record') + )) diff --git a/BaCa2/widgets/listing/col_defs.py b/BaCa2/widgets/listing/col_defs.py new file mode 100644 index 00000000..a8e56dd5 --- /dev/null +++ b/BaCa2/widgets/listing/col_defs.py @@ -0,0 +1,15 @@ +from widgets.forms.course import RejudgeSubmitFormWidget +from widgets.listing.columns import FormSubmitColumn + + +class RejudgeSubmitColumn(FormSubmitColumn): + def __init__(self, course_id, request): + super().__init__(name='rejudge', + form_widget=RejudgeSubmitFormWidget(course_id=course_id, request=request), + mappings={'submit_id': 'id'}, + btn_icon='exclamation-triangle', + header_icon='clock-history', + condition_key='is_legacy', + condition_value='true', + disabled_appearance=FormSubmitColumn.DisabledAppearance.ICON, + disabled_content='check-lg') diff --git a/BaCa2/widgets/listing/columns.py b/BaCa2/widgets/listing/columns.py new file mode 100644 index 00000000..948c2820 --- /dev/null +++ b/BaCa2/widgets/listing/columns.py @@ -0,0 +1,359 @@ +from __future__ import annotations + +import json +from enum import Enum +from typing import Any, Dict + +from django.http import HttpRequest + +from widgets.base import Widget +from widgets.forms import FormWidget + + +class Column(Widget): + """ + A helper class for the table widget used to define the properties of a table column. + + See also: + - :class:`widgets.listing.base.TableWidget` + - :class:`TextColumn` + """ + + #: The template used to render the column header. Must be within the + #: 'templates/widget_templates/listing' directory. + template = None + + def __init__(self, + *, + name: str, + col_type: str, + request: HttpRequest = None, + data_null: bool = False, + header: str | None = None, + header_icon: str | None = None, + searchable: bool = True, + search_header: bool = False, + sortable: bool = False, + auto_width: bool = True, + width: str | None = None) -> None: + """ + :param name: The name of the column. This should be the same as the key under which + the column's data can be found in the data dictionary retrieved by the table widget. + :type name: str + :param col_type: The type of the column. This is used to determine how the data in the + column should be displayed. + :type col_type: str + :param request: The HTTP request object received by the view in which the table widget + is rendered. + :type request: HttpRequest + :param data_null: Whether the column displays data retrieved from the table's data source. + should be set to True for all special columns not based on retrieved data, such as the + select and delete columns. + :type data_null: bool + :param header: The text to be displayed in the column header. If not set, the column name + will be used instead. + :type header: str + :param header_icon: The icon to be displayed in the column header. If no header text is + set, the icon will be displayed in place of the header text. If both header text and + icon are set, the icon will be displayed to the left of the header text. + :type header_icon: str + :param searchable: Whether values in the column should be searchable. + :type searchable: bool + :param search_header: Whether the column header should be replaced with a column-specific + search input. Only applicable if searchable is set to True. + :type search_header: bool + :param sortable: Whether the column should be sortable. + :type sortable: bool + :param auto_width: Whether the column width should be determined automatically. If set to + False, the width parameter must be set. + :type auto_width: bool + :param width: The width of the column. This parameter should only be set if auto_width is + set to False. + :type width: str + """ + if auto_width and width: + raise self.WidgetParameterError('Cannot set column width when auto width is enabled.') + if not auto_width and not width: + raise self.WidgetParameterError('Must set column width when auto width is disabled.') + + super().__init__(name=name, request=request) + + if header is None and header_icon is None: + header = name + + self.header = header + self.header_icon = header_icon + self.col_type = col_type + self.data_null = data_null + self.searchable = searchable + self.search_header = search_header + self.sortable = sortable + self.auto_width = auto_width + self.width = width if width else '' + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'template': f'widget_templates/listing/{self.template}', + 'col_type': self.col_type, + 'header': self.header, + 'header_icon': self.header_icon, + 'data_null': json.dumps(self.data_null), + 'searchable': json.dumps(self.searchable), + 'search_header': self.search_header, + 'sortable': json.dumps(self.sortable), + 'auto_width': json.dumps(self.auto_width), + 'width': self.width + } + + def data_tables_context(self) -> Dict[str, Any]: + return { + 'name': self.name, + 'col_type': self.col_type, + 'data_null': json.dumps(self.data_null), + 'searchable': json.dumps(self.searchable), + 'sortable': json.dumps(self.sortable), + 'auto_width': json.dumps(self.auto_width), + 'width': self.width + } + + +class TextColumn(Column): + """ + Basic column type for displaying text data. + + See also: + - :class:`widgets.listing.base.TableWidget` + - :class:`Column` + """ + + template = 'text_column.html' + + def __init__(self, + *, + name: str, + header: str | None = None, + header_icon: str | None = None, + searchable: bool = True, + search_header: bool = False, + sortable: bool = True, + auto_width: bool = True, + width: str | None = None) -> None: + """ + :param name: The name of the column. This should be the same as the key under which + the column's data can be found in the data dictionary retrieved by the table widget. + :type name: str + :param header: The text to be displayed in the column header. If not set, the column name + will be used instead. + :type header: str + :param header_icon: The icon to be displayed in the column header. If no header text is + set, the icon will be displayed in place of the header text. If both header text and + icon are set, the icon will be displayed to the left of the header text. + :type header_icon: str + :param searchable: Whether values in the column should be searchable. + :type searchable: bool + :param search_header: Whether the column header should be replaced with a column-specific + search input. Only applicable if searchable is set to True. + :type search_header: bool + :param sortable: Whether the column should be sortable. + :type sortable: bool + :param auto_width: Whether the column width should be determined automatically. If set to + False, the width parameter must be set. + :type auto_width: bool + :param width: The width of the column. This parameter should only be set if auto_width is + set to False. + :type width: str + """ + super().__init__(name=name, + col_type='text', + data_null=False, + header=header, + header_icon=header_icon, + searchable=searchable, + search_header=search_header, + sortable=sortable, + auto_width=auto_width, + width=width) + + +class DatetimeColumn(Column): + """ + Basic column type for displaying text data. + + See also: + - :class:`widgets.listing.base.TableWidget` + - :class:`Column` + """ + + template = 'text_column.html' + + def __init__(self, *, + name: str, + header: str | None = None, + header_icon: str | None = None, + formatter: str = 'dd/MM/yyyy H:mm', + searchable: bool = True, + search_header: bool = False, + sortable: bool = True, + auto_width: bool = True, + width: str | None = None) -> None: + """ + :param name: The name of the column. This should be the same as the key under which + the column's data can be found in the data dictionary retrieved by the table widget. + :type name: str + :param header: The text to be displayed in the column header. If not set, the column name + will be used instead. + :type header: str + :param header_icon: The icon to be displayed in the column header. If no header text is + set, the icon will be displayed in place of the header text. If both header text and + icon are set, the icon will be displayed to the left of the header text. + :type header_icon: str + :param searchable: Whether values in the column should be searchable. + :type searchable: bool + :param search_header: Whether the column header should be replaced with a column-specific + search input. Only applicable if searchable is set to True. + :type search_header: bool + :param sortable: Whether the column should be sortable. + :type sortable: bool + :param auto_width: Whether the column width should be determined automatically. If set to + False, the width parameter must be set. + :type auto_width: bool + :param width: The width of the column. This parameter should only be set if auto_width is + set to False. + :type width: str + """ + super().__init__(name=name, + col_type='datetime', + data_null=False, + header=header, + header_icon=header_icon, + searchable=searchable, + search_header=search_header, + sortable=sortable, + auto_width=auto_width, + width=width) + self.formatter = formatter + + def data_tables_context(self) -> Dict[str, Any]: + return super().data_tables_context() | { + 'formatter': self.formatter + } + + +class SelectColumn(Column): + """ + Column used for displaying checkboxes for row selection. + + See also: + - :class:`widgets.listing.base.TableWidget` + - :class:`Column` + """ + + template = 'text_column.html' + + def __init__(self) -> None: + super().__init__(name='select', + col_type='select', + data_null=True, + header='', + searchable=False, + search_header=False, + sortable=False, + auto_width=False, + width='1rem') + + +class DeleteColumn(Column): + """ + Column used for displaying delete buttons for record deletion. + + See also: + - :class:`widgets.listing.base.TableWidget` + - :class:`Column` + """ + + template = 'text_column.html' + + def __init__(self) -> None: + super().__init__(name='delete', + col_type='delete', + data_null=True, + header='', + searchable=False, + search_header=False, + sortable=False, + auto_width=False, + width='1rem') + + +class FormSubmitColumn(Column): + template = 'form_submit_column.html' + + class DisabledAppearance(Enum): + DISABLED = 'disabled' + HIDDEN = 'hidden' + ICON = 'icon' + TEXT = 'text' + + def __init__(self, *, + name: str, + form_widget: FormWidget, + mappings: Dict[str, str], + header: str | None = None, + header_icon: str | None = None, + btn_text: str = '', + btn_icon: str = '', + condition_key: str = '', + condition_value: str = 'true', + disabled_appearance: 'DisabledAppearance' = None, + disabled_content: str = '', + refresh_table_on_submit: bool = True) -> None: + super().__init__(name=name, + col_type='form-submit', + data_null=True, + header=header, + header_icon=header_icon, + searchable=False, + search_header=False, + sortable=False, + auto_width=False, + width='1rem') + + if not btn_text and not btn_icon: + raise ValueError('Either btn_text or btn_icon must be set.') + + for key in mappings.keys(): + if key not in form_widget.form.fields.keys(): + raise ValueError(f'Key "{key}" not found in form fields.') + + self.form_widget = form_widget + self.mappings = mappings + self.btn_text = btn_text + self.btn_icon = btn_icon + self.condition_key = condition_key + self.condition_value = condition_value + + if not disabled_appearance: + disabled_appearance = self.DisabledAppearance.DISABLED + + self.disabled_appearance = disabled_appearance + self.disabled_content = disabled_content + self.refresh_table_on_submit = refresh_table_on_submit + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'form_widget': self.form_widget.get_context(), + } + + def data_tables_context(self) -> Dict[str, Any]: + return super().data_tables_context() | { + 'form_id': self.form_widget.name, + 'mappings': json.dumps(self.mappings), + 'btn_text': self.btn_text, + 'btn_icon': self.btn_icon, + 'conditional': json.dumps(bool(self.condition_key)), + 'condition_key': self.condition_key, + 'condition_value': self.condition_value, + 'disabled_appearance': self.disabled_appearance.value, + 'disabled_content': self.disabled_content, + 'refresh_table_on_submit': json.dumps(self.refresh_table_on_submit) + } diff --git a/BaCa2/widgets/navigation.py b/BaCa2/widgets/navigation.py new file mode 100644 index 00000000..26b33efa --- /dev/null +++ b/BaCa2/widgets/navigation.py @@ -0,0 +1,143 @@ +from typing import Any, Dict, List + +from django.http.request import HttpRequest +from django.urls import reverse_lazy +from django.utils.translation import gettext_lazy as _ + +from util.models_registry import ModelsRegistry +# from core.choices import BasicModelAction +# from package.models import PackageInstance +from widgets.base import Widget + + +class NavBar(Widget): + """ + Navbar widget containing links to the most important pages. Visible links depend on user's + permissions. + """ + + def __init__(self, request: HttpRequest) -> None: + """ + :param request: Request object used to determine user's permissions and to generate links. + :type request: HttpRequest + """ + super().__init__(name='navbar', request=request) + + self.links = [ + {'name': _('Dashboard'), 'url': reverse_lazy('main:dashboard')}, + {'name': _('Courses'), 'url': reverse_lazy('main:courses')}, + # {'name': _('Tasks'), 'url': '#'} + ] + + # if request.user.has_basic_model_permissions(model=PackageInstance, + # permissions=BasicModelAction.VIEW): + # self.links.append({'name': _('Packages'), 'url': '#'}) + + if request.user.is_superuser: + self.links.append({'name': _('Admin'), 'url': reverse_lazy('main:admin')}) + + course_id = getattr(request, 'course_id', None) + + if course_id: + course = ModelsRegistry.get_course(course_id) + self.links.append('|') + self.links.append( + {'name': course.name, + 'url': reverse_lazy('course:course-view', kwargs={'course_id': course_id})} + ) + + def get_context(self) -> Dict[str, Any]: + return {'links': self.links} + + +class SideNav(Widget): + """ + Side navigation widget containing a custom set of tabs. Tabs are all part of the same page and + unlike in the navbar do not represent links to other urls. + """ + + def __init__(self, + request: HttpRequest, + tabs: List[str], + sub_tabs: Dict[str, List[str]] = None, + collapsed: bool = False, + toggle_button: bool = False, + sticky: bool = True) -> None: + """ + :param request: HTTP request object received by the view this side nav panel is rendered in. + :type request: HttpRequest + :param tabs: List of tab names. + :type tabs: List[str] + :param sub_tabs: Dictionary of sub-tabs. Each key is the name of the tab and the value is a + list of sub-tab names. + :type sub_tabs: Dict[str, List[str]] + :param collapsed: Whether the side navigation sub-tabs should be collapsed by default and + expand only on hover/use (or when the toggle button is clicked). + :type collapsed: bool + :param toggle_button: Whether the toggle button should be displayed. Toggle button is used + to expand/collapse the side navigation sub-tabs. + :type toggle_button: bool + :param sticky: Whether the side navigation should be sticky and always visible. + :type sticky: bool + """ + super().__init__(name='sidenav', request=request) + sub_tabs = sub_tabs or {} + + self.toggle_button = {'on': toggle_button, + 'state': collapsed, + 'text_collapsed': _('Expand'), + 'text_expanded': _('Collapse')} + + self.tabs = [ + { + 'name': tab_name, + 'data_id': SideNav.normalize_tab_name(tab_name) + '-tab', + 'sub_tabs': [{'name': sub_tab_name, + 'data_id': SideNav.normalize_tab_name(sub_tab_name) + '-tab'} + for sub_tab_name in sub_tabs.get(tab_name, [])] + } + for tab_name in tabs + ] + + if sticky: + self.add_class('sticky-side-nav') + if collapsed: + self.add_class('collapsed') + else: + self.add_class('expanded') + + @staticmethod + def normalize_tab_name(tab_name: str) -> str: + """ + Normalizes tab name by replacing spaces with dashes and converting to lowercase. + + :param tab_name: Tab name to normalize. + :type tab_name: str + + :return: Normalized tab name. + :rtype: str + """ + return tab_name.replace(' ', '-').lower() + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | {'tabs': self.tabs, 'toggle_button': self.toggle_button} + + def add_tab(self, tab_name: str, sub_tabs: List[str] = None) -> None: + """ + Adds a new tab to the side navigation. + + :param tab_name: Name of the tab to add. + :type tab_name: str + :param sub_tabs: List of sub-tab names. + :type sub_tabs: List[str] + """ + new_tab = { + 'name': tab_name, + 'data_id': SideNav.normalize_tab_name(tab_name) + '-tab', + 'sub_tabs': [] + } + if sub_tabs: + new_tab['sub_tabs'] = [{'name': sub_tab_name, + 'data_id': SideNav.normalize_tab_name(sub_tab_name) + '-tab'} + for sub_tab_name in sub_tabs] + self.tabs.append(new_tab) diff --git a/BaCa2/widgets/popups/__init__.py b/BaCa2/widgets/popups/__init__.py new file mode 100644 index 00000000..8c2bbaa0 --- /dev/null +++ b/BaCa2/widgets/popups/__init__.py @@ -0,0 +1 @@ +from .base import PopupWidget diff --git a/BaCa2/widgets/popups/base.py b/BaCa2/widgets/popups/base.py new file mode 100644 index 00000000..813b3fb7 --- /dev/null +++ b/BaCa2/widgets/popups/base.py @@ -0,0 +1,57 @@ +from enum import Enum +from typing import Any, Dict + +from django.http import HttpRequest + +from widgets.base import Widget + + +class PopupWidget(Widget): + """ + Base class for all popup widgets. Contains context fields common to all popups. + + See Also: + - :class:`widgets.base.Widget` + """ + + class PopupSize(Enum): + """ + Enum representing the possible sizes of a popup. Values are CSS Bootstrap classes. + """ + SMALL = 'modal-sm' + MEDIUM = '' + LARGE = 'modal-lg' + EXTRA_LARGE = 'modal-xl' + + def __init__(self, + *, + name: str, + request: HttpRequest, + title: str, + message: str, + widget_class: str = '', + size: PopupSize = PopupSize.MEDIUM) -> None: + """ + :param name: Name of the widget. + :type name: str + :param request: HTTP request object received by the view this popup is rendered in. + :type request: HttpRequest + :param title: Title of the popup. + :type title: str + :param message: Message of the popup. + :type message: str + :param widget_class: CSS class applied to the popup. + :type widget_class: str + :param size: Size of the popup. + :type size: :class:`PopupWidget.PopupSize` + """ + super().__init__(name=name, request=request, widget_class=widget_class) + self.title = title + self.message = message + self.add_class(size.value) + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'title': self.title, + 'message': self.message, + } diff --git a/BaCa2/widgets/popups/forms.py b/BaCa2/widgets/popups/forms.py new file mode 100644 index 00000000..073eb1fd --- /dev/null +++ b/BaCa2/widgets/popups/forms.py @@ -0,0 +1,178 @@ +from typing import Any, Dict, List + +from django.http import HttpRequest +from django.utils.translation import gettext_lazy as _ + +from widgets.base import Widget +from widgets.popups.base import PopupWidget + + +class SubmitConfirmationPopup(PopupWidget): + """ + A popup widget used to confirm a form submission. Can optionally display a summary of the form + data. + + See Also: + - :class:`widgets.forms.FormWidget` + - :class:`widgets.popups.PopupWidget` + """ + + def __init__(self, + title: str, + message: str, + name: str = '', + request: HttpRequest = None, + confirm_button_text: str = None, + cancel_button_text: str = None, + input_summary: bool = False, + input_summary_fields: List[str] = None) -> None: + """ + :param title: Title of the popup. + :type title: str + :param message: Message of the popup. + :type message: str + :param name: Name of the widget. Will normally be automatically provided by a parent form + widget. + :type name: str + :param request: HTTP request object received by the view this popup is rendered in. Provided + by the parent form widget. + :type request: HttpRequest + :param confirm_button_text: Text displayed on the submission confirmation button. + :type confirm_button_text: str + :param cancel_button_text: Text displayed on the submission cancellation button. + :type cancel_button_text: str + :param input_summary: Whether to display a summary of the form data. + :type input_summary: bool + :param input_summary_fields: List of field names to display in the input summary. + :type input_summary_fields: List[str] + :raises Widget.WidgetParameterError: If input summary is enabled without specifying input + summary fields. + """ + if confirm_button_text is None: + confirm_button_text = _('Confirm') + if cancel_button_text is None: + cancel_button_text = _('Cancel') + + if input_summary and not input_summary_fields: + raise Widget.WidgetParameterError( + 'Cannot use input summary without specifying input summary fields.' + ) + + super().__init__(name=name, + request=request, + title=title, + message=message, + widget_class='form-confirmation-popup', + size=PopupWidget.PopupSize.MEDIUM) + self.confirm_button_text = confirm_button_text + self.cancel_button_text = cancel_button_text + self.input_summary = input_summary + self.input_summary_fields = input_summary_fields + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'input_summary': self.input_summary, + 'input_summary_fields': self.input_summary_fields, + 'confirm_button_text': self.confirm_button_text, + 'cancel_button_text': self.cancel_button_text + } + + +class SubmitSuccessPopup(PopupWidget): + """ + A popup widget used to display a success message after a form submission. Can display either a + predefined message or a message received in the JSON response from the server. + + See Also: + - :class:`widgets.forms.FormWidget` + - :class:`widgets.popups.PopupWidget` + """ + + def __init__(self, + title: str = None, + message: str = '', + name: str = '', + request: HttpRequest = None, + confirm_button_text: str = None) -> None: + """ + :param title: Title of the popup. Defaults to "Success". + :type title: str + :param message: Message of the popup. If not specified, the message received in the JSON + response from the server will be used instead. + :type message: str + :param name: Name of the widget. Will normally be automatically provided by a parent form + widget. + :type name: str + :param request: HTTP request object received by the view this popup is rendered in. Provided + by the parent form widget. + :type request: HttpRequest + :param confirm_button_text: Text displayed on the confirmation button. Defaults to "OK". + :type confirm_button_text: str + """ + if title is None: + title = _('Success') + if confirm_button_text is None: + confirm_button_text = _('OK') + + super().__init__(name=name, + request=request, + title=title, + message=message, + widget_class='form-success-popup', + size=PopupWidget.PopupSize.SMALL) + self.confirm_button_text = confirm_button_text + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'confirm_button_text': self.confirm_button_text + } + + +class SubmitFailurePopup(PopupWidget): + """ + A popup widget used to display a failure message after a form submission. Can display either a + predefined message or a message received in the JSON response from the server. + + See Also: + - :class:`widgets.forms.FormWidget` + - :class:`widgets.popups.PopupWidget` + """ + + def __init__(self, + title: str = None, + message: str = '', + name: str = '', + request: HttpRequest = None, + confirm_button_text: str = None) -> None: + """ + :param title: Title of the popup. Defaults to "Failure". + :type title: str + :param message: Message of the popup. If not specified, the message received in the JSON + response from the server will be used instead. + :type message: str + :param name: Name of the widget. Will normally be automatically provided by a parent form + widget. + :type name: str + :param request: HTTP request object received by the view this popup is rendered in. Provided + by the parent form widget. + :type request: HttpRequest + :param confirm_button_text: Text displayed on the confirmation button. Defaults to "OK". + :type confirm_button_text: str + """ + if title is None: + title = _('Failure') + if confirm_button_text is None: + confirm_button_text = _('OK') + + super().__init__(name=name, + request=request, + title=title, + message=message, + widget_class='form-failure-popup', + size=PopupWidget.PopupSize.SMALL) + self.confirm_button_text = confirm_button_text + + def get_context(self) -> Dict[str, Any]: + return super().get_context() | { + 'confirm_button_text': self.confirm_button_text + } diff --git a/BaCa2/widgets/querying.py b/BaCa2/widgets/querying.py new file mode 100644 index 00000000..9996a851 --- /dev/null +++ b/BaCa2/widgets/querying.py @@ -0,0 +1,88 @@ +from typing import List + +from django.core.exceptions import ValidationError +from django.db import models +from django.db.models import Q +from django.db.models.query import QuerySet + + +def validate_filter( + model: models, + required_values: dict = None, + or_conditions: List[List] = None +) -> bool: + + model_fields = [field.name for field in model._meta.get_fields()] + for field in required_values: + if field not in model_fields: + return False + + for alternative in or_conditions: + for field in alternative: + if field not in model_fields: + return False + + return True + + +def create_qfilter( + required_values: dict = None, + or_conditions: List[List] = None +) -> Q: + + values_dict = {} + qfilter_elems = [] + qfilter = Q() + + for field in required_values: + if not isinstance(required_values[field], List): + required_values[field] = [required_values[field]] + temp = Q() + for val in required_values[field]: + temp |= Q(**{field: val}) + values_dict[field] = temp + + for alternative in or_conditions: + temp = Q() + for field in alternative: + temp |= values_dict[field] + values_dict.pop(field) + qfilter_elems.append(temp) + + for field in values_dict: + qfilter_elems.append(values_dict[field]) + + for elem in qfilter_elems: + qfilter &= elem + + return qfilter + + +def get_queryset( + model: models, + required_values: dict = None, + or_required: List[List] or List = None, + forbidden_values: dict = None, + or_forbidden: List[List] or List = None +) -> QuerySet: + + if not or_required: + or_required = [[]] + if not isinstance(or_required[0], List): + or_required = [or_required] + if not required_values: + required_values = {} + if not or_forbidden: + or_forbidden = [[]] + if not isinstance(or_forbidden[0], List): + or_forbidden = [or_forbidden] + if not forbidden_values: + forbidden_values = {} + + if not validate_filter(model, required_values, or_required): + raise ValidationError('Discrepancy between model fields and filter elements.') + if not validate_filter(model, forbidden_values, or_forbidden): + raise ValidationError('Discrepancy between model fields and filter elements.') + + queryset = model.objects.filter(create_qfilter(required_values, or_required)) + return queryset.exclude(create_qfilter(forbidden_values, or_forbidden)) diff --git a/BaCa2/widgets/text_display.py b/BaCa2/widgets/text_display.py new file mode 100644 index 00000000..98b82870 --- /dev/null +++ b/BaCa2/widgets/text_display.py @@ -0,0 +1,176 @@ +from enum import Enum +from pathlib import Path + +from django.utils.translation import gettext_lazy as _ + +from markdown import markdown +from mdx_math import MathExtension +from widgets.base import Widget + + +class TextDisplayer(Widget): + """ + Wrapper widget which selects the appropriate displayer widget based on the format of the file + provided. If no file is provided, a special case displayer widget is used to display a message + in place of the file. + """ + + class EmptyDisplayer(Widget): + """ + Special case displayer widget used when no file to display is provided. Will be rendered + as a specified message (defaulting to "No file to display"). + """ + def __init__(self, name: str, message: str = None) -> None: + """ + :param name: Name of the widget. + :type name: str + :param message: Message to be displayed in place of the file. + :type message: str + """ + if message is None: + message = _('No file to display') + super().__init__(name=name) + self.message = message + + def get_context(self) -> dict: + return super().get_context() | {'message': self.message} + + def __init__(self, + name: str, + file_path: Path, + no_file_message: str = None, + **kwargs) -> None: + """ + :param name: Name of the widget. + :type name: str + :param file_path: Path to the file to be displayed. File should PDF, HTML, Markdown, or + plain text. If no file is provided, a special case displayer widget will be used to + display a message in place of the file. + :type file_path: Path + :param no_file_message: Message to be displayed in place of the file if no file is provided. + Default is "No file to display". + :type no_file_message: str + :param kwargs: Additional keyword arguments to be passed to the specific displayer widget + selected based on the format of the file provided. + :type kwargs: dict + """ + if no_file_message is None: + no_file_message = _('No file to display') + super().__init__(name=name) + + if not file_path: + self.displayer_type = 'none' + self.displayer = self.EmptyDisplayer(name=name, message=no_file_message) + elif file_path.suffix == '.pdf': + self.displayer_type = 'pdf' + self.displayer = PDFDisplayer(name=name, file_path=file_path) + else: + self.displayer_type = 'markup' + self.displayer = MarkupDisplayer(name=name, file_path=file_path, **kwargs) + + def get_context(self) -> dict: + return super().get_context() | { + 'displayer_type': self.displayer_type, + 'displayer': self.displayer.get_context() + } + + +class MarkupDisplayer(Widget): + """ + Widget used to display the contents of files in HTML, Markdown, or plain text format. Supports + LaTeX math in Markdown files. + """ + + class AcceptedFormats(Enum): + """ + Enum containing all the file formats supported by the widget. + """ + HTML = '.html' + MARKDOWN = '.md' + TEXT = '.txt' + + def __init__(self, + name: str, + file_path: Path, + line_height: float = 1.2, + limit_display_height: bool = True, + display_height: int = 40, + pdf_download: Path = None) -> None: + """ + :param name: Name of the widget. + :type name: str + :param file_path: Path to the file to be displayed. File must be in HTML, Markdown, or plain + text format. + :type file_path: Path + :param line_height: Line height of the displayed text in `rem` units. Default is 1.2. + :type line_height: float + :param limit_display_height: Whether to limit the height of the displayed text. If set to + `True`, the height of the displayed text will be limited to `display_height` lines of + text. + :type limit_display_height: bool + :param display_height: Height of the displayed text in number of standard height lines. + Only used if `limit_display_height` is set to `True`. Default is 50. + :type display_height: int + :param pdf_download: Path to the PDF file to be downloaded. If provided, a download button + will be displayed above the text. + :type pdf_download: Path + """ + super().__init__(name=name) + + suffix = file_path.suffix + + if suffix not in {extension.value for extension in self.AcceptedFormats}: + raise self.WidgetParameterError(f'File format {suffix} not supported.') + + with file_path.open('r', encoding='utf-8') as file: + self.content = file.read() + + if suffix == self.AcceptedFormats.MARKDOWN.value: + self.content = markdown(self.content, extensions=[MathExtension()]) + elif suffix == self.AcceptedFormats.TEXT.value: + self.content = self.content.replace('\n', '
    ')\ + + if pdf_download: + if pdf_download.suffix != '.pdf': + raise self.WidgetParameterError(f'File format {suffix} not supported.') + self.pdf_download = str(pdf_download.name).replace('\\', '/') + else: + self.pdf_download = False + + self.line_height = f'{round(line_height, 2)}rem' + self.limit_display_height = limit_display_height + self.display_height = f'{round(display_height * line_height, 2)}rem' + + def get_context(self) -> dict: + return super().get_context() | { + 'content': self.content, + 'line_height': self.line_height, + 'limit_display_height': self.limit_display_height, + 'display_height': self.display_height, + 'pdf_download': self.pdf_download + } + + +class PDFDisplayer(Widget): + """ + Widget used to display PDF files. + """ + + def __init__(self, name: str, file_path: Path) -> None: + """ + :param name: Name of the widget. + :type name: str + :param file_path: Path to the PDF file to be displayed. + :type file_path: Path + """ + super().__init__(name=name) + + suffix = file_path.suffix + + if suffix != '.pdf': + raise self.WidgetParameterError(f'File format {suffix} not supported.') + + self.file_path = str(file_path.name).replace('\\', '/') + + def get_context(self) -> dict: + return super().get_context() | {'file_path': self.file_path} diff --git a/LICENSE b/LICENSE index e72bfdda..f288702d 100644 --- a/LICENSE +++ b/LICENSE @@ -671,4 +671,4 @@ into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read -. \ No newline at end of file +. diff --git a/Makefile b/Makefile new file mode 100644 index 00000000..e0b8ae8f --- /dev/null +++ b/Makefile @@ -0,0 +1,21 @@ +.PHONY: install +install: + poetry intall + +.PHONY: update +update: + poetry update + +.PHONY: migrations +migrations: + poetry run python BaCa2/manage.py makemigrations + +.PHONY: migrate +migrate: + poetry run python BaCa2/manage.py migrate + +#tests: +# poetry run python BaCa2/manage.py test + +.PHONY: update-all +update-all: update migrations migrate; diff --git a/README.md b/README.md new file mode 100644 index 00000000..745fac8b --- /dev/null +++ b/README.md @@ -0,0 +1,41 @@ +# BaCa2 Logo + +Welcome to the [BaCa2](https://github.com/BaCa2-project) project web application component. This Django-based web app is designed to provide an online platform for creating and managing programming tasks, as well as submitting solutions for automatic evaluation. The system revolves around the concept of courses which are used to organize groups of users into roles and provide them with access to a curated list of tasks designed by course leaders. + +Currently developed for the [Institute of Computer Science and Mathematics](https://ii.uj.edu.pl/en_GB/start) at the Jagiellonian University + +## Overview + +This repository represents the web application component of the BaCa2 Project, a collaborative effort to develop a comprehensive online system for creating programming tasks and automating the validation of submitted solutions. For a broader understanding of the project and its goals, please refer to the [project README](https://github.com/BaCa2-project/.github/blob/main/profile/README.md). + +## Project Structure + +The Django project is organized into three main apps: + +1. **`main`** - responsible for authentication, user data and settings, management of courses and their members + +2. **`course`** - responsible for management of tasks, submissions and other data and functionalities internal to any given course + +3. **`package`** - used to communicate with BaCa2-package-manager and represent its packages within the web app + +## Contributors + +The BaCa2 web application component is currently being developed by 2 members of the project team: + + + krzysztof-kalita-pfp + + +**`Krzysztof Kalita`**
    +Main team member responsible for BaCa2 web app development, working on both backend and frontend. Responsible for the `main` app, views, widgets and backend-frontend communication. + + + bartosz-deptula-pfp + + +**`Bartosz Deptuła`**
    +Responsible for the `course` and `package` app backend, as well as custom database router used to dynamically create databases for new courses and route between them. + +## License + +The BaCa2 Project is licensed under the [GPL-3.0 license](LICENSE). diff --git a/README.rst b/README.rst deleted file mode 100644 index 87ba71ba..00000000 --- a/README.rst +++ /dev/null @@ -1,10 +0,0 @@ -BaCa2 Project -============= - -**BaCa2** is an automated, online code checking system for programming tasks. It is currently being developed for the Faculty of Mathematics and Computer Science of the Jagiellonian University in Cracow, Poland. The system aims to ensure simplicity and ease of use while allowing for significant complexity of both the programming tasks and solution checking methods. - -BaCa2 is currently being developed by a team of four UJ students: -Bartosz Deptuła, -Izabela Golec, -Mateusz Kadula, -Krzysztof Kalita diff --git a/baca2cli.sh b/baca2cli.sh new file mode 100644 index 00000000..80decc74 --- /dev/null +++ b/baca2cli.sh @@ -0,0 +1,333 @@ +#!/bin/bash + +# ANSI color codes +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' # No Color + +APACHE_LOG='/var/log/baca2_access.log' +GUNICORN_LOG='/home/www/BaCa2/BaCa2/logs/info.log' +BROKER_LOG='/home/www/BaCa2-broker/logs/broker.log' + +# Function to display script usage +usage() { + echo "Usage: $0 [--reboot --update-poetry --rm-migrations --clear-db --migrate --status [rows]] --clear-submits" 1>&2 + exit 1 +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --rm-migrations) + RM_MIGRATIONS=true + CLEAR_DB=true + MIGRATE=true + REBOOT=true + CLEAR_SUBMITS=true + shift + ;; + --clear-db) + CLEAR_DB=true + MIGRATE=true + REBOOT=true + CLEAR_SUBMITS=true + shift + ;; + --clear-submits) + CLEAR_SUBMITS=true + shift + ;; + --migrate) + MIGRATE=true + shift + ;; + --reboot) + REBOOT=true + shift + ;; + --update-poetry) + UPDATE_POETRY=true + REBOOT=true + shift + ;; + --status) + STATUS=true + shift + if [[ $1 =~ ^[0-9]+$ ]]; then + LINES_AMOUNT=$1 + shift + else + LINES_AMOUNT=4 + fi + ;; + *) + usage + ;; + esac +done + +print_result() { + if [ $? -eq 0 ]; then + echo -e "${GREEN}done${NC}" + else + echo -e "${RED}error${NC}" + fi +} + +print_status() { + state=$(systemctl is-active apache2) + if [ "$state" = "active" ]; then + echo -e " apache2 -------- ${GREEN}$state${NC}" + else + echo -e " apache2 -------- ${RED}$state${NC}" + fi + + status=$(systemctl is-active baca2_gunicorn) + if [ "$status" = "active" ]; then + echo -e " baca2 web app -- ${GREEN}$status${NC}" + else + echo -e " baca2 web app -- ${RED}$status${NC}" + fi + + status=$(systemctl is-active baca2_broker) + if [ "$status" = "active" ]; then + echo -e " baca2 broker --- ${GREEN}$status${NC}" + else + echo -e " baca2 broker --- ${RED}$status${NC}" + fi +} + +print_extensive_status() { + tput el + echo -e "BaCa2 status:" + + status=$(systemctl is-active apache2) + tput el + if [ "$status" = "active" ]; then + echo -e " apache2 -------- ${GREEN}$status${NC}" + else + echo -e " apache2 -------- ${RED}$status${NC}" + fi + + for ((i=LINES_AMOUNT; i>0; i--)); do + tput el + echo -e "$(tail $APACHE_LOG -n $i | head -n1)" + done + + tput el + echo -e "" + tput el + echo -e "" + status=$(systemctl is-active baca2_gunicorn) + tput el + if [ "$status" = "active" ]; then + echo -e " baca2 web app -- ${GREEN}$status${NC}" + else + echo -e " baca2 web app -- ${RED}$status${NC}" + fi + + for ((i=LINES_AMOUNT; i>0; i--)); do + tput el + echo -e "$(tail $GUNICORN_LOG -n $i | head -n1)" + done + + tput el + echo -e "" + tput el + echo -e "" + status=$(systemctl is-active baca2_broker) + if [ "$status" = "active" ]; then + tput el + echo -e " baca2 broker --- ${GREEN}$status${NC}" + else + echo -e " baca2 broker --- ${RED}$status${NC}" + fi + + for ((i=LINES_AMOUNT; i>0; i--)); do + tput el + echo -e "$(tail $BROKER_LOG -n $i | head -n1)" + done + tput ed + + terminal_width=$(tput cols) + apache2_lines=$(tail $APACHE_LOG -n$LINES_AMOUNT | awk -v width="$terminal_width" '{print int(length / width) + 1}' | awk '{s+=$1} END {print s}') + gunicorn_lines=$(tail $GUNICORN_LOG -n$LINES_AMOUNT | awk -v width="$terminal_width" '{print int(length / width) + 1}' | awk '{s+=$1} END {print s}') + broker_lines=$(tail $BROKER_LOG -n$LINES_AMOUNT | awk -v width="$terminal_width" '{print int(length / width) + 1}' | awk '{s+=$1} END {print s}') + lines=$((apache2_lines + gunicorn_lines + broker_lines + 4)) + + sleep 1 + + tput cuu $lines +} + +# Parse command line options + +if [ "$REBOOT" = true ]; then + echo -n "Stopping services..." + + systemctl stop baca2_broker + systemctl stop baca2_gunicorn + systemctl stop apache2 + + print_result + + echo "" +fi + +if [ "$RM_MIGRATIONS" = true ]; then + echo "Removing migrations:" + echo -n " broker_api..." + rm -rf /home/www/BaCa2/BaCa2/broker_api/migrations/0*.py + print_result + echo -n " course..." + rm -rf /home/www/BaCa2/BaCa2/course/migrations/0*.py + print_result + echo -n " main..." + rm -rf /home/www/BaCa2/BaCa2/main/migrations/0*.py + print_result + echo -n " package..." + rm -rf /home/www/BaCa2/BaCa2/package/migrations/0*.py + print_result + echo "" +fi + +if [ "$CLEAR_DB" = true ]; then + echo "Clearing database:" + echo -n " drop baca2db..." + sudo -u postgres psql -c "DROP DATABASE IF EXISTS baca2db;" > /dev/null 2>&1 + print_result + echo -n " create empty baca2db..." + sudo -u postgres psql -c "CREATE DATABASE baca2db;" > /dev/null 2>&1 + print_result + echo -n " grant privileges to baca2 user..." + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE baca2db TO baca2;" > /dev/null 2>&1 + print_result + echo -n " grant privileges to root user..." + sudo -u postgres psql -c "GRANT ALL PRIVILEGES ON DATABASE baca2db TO root;" > /dev/null 2>&1 + print_result + echo -n " clear db.cache..." + rm -f /home/www/BaCa2/BaCa2/core/db/db.cache + print_result + echo -n " create empty db.cache..." + touch /home/www/BaCa2/BaCa2/core/db/db.cache + print_result + echo -n " chmod db.cache..." + chmod 777 /home/www/BaCa2/BaCa2/core/db/db.cache + print_result + echo "" + echo "Clearing course databases:" + echo -n " introspecting..." + sudo -u postgres psql -c "select datname from pg_database;" -t | grep _db > to_delete.txt + + if [ $? -ne 0 ]; then + echo -e "${RED}no db found${NC}" + else + echo -e "${GREEN}done${NC}" + echo " dropping:" + while read -r line; do + echo -n " $line..." + sudo -u postgres psql -c "DROP DATABASE IF EXISTS $line;" > /dev/null 2>&1 + print_result + done < to_delete.txt + echo -n " removing to_delete.txt..." + rm to_delete.txt + print_result + fi + echo "" +fi + +if [ "$CLEAR_SUBMITS" = true ]; then + echo "Clearing submits:" + echo -n " from baca2 web app..." + rm -rf /home/www/BaCa2/BaCa2/submits/* + print_result + echo -n " from baca2 broker..." + rm -rf /home/www/BaCa2-broker/submits/* + print_result + + echo -n "clear package info..." + rm -rf /home/www/BaCa2/BaCa2/packages_source/* + print_result + + echo -n "clear task descriptions..." + rm -rf /home/www/BaCa2/BaCa2/tasks/* + print_result + echo "" +fi + +# If --migrate flag is provided, perform migration +if [ "$MIGRATE" = true ]; then + echo -n "Creating migrations..." + /home/www/.cache/pypoetry/virtualenvs/baca2-pMI66htX-py3.11/bin/python3.11 /home/www/BaCa2/BaCa2/manage.py makemigrations > /dev/null 2>&1 + if [ $? -eq 0 ]; then + echo -e "${GREEN}done${NC}" + echo -n "Migrating..." + /home/www/.cache/pypoetry/virtualenvs/baca2-pMI66htX-py3.11/bin/python3.11 /home/www/BaCa2/BaCa2/manage.py migrate > /dev/null 2>&1 + if [ $? -eq 0 ]; then + echo -e "${GREEN}done${NC}" + else + echo -e "${RED}error occurred during migration${NC}" + fi + + else + echo -e "${GREEN}error occurred during migration${NC}" + fi + echo "" +fi + +if [ "$CLEAR_DB" = true ]; then + echo -n "Creating superuser..." + echo "from main.models import User; User.objects.create_superuser(email='admin@baca2.ii.uj.edu.pl', password='9pBTR%7XzynBCWD', first_name='Administrator', last_name='Systemu')" | /home/www/.cache/pypoetry/virtualenvs/baca2-pMI66htX-py3.11/bin/python3.11 /home/www/BaCa2/BaCa2/manage.py shell > /dev/null 2>&1 + print_result + echo "" +fi + +if [ "$UPDATE_POETRY" = true ]; then + echo "Updating poetry environments" + echo -n " clear cache..." + sudo -u www -D "/home/www/BaCa2" poetry cache clear --all . -n + print_result + echo " baca2 web app:" + sudo -u www -D /home/www/BaCa2 poetry update --all . -n + echo -n " baca2 web app --- " + print_result + echo " baca2 broker:" + sudo -u www -D "/home/www/BaCa2-broker" poetry update --all . -n + echo -n " baca2 broker..." + print_result + echo "" +fi + +# If --reboot flag is provided, start services +if [ "$REBOOT" = true ]; then + + echo -n "Starting apache2..." + systemctl start apache2 + print_result + + echo -n "Starting baca2 web app..." + systemctl start baca2_gunicorn + print_result + + echo -n "Starting baca2 broker..." + systemctl start baca2_broker + print_result + + echo -e -n "\nStatus check" + + for ((i=0; i<3; i++)); do + echo -n "." + sleep 0.5 + done + echo "" + + print_status + +fi + +if [ "$STATUS" = true ]; then + clear + echo -e "BaCa2 status:" + while true; do + print_extensive_status + done +fi diff --git a/lib/usosapi-python/README.md b/lib/usosapi-python/README.md new file mode 100644 index 00000000..c2ebcff5 --- /dev/null +++ b/lib/usosapi-python/README.md @@ -0,0 +1,13 @@ +Usosapi module +============== + +This module is created to help Python developers use USOS API +without the need of learning how to use any OAuth libraries. + +Requirements +============ +Supported Python versions: >3.3 + +There are two Python packages required for this module to work: + * [rauth](https://github.com/litl/rauth) + * [requests](https://github.com/kennethreitz/requests) diff --git a/lib/usosapi-python/setup.py b/lib/usosapi-python/setup.py new file mode 100644 index 00000000..857d4e46 --- /dev/null +++ b/lib/usosapi-python/setup.py @@ -0,0 +1,76 @@ +# Always prefer setuptools over distutils +# To use a consistent encoding +from codecs import open +from os import path + +from setuptools import find_packages, setup + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +setup( + name='usosapi', + + # Versions should comply with PEP440. For a discussion on single-sourcing + # the version across setup.py and the project code, see + # https://packaging.python.org/en/latest/single_source_version.html + version='1.0.0', + + description='Simple helper module for interacting with USOS API.', + long_description=long_description, + + # The project's main homepage. + url='https://github.com/MUCI/usosapi-python', + + # Author details + author='USOS Developers', + author_email='usos@usos.edu.pl', + + # Choose your license + license='MIT', + + # See https://pypi.python.org/pypi?%3Aaction=list_classifiers + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Beta', + + # Indicate who your project is intended for + 'Intended Audience :: Developers', + + # Pick your license as you wish (should match "license" above) + 'License :: OSI Approved :: MIT License', + + # Specify the Python versions you support here. In particular, ensure + # that you indicate whether you support Python 2, Python 3 or both. + 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.3', + 'Programming Language :: Python :: 3.4', + 'Programming Language :: Python :: 3.5', + ], + + # What does your project relate to? + keywords='usos api usosapi', + + # You can just specify the packages manually here if your project is + # simple. Or you can use find_packages(). + # packages=find_packages(exclude=['contrib', 'docs', 'tests']), + + # Alternatively, if you want to distribute just a my_module.py, uncomment + # this: + py_modules=['usosapi'], + + # List run-time dependencies here. These will be installed by pip when + # your project is installed. For an analysis of "install_requires" vs pip's + # requirements files see: + # https://packaging.python.org/en/latest/requirements.html + install_requires=[ + 'rauth', + 'requests', + ], +) diff --git a/lib/usosapi-python/usosapi.py b/lib/usosapi-python/usosapi.py new file mode 100644 index 00000000..08a3adcd --- /dev/null +++ b/lib/usosapi-python/usosapi.py @@ -0,0 +1,318 @@ +# coding=utf-8 +""" +This package is part of the USOS API project. +https://apps.usos.edu.pl/developers/ +""" +import hashlib +import logging +import os +import os.path +import re +import shutil +import tempfile +import time +import urllib.request +import warnings + +import rauth +import requests.exceptions + +VERSION = '1.0.0' + +_REQUEST_TOKEN_SUFFIX = 'services/oauth/request_token' +_AUTHORIZE_SUFFIX = 'services/oauth/authorize' +_ACCESS_TOKEN_SUFFIX = 'services/oauth/access_token' + +SCOPES = 'offline_access' + +_LOGGER = logging.getLogger('USOSAPI') +_DOWNLOAD_LOGGER = logging.getLogger('USOSAPI.download') + + +class USOSAPIException(Exception): + pass + + +def download_file(url: str) -> str: + """ + This function is here for convenience. It's useful for downloading + for eg. user photos. It blocks until the file is saved on the disk + and then returns path of the file. + If given url was already downloaded before, it won't be downloaded + again (useful when you download user profile photos, most of them + are blanks). + """ + md5 = hashlib.md5() + file_name, extension = os.path.splitext(url) + md5.update(url.encode()) + file_name = md5.hexdigest() + extension + file_dir = os.path.join(tempfile.gettempdir(), 'USOSAPI') + + if not os.path.exists(file_dir): + os.mkdir(file_dir) + else: + if not os.path.isdir(file_dir): + shutil.rmtree(file_dir) + os.mkdir(file_dir) + + file_name = os.path.join(file_dir, file_name) + + if os.path.exists(file_name): + if os.path.isfile(file_name): + return file_name + else: + shutil.rmtree(file_name) + + with urllib.request.urlopen(url) as resp, open(file_name, 'wb') as out: + shutil.copyfileobj(resp, out) + + _DOWNLOAD_LOGGER.info('File from {} saved as {}.'.format(url, file_name)) + + return file_name + + +class USOSAPIConnection(): + """ + This class provides basic functionality required to work with + USOS API server. To start communication you need to provide server + address and your consumer key/secret pair to the constructor. + + After you create an USOSAPIConnection object with working parameters + (check them with test_connection function), you may already use a subset + of USOS API services that don't require a valid access key. + + To log in as a specific user you need to get an URL address with + get_authorization_url and somehow display it to the user (this module + doesn't provide any UI). On the web page, after accepting + scopes required by the module, user will receive a PIN code. + This code should be passed to authorize_with_pin function to + complete the authorization process. After successfully calling the + authorize_with_pin function, you will have an authorized_session. + """ + def __init__(self, api_base_address: str, consumer_key: str, + consumer_secret: str): + self.base_address = str(api_base_address) + if not self.base_address: + raise ValueError('Empty USOS API address.') + if not self.base_address.startswith('https'): + warnings.warn('Insecure protocol in USOS API address. ' + 'The address should start with https.') + if not self.base_address.endswith('/'): + self.base_address += '/' + + self.consumer_key = str(consumer_key) + self.consumer_secret = str(consumer_secret) + + req_token_url = self.base_address + _REQUEST_TOKEN_SUFFIX + authorize_url = self.base_address + _AUTHORIZE_SUFFIX + access_token_url = self.base_address + _ACCESS_TOKEN_SUFFIX + + self._service = rauth.OAuth1Service(consumer_key=consumer_key, + consumer_secret=consumer_secret, + name='USOSAPI', + request_token_url=req_token_url, + authorize_url=authorize_url, + access_token_url=access_token_url, + base_url=self.base_address) + + self._request_token_secret = '' + self._request_token = '' + + self._authorized_session = None + _LOGGER.info('New connection to {} created with key: {} ' + 'and secret: {}.'.format(api_base_address, + consumer_key, consumer_secret)) + + def _generate_request_token(self): + params = {'oauth_callback': 'oob', 'scopes': SCOPES} + token_tuple = self._service.get_request_token(params=params) + self._request_token, self._request_token_secret = token_tuple + _LOGGER.info('New request token generated: {}'.format(token_tuple[0])) + return + + def is_anonymous(self) -> bool: + """ + Checks if current USOS API session is anonymous. + This function assumes that USOS API server connection data + (server address, consumer key and consumer secret) are correct. + """ + return self._authorized_session is None + + def is_authorized(self) -> bool: + """ + Checks if current USOS API session is authorized (if you are logged in + as specific user). This function assumes that USOS API server + connection data (server address, consumer key and consumer secret) + are correct. + """ + if self._authorized_session is None: + return False + try: + identity = self.get('services/users/user') + return bool(identity['id']) + except USOSAPIException: + return False + + def test_connection(self) -> bool: + """ + Checks if parameters passed for this object's constructor are correct + and if it's possible to connect to the USOS API server. + """ + time_re = '^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{6}$' + try: + anonymous_session = self._service.get_session() + now = anonymous_session.get('services/apisrv/now') + now = now.json() + return bool(re.match(time_re, now)) + except Exception as e: + _LOGGER.debug('Connection test failed: {}'.format(e)) + return False + + def get_authorization_url(self) -> str: + """ + Returns an URL address that user has to visit using some + internet browser to obtain a PIN code required for authorization. + Every time you call this function, a new request token is generated, + so only PIN code acquired with the last generated address will allow + successful authorization. + """ + self._generate_request_token() + url = self._service.get_authorize_url(self._request_token) + _LOGGER.info('New authorization URL generated: {}'.format(url)) + return url + + def authorize_with_pin(self, pin: str): + """ + Call this function after user has obtained PIN code from website + which address was generated by the set_authorization_url function. + Remember that only PIN code from the last generated address will work. + + Will raise USOSAPIException if the PIN is incorrect. + """ + if not(self._request_token and self._request_token_secret): + raise USOSAPIException('Request token not initialized. ' + 'Use get_authorization_url to generate ' + 'the token.') + + rt = self._request_token + rts = self._request_token_secret + params = {'oauth_verifier': pin} + + _LOGGER.debug('Trying to authorize request token {} ' + 'with PIN code: {}'.format(self._request_token, pin)) + + try: + self._authorized_session = \ + self._service.get_auth_session(rt, rts, params=params) + except KeyError: + response = self._service.get_raw_access_token(rt, rts, + params=params) + text = response.json() + if isinstance(text, dict) and 'message' in text: + text = text['message'] + _LOGGER.info('Authorization failed, response message: ' + text) + raise USOSAPIException(text) + at = self.get_access_data()[0] + _LOGGER.info('Authorization successful, received access token: ' + at) + + def get_access_data(self) -> tuple: + """ + Returns a tuple of access token and access token secret. + You can save them somewhere and later use them to resume + an authorized session. + """ + if self.is_anonymous(): + raise USOSAPIException('Connection not yet authorized.') + at = self._authorized_session.access_token + ats = self._authorized_session.access_token_secret + return at, ats + + def set_access_data(self, access_token: str, + access_token_secret: str) -> bool: + """ + Using this function you can resume an authorized session. + Although this module requires offline_access scope from users + it is still possible, that the session won't be valid when it's + resumed. Check return value to make sure if provided access + pair was valid. + """ + self._authorized_session = self._service.get_session() + self._authorized_session.access_token = access_token + self._authorized_session.access_token_secret = access_token_secret + + if not self.is_authorized(): + self._authorized_session = None + _LOGGER.info('Access token {} is invalid.'.format(access_token)) + return False + + _LOGGER.info('New access token ({}) and secret ({}) ' + 'set.'.format(access_token, access_token_secret)) + return True + + def get(self, service: str, **kwargs): + """ + General use function to retrieve data from USOS API server. + Although it is called 'get' it will issue a POST request. + It's arguments are service name and an optional set of keyword + arguments, that will be passed as parameters of the request. + + Return type depends on the called service. It will usually be + a dictionary or a string. + """ + session = self._service.get_session() + if self._authorized_session is not None: + session = self._authorized_session + + start = time.time() + response = session.post(service, params=kwargs, data={}) + ex_time = time.time() - start + + if not response.ok: + try: + _LOGGER.info('{} ({}) FAILED: [{}] {}' + ''.format(service, repr(kwargs), + response.status_code, response.text)) + response.raise_for_status() + except requests.exceptions.HTTPError as e: + if response.status_code == 401: + raise USOSAPIException('HTTP 401: Unauthorized. Your ' + 'access key probably expired.') + if response.status_code == 400: + msg = response.text + raise USOSAPIException('HTTP 400: Bad request: ' + msg) + raise e + + _LOGGER.info('{} ({}) {:f}s'.format(service, repr(kwargs), + ex_time)) + _LOGGER.debug('{} ({}) -> {}'.format(response.url, repr(kwargs), + response.text)) + + return response.json() + + def logout(self): + """ + This function results in revoking currently used access key + and closing the authenticated session. + You can safely call this method multiple times. + """ + if self._authorized_session is None: + return + + at = self.get_access_data()[0] + self.get('services/oauth/revoke_token') + _LOGGER.debug('Access token {} revoked.'.format(at)) + self._authorized_session = None + + def current_identity(self): + """ + Returns a dictionary containing following keys: first_name, + last_name and id. + + If current session is anonymous it will raise USOSAPIException. + """ + try: + data = self.get('services/users/user') + return data + except USOSAPIException: + raise USOSAPIException('Trying to get identity of an unauthorized' + ' session.') diff --git a/local.env b/local.env new file mode 100644 index 00000000..3daf6a57 --- /dev/null +++ b/local.env @@ -0,0 +1,26 @@ +SECRET_KEY='django-insecure-h)q%%z-63-!_w*7qsme!7j#1n6_9_v6r+4e%k1u+va@dz4p%x#' +DEBUG=True +HOST_IP='149.156.65.241' +HOST_NAME='baca2.ii.uj.edu.pl' +STATIC_ROOT=False + +BACA2_DB_USER='baca2' +BACA2_DB_PASSWORD='zaqwsxcde' +BACA2_DB_ROOT_USER='root' +BACA2_DB_ROOT_PASSWORD='BaCa2root' + +# EMAIL settings +# EMAIL settings +EMAIL_USER='baca2.noresponse@hotmail.com' +EMAIL_PASSWORD='no-response-password' + +# Broker settings +# PASSWORDS HAVE TO DIFFERENT IN ORDER TO BE EFFECTIVE +BROKER_URL='http://127.0.0.1:8180/baca/' +BACA_PASSWORD='tmp-baca-password' +BROKER_PASSWORD='tmp-broker-password' + +# USOS settings +USOS_CONSUMER_KEY='no-key' +USOS_CONSUMER_SECRET='no-key' +USOS_GATEWAY='https://apps.usos.uj.edu.pl/' diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..f70049ae --- /dev/null +++ b/package-lock.json @@ -0,0 +1,18 @@ +{ + "name": "baca2", + "version": "1.0.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "bootstrap": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/bootstrap/-/bootstrap-5.3.1.tgz", + "integrity": "sha512-jzwza3Yagduci2x0rr9MeFSORjcHpt0lRZukZPZQJT1Dth5qzV7XcgGqYzi39KGAVYR8QEDVoO0ubFKOxzMG+g==" + }, + "bootstrap-icons": { + "version": "1.11.3", + "resolved": "https://registry.npmjs.org/bootstrap-icons/-/bootstrap-icons-1.11.3.tgz", + "integrity": "sha512-+3lpHrCw/it2/7lBL15VR0HEumaBss0+f/Lb6ZvHISn1mlK83jjFpooTLsMWbIjJMDjDjOExMsTxnXSIT4k4ww==" + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 00000000..4620e4ed --- /dev/null +++ b/package.json @@ -0,0 +1,15 @@ +{ + "name": "baca2", + "version": "1.0.0", + "description": "BaCa2 Project\r =============", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "author": "Krzysztof Kalita", + "license": "ISC", + "dependencies": { + "bootstrap": "^5.3.1", + "bootstrap-icons": "^1.11.2" + } +} diff --git a/poetry.lock b/poetry.lock index 85466068..ed80234c 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,27 +1,53 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.7.1 and should not be changed by hand. [[package]] name = "alabaster" -version = "0.7.13" -description = "A configurable sidebar-enabled Sphinx theme" -category = "main" +version = "0.7.16" +description = "A light, configurable Sphinx theme" +optional = false +python-versions = ">=3.9" +files = [ + {file = "alabaster-0.7.16-py3-none-any.whl", hash = "sha256:b46733c07dce03ae4e150330b975c75737fa60f0a7c591b6c8bf4928a28e2c92"}, + {file = "alabaster-0.7.16.tar.gz", hash = "sha256:75a8b99c28a5dad50dd7f8ccdd447a121ddb3892da9e53d1ca5cca3106d58d65"}, +] + +[[package]] +name = "apscheduler" +version = "3.10.4" +description = "In-process task scheduler with Cron-like capabilities" optional = false python-versions = ">=3.6" files = [ - {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"}, - {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"}, + {file = "APScheduler-3.10.4-py3-none-any.whl", hash = "sha256:fb91e8a768632a4756a585f79ec834e0e27aad5860bac7eaa523d9ccefd87661"}, + {file = "APScheduler-3.10.4.tar.gz", hash = "sha256:e6df071b27d9be898e486bc7940a7be50b4af2e9da7c08f0744a96d4bd4cef4a"}, ] +[package.dependencies] +pytz = "*" +six = ">=1.4.0" +tzlocal = ">=2.0,<3.dev0 || >=4.dev0" + +[package.extras] +doc = ["sphinx", "sphinx-rtd-theme"] +gevent = ["gevent"] +mongodb = ["pymongo (>=3.0)"] +redis = ["redis (>=3.0)"] +rethinkdb = ["rethinkdb (>=2.4.0)"] +sqlalchemy = ["sqlalchemy (>=1.4)"] +testing = ["pytest", "pytest-asyncio", "pytest-cov", "pytest-tornado5"] +tornado = ["tornado (>=4.3)"] +twisted = ["twisted"] +zookeeper = ["kazoo"] + [[package]] name = "asgiref" -version = "3.6.0" +version = "3.7.2" description = "ASGI specs, helper code, and adapters" -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "asgiref-3.6.0-py3-none-any.whl", hash = "sha256:71e68008da809b957b7ee4b43dbccff33d1b23519fb8344e33f049897077afac"}, - {file = "asgiref-3.6.0.tar.gz", hash = "sha256:9567dfe7bd8d3c8c892227827c41cce860b368104c3431da67a0c5a65a949506"}, + {file = "asgiref-3.7.2-py3-none-any.whl", hash = "sha256:89b2ef2247e3b562a16eef663bc0e2e703ec6468e2fa8a5cd61cd449786d4f6e"}, + {file = "asgiref-3.7.2.tar.gz", hash = "sha256:9e0ce3aa93a819ba5b45120216b23878cf6e8525eb3848653452b4192b92afed"}, ] [package.extras] @@ -29,211 +55,221 @@ tests = ["mypy (>=0.800)", "pytest", "pytest-asyncio"] [[package]] name = "babel" -version = "2.11.0" +version = "2.14.0" description = "Internationalization utilities" -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" +files = [ + {file = "Babel-2.14.0-py3-none-any.whl", hash = "sha256:efb1a25b7118e67ce3a259bed20545c29cb68be8ad2c784c83689981b7a57287"}, + {file = "Babel-2.14.0.tar.gz", hash = "sha256:6919867db036398ba21eb5c7a0f6b28ab8cbc3ae7a73a44ebe34ae74a4e7d363"}, +] + +[package.extras] +dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] + +[[package]] +name = "baca2-package-manager" +version = "0.3.4" +description = "A package manager for Baca2 project" +optional = false +python-versions = ">=3.11" files = [ - {file = "Babel-2.11.0-py3-none-any.whl", hash = "sha256:1ad3eca1c885218f6dce2ab67291178944f810a10a9b5f3cb8382a5a232b64fe"}, - {file = "Babel-2.11.0.tar.gz", hash = "sha256:5ef4b3226b0180dedded4229651c8b0e1a3a6a2837d45a073272f313e4cf97f6"}, + {file = "baca2-package-manager-0.3.4.tar.gz", hash = "sha256:425e323fc966c5f462a446e90172f0396a0276c36a4bd2120887830afafb1806"}, + {file = "baca2_package_manager-0.3.4-py3-none-any.whl", hash = "sha256:2fb82ddf9a8d8968a8cabf2d482ff756a1e52db016316806d059e6f3a0cd5cca"}, ] [package.dependencies] -pytz = ">=2015.7" +pyyaml = "*" [[package]] name = "certifi" -version = "2022.12.7" +version = "2024.2.2" description = "Python package for providing Mozilla's CA Bundle." -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "certifi-2022.12.7-py3-none-any.whl", hash = "sha256:4ad3232f5e926d6718ec31cfc1fcadfde020920e278684144551c91769c7bc18"}, - {file = "certifi-2022.12.7.tar.gz", hash = "sha256:35824b4c3a97115964b408844d64aa14db1cc518f6562e8d7261699d1350a9e3"}, + {file = "certifi-2024.2.2-py3-none-any.whl", hash = "sha256:dc383c07b76109f368f6106eee2b593b04a011ea4d55f652c6ca24a754d1cdd1"}, + {file = "certifi-2024.2.2.tar.gz", hash = "sha256:0569859f95fc761b18b45ef421b1290a0f65f147e92a1e5eb3e635f9a5e4e66f"}, ] [[package]] name = "cffi" -version = "1.15.1" +version = "1.16.0" description = "Foreign Function Interface for Python calling C code." -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "cffi-1.15.1-cp27-cp27m-macosx_10_9_x86_64.whl", hash = "sha256:a66d3508133af6e8548451b25058d5812812ec3798c886bf38ed24a98216fab2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_i686.whl", hash = "sha256:470c103ae716238bbe698d67ad020e1db9d9dba34fa5a899b5e21577e6d52ed2"}, - {file = "cffi-1.15.1-cp27-cp27m-manylinux1_x86_64.whl", hash = "sha256:9ad5db27f9cabae298d151c85cf2bad1d359a1b9c686a275df03385758e2f914"}, - {file = "cffi-1.15.1-cp27-cp27m-win32.whl", hash = "sha256:b3bbeb01c2b273cca1e1e0c5df57f12dce9a4dd331b4fa1635b8bec26350bde3"}, - {file = "cffi-1.15.1-cp27-cp27m-win_amd64.whl", hash = "sha256:e00b098126fd45523dd056d2efba6c5a63b71ffe9f2bbe1a4fe1716e1d0c331e"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_i686.whl", hash = "sha256:d61f4695e6c866a23a21acab0509af1cdfd2c013cf256bbf5b6b5e2695827162"}, - {file = "cffi-1.15.1-cp27-cp27mu-manylinux1_x86_64.whl", hash = "sha256:ed9cb427ba5504c1dc15ede7d516b84757c3e3d7868ccc85121d9310d27eed0b"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:39d39875251ca8f612b6f33e6b1195af86d1b3e60086068be9cc053aa4376e21"}, - {file = "cffi-1.15.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:285d29981935eb726a4399badae8f0ffdff4f5050eaa6d0cfc3f64b857b77185"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3eb6971dcff08619f8d91607cfc726518b6fa2a9eba42856be181c6d0d9515fd"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:21157295583fe8943475029ed5abdcf71eb3911894724e360acff1d61c1d54bc"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5635bd9cb9731e6d4a1132a498dd34f764034a8ce60cef4f5319c0541159392f"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2012c72d854c2d03e45d06ae57f40d78e5770d252f195b93f581acf3ba44496e"}, - {file = "cffi-1.15.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dd86c085fae2efd48ac91dd7ccffcfc0571387fe1193d33b6394db7ef31fe2a4"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:fa6693661a4c91757f4412306191b6dc88c1703f780c8234035eac011922bc01"}, - {file = "cffi-1.15.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:59c0b02d0a6c384d453fece7566d1c7e6b7bae4fc5874ef2ef46d56776d61c9e"}, - {file = "cffi-1.15.1-cp310-cp310-win32.whl", hash = "sha256:cba9d6b9a7d64d4bd46167096fc9d2f835e25d7e4c121fb2ddfc6528fb0413b2"}, - {file = "cffi-1.15.1-cp310-cp310-win_amd64.whl", hash = "sha256:ce4bcc037df4fc5e3d184794f27bdaab018943698f4ca31630bc7f84a7b69c6d"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3d08afd128ddaa624a48cf2b859afef385b720bb4b43df214f85616922e6a5ac"}, - {file = "cffi-1.15.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3799aecf2e17cf585d977b780ce79ff0dc9b78d799fc694221ce814c2c19db83"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a591fe9e525846e4d154205572a029f653ada1a78b93697f3b5a8f1f2bc055b9"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3548db281cd7d2561c9ad9984681c95f7b0e38881201e157833a2342c30d5e8c"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:91fc98adde3d7881af9b59ed0294046f3806221863722ba7d8d120c575314325"}, - {file = "cffi-1.15.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:94411f22c3985acaec6f83c6df553f2dbe17b698cc7f8ae751ff2237d96b9e3c"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:03425bdae262c76aad70202debd780501fabeaca237cdfddc008987c0e0f59ef"}, - {file = "cffi-1.15.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:cc4d65aeeaa04136a12677d3dd0b1c0c94dc43abac5860ab33cceb42b801c1e8"}, - {file = "cffi-1.15.1-cp311-cp311-win32.whl", hash = "sha256:a0f100c8912c114ff53e1202d0078b425bee3649ae34d7b070e9697f93c5d52d"}, - {file = "cffi-1.15.1-cp311-cp311-win_amd64.whl", hash = "sha256:04ed324bda3cda42b9b695d51bb7d54b680b9719cfab04227cdd1e04e5de3104"}, - {file = "cffi-1.15.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50a74364d85fd319352182ef59c5c790484a336f6db772c1a9231f1c3ed0cbd7"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e263d77ee3dd201c3a142934a086a4450861778baaeeb45db4591ef65550b0a6"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cec7d9412a9102bdc577382c3929b337320c4c4c4849f2c5cdd14d7368c5562d"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4289fc34b2f5316fbb762d75362931e351941fa95fa18789191b33fc4cf9504a"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:173379135477dc8cac4bc58f45db08ab45d228b3363adb7af79436135d028405"}, - {file = "cffi-1.15.1-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:6975a3fac6bc83c4a65c9f9fcab9e47019a11d3d2cf7f3c0d03431bf145a941e"}, - {file = "cffi-1.15.1-cp36-cp36m-win32.whl", hash = "sha256:2470043b93ff09bf8fb1d46d1cb756ce6132c54826661a32d4e4d132e1977adf"}, - {file = "cffi-1.15.1-cp36-cp36m-win_amd64.whl", hash = "sha256:30d78fbc8ebf9c92c9b7823ee18eb92f2e6ef79b45ac84db507f52fbe3ec4497"}, - {file = "cffi-1.15.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:198caafb44239b60e252492445da556afafc7d1e3ab7a1fb3f0584ef6d742375"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5ef34d190326c3b1f822a5b7a45f6c4535e2f47ed06fec77d3d799c450b2651e"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8102eaf27e1e448db915d08afa8b41d6c7ca7a04b7d73af6514df10a3e74bd82"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5df2768244d19ab7f60546d0c7c63ce1581f7af8b5de3eb3004b9b6fc8a9f84b"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8c4917bd7ad33e8eb21e9a5bbba979b49d9a97acb3a803092cbc1133e20343c"}, - {file = "cffi-1.15.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2642fe3142e4cc4af0799748233ad6da94c62a8bec3a6648bf8ee68b1c7426"}, - {file = "cffi-1.15.1-cp37-cp37m-win32.whl", hash = "sha256:e229a521186c75c8ad9490854fd8bbdd9a0c9aa3a524326b55be83b54d4e0ad9"}, - {file = "cffi-1.15.1-cp37-cp37m-win_amd64.whl", hash = "sha256:a0b71b1b8fbf2b96e41c4d990244165e2c9be83d54962a9a1d118fd8657d2045"}, - {file = "cffi-1.15.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:320dab6e7cb2eacdf0e658569d2575c4dad258c0fcc794f46215e1e39f90f2c3"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e74c6b51a9ed6589199c787bf5f9875612ca4a8a0785fb2d4a84429badaf22a"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a5c84c68147988265e60416b57fc83425a78058853509c1b0629c180094904a5"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b926aa83d1edb5aa5b427b4053dc420ec295a08e40911296b9eb1b6170f6cca"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:87c450779d0914f2861b8526e035c5e6da0a3199d8f1add1a665e1cbc6fc6d02"}, - {file = "cffi-1.15.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f2c9f67e9821cad2e5f480bc8d83b8742896f1242dba247911072d4fa94c192"}, - {file = "cffi-1.15.1-cp38-cp38-win32.whl", hash = "sha256:8b7ee99e510d7b66cdb6c593f21c043c248537a32e0bedf02e01e9553a172314"}, - {file = "cffi-1.15.1-cp38-cp38-win_amd64.whl", hash = "sha256:00a9ed42e88df81ffae7a8ab6d9356b371399b91dbdf0c3cb1e84c03a13aceb5"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:54a2db7b78338edd780e7ef7f9f6c442500fb0d41a5a4ea24fff1c929d5af585"}, - {file = "cffi-1.15.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fcd131dd944808b5bdb38e6f5b53013c5aa4f334c5cad0c72742f6eba4b73db0"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7473e861101c9e72452f9bf8acb984947aa1661a7704553a9f6e4baa5ba64415"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c9a799e985904922a4d207a94eae35c78ebae90e128f0c4e521ce339396be9d"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bcde07039e586f91b45c88f8583ea7cf7a0770df3a1649627bf598332cb6984"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:33ab79603146aace82c2427da5ca6e58f2b3f2fb5da893ceac0c42218a40be35"}, - {file = "cffi-1.15.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5d598b938678ebf3c67377cdd45e09d431369c3b1a5b331058c338e201f12b27"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:db0fbb9c62743ce59a9ff687eb5f4afbe77e5e8403d6697f7446e5f609976f76"}, - {file = "cffi-1.15.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:98d85c6a2bef81588d9227dde12db8a7f47f639f4a17c9ae08e773aa9c697bf3"}, - {file = "cffi-1.15.1-cp39-cp39-win32.whl", hash = "sha256:40f4774f5a9d4f5e344f31a32b5096977b5d48560c5592e2f3d2c4374bd543ee"}, - {file = "cffi-1.15.1-cp39-cp39-win_amd64.whl", hash = "sha256:70df4e3b545a17496c9b3f41f5115e69a4f2e77e94e1d2a8e1070bc0c38c8a3c"}, - {file = "cffi-1.15.1.tar.gz", hash = "sha256:d400bfb9a37b1351253cb402671cea7e89bdecc294e8016a707f6d1d8ac934f9"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6b3d6606d369fc1da4fd8c357d026317fbb9c9b75d36dc16e90e84c26854b088"}, + {file = "cffi-1.16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ac0f5edd2360eea2f1daa9e26a41db02dd4b0451b48f7c318e217ee092a213e9"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7e61e3e4fa664a8588aa25c883eab612a188c725755afff6289454d6362b9673"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a72e8961a86d19bdb45851d8f1f08b041ea37d2bd8d4fd19903bc3083d80c896"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b50bf3f55561dac5438f8e70bfcdfd74543fd60df5fa5f62d94e5867deca684"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7651c50c8c5ef7bdb41108b7b8c5a83013bfaa8a935590c5d74627c047a583c7"}, + {file = "cffi-1.16.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e4108df7fe9b707191e55f33efbcb2d81928e10cea45527879a4749cbe472614"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:32c68ef735dbe5857c810328cb2481e24722a59a2003018885514d4c09af9743"}, + {file = "cffi-1.16.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:673739cb539f8cdaa07d92d02efa93c9ccf87e345b9a0b556e3ecc666718468d"}, + {file = "cffi-1.16.0-cp310-cp310-win32.whl", hash = "sha256:9f90389693731ff1f659e55c7d1640e2ec43ff725cc61b04b2f9c6d8d017df6a"}, + {file = "cffi-1.16.0-cp310-cp310-win_amd64.whl", hash = "sha256:e6024675e67af929088fda399b2094574609396b1decb609c55fa58b028a32a1"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:b84834d0cf97e7d27dd5b7f3aca7b6e9263c56308ab9dc8aae9784abb774d404"}, + {file = "cffi-1.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1b8ebc27c014c59692bb2664c7d13ce7a6e9a629be20e54e7271fa696ff2b417"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ee07e47c12890ef248766a6e55bd38ebfb2bb8edd4142d56db91b21ea68b7627"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8a9d3ebe49f084ad71f9269834ceccbf398253c9fac910c4fd7053ff1386936"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e70f54f1796669ef691ca07d046cd81a29cb4deb1e5f942003f401c0c4a2695d"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5bf44d66cdf9e893637896c7faa22298baebcd18d1ddb6d2626a6e39793a1d56"}, + {file = "cffi-1.16.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b78010e7b97fef4bee1e896df8a4bbb6712b7f05b7ef630f9d1da00f6444d2e"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c6a164aa47843fb1b01e941d385aab7215563bb8816d80ff3a363a9f8448a8dc"}, + {file = "cffi-1.16.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e09f3ff613345df5e8c3667da1d918f9149bd623cd9070c983c013792a9a62eb"}, + {file = "cffi-1.16.0-cp311-cp311-win32.whl", hash = "sha256:2c56b361916f390cd758a57f2e16233eb4f64bcbeee88a4881ea90fca14dc6ab"}, + {file = "cffi-1.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:db8e577c19c0fda0beb7e0d4e09e0ba74b1e4c092e0e40bfa12fe05b6f6d75ba"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:fa3a0128b152627161ce47201262d3140edb5a5c3da88d73a1b790a959126956"}, + {file = "cffi-1.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:68e7c44931cc171c54ccb702482e9fc723192e88d25a0e133edd7aff8fcd1f6e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:abd808f9c129ba2beda4cfc53bde801e5bcf9d6e0f22f095e45327c038bfe68e"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:88e2b3c14bdb32e440be531ade29d3c50a1a59cd4e51b1dd8b0865c54ea5d2e2"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fcc8eb6d5902bb1cf6dc4f187ee3ea80a1eba0a89aba40a5cb20a5087d961357"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b7be2d771cdba2942e13215c4e340bfd76398e9227ad10402a8767ab1865d2e6"}, + {file = "cffi-1.16.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e715596e683d2ce000574bae5d07bd522c781a822866c20495e52520564f0969"}, + {file = "cffi-1.16.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:2d92b25dbf6cae33f65005baf472d2c245c050b1ce709cc4588cdcdd5495b520"}, + {file = "cffi-1.16.0-cp312-cp312-win32.whl", hash = "sha256:b2ca4e77f9f47c55c194982e10f058db063937845bb2b7a86c84a6cfe0aefa8b"}, + {file = "cffi-1.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:68678abf380b42ce21a5f2abde8efee05c114c2fdb2e9eef2efdb0257fba1235"}, + {file = "cffi-1.16.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0c9ef6ff37e974b73c25eecc13952c55bceed9112be2d9d938ded8e856138bcc"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a09582f178759ee8128d9270cd1344154fd473bb77d94ce0aeb2a93ebf0feaf0"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e760191dd42581e023a68b758769e2da259b5d52e3103c6060ddc02c9edb8d7b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:80876338e19c951fdfed6198e70bc88f1c9758b94578d5a7c4c91a87af3cf31c"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a6a14b17d7e17fa0d207ac08642c8820f84f25ce17a442fd15e27ea18d67c59b"}, + {file = "cffi-1.16.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6602bc8dc6f3a9e02b6c22c4fc1e47aa50f8f8e6d3f78a5e16ac33ef5fefa324"}, + {file = "cffi-1.16.0-cp38-cp38-win32.whl", hash = "sha256:131fd094d1065b19540c3d72594260f118b231090295d8c34e19a7bbcf2e860a"}, + {file = "cffi-1.16.0-cp38-cp38-win_amd64.whl", hash = "sha256:31d13b0f99e0836b7ff893d37af07366ebc90b678b6664c955b54561fc36ef36"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:582215a0e9adbe0e379761260553ba11c58943e4bbe9c36430c4ca6ac74b15ed"}, + {file = "cffi-1.16.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:b29ebffcf550f9da55bec9e02ad430c992a87e5f512cd63388abb76f1036d8d2"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:dc9b18bf40cc75f66f40a7379f6a9513244fe33c0e8aa72e2d56b0196a7ef872"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cb4a35b3642fc5c005a6755a5d17c6c8b6bcb6981baf81cea8bfbc8903e8ba8"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b86851a328eedc692acf81fb05444bdf1891747c25af7529e39ddafaf68a4f3f"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c0f31130ebc2d37cdd8e44605fb5fa7ad59049298b3f745c74fa74c62fbfcfc4"}, + {file = "cffi-1.16.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8f8e709127c6c77446a8c0a8c8bf3c8ee706a06cd44b1e827c3e6a2ee6b8c098"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:748dcd1e3d3d7cd5443ef03ce8685043294ad6bd7c02a38d1bd367cfd968e000"}, + {file = "cffi-1.16.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8895613bcc094d4a1b2dbe179d88d7fb4a15cee43c052e8885783fac397d91fe"}, + {file = "cffi-1.16.0-cp39-cp39-win32.whl", hash = "sha256:ed86a35631f7bfbb28e108dd96773b9d5a6ce4811cf6ea468bb6a359b256b1e4"}, + {file = "cffi-1.16.0-cp39-cp39-win_amd64.whl", hash = "sha256:3686dffb02459559c74dd3d81748269ffb0eb027c39a6fc99502de37d501faa8"}, + {file = "cffi-1.16.0.tar.gz", hash = "sha256:bcb3ef43e58665bbda2fb198698fcae6776483e0c4a631aa5647806c25e02cc0"}, ] [package.dependencies] pycparser = "*" +[[package]] +name = "cfgv" +version = "3.4.0" +description = "Validate configuration and produce human readable error messages." +optional = false +python-versions = ">=3.8" +files = [ + {file = "cfgv-3.4.0-py2.py3-none-any.whl", hash = "sha256:b7265b1f29fd3316bfcd2b330d63d024f2bfd8bcb8b0272f8e19a504856c48f9"}, + {file = "cfgv-3.4.0.tar.gz", hash = "sha256:e52591d4c5f5dead8e0f673fb16db7949d2cfb3f7da4582893288f0ded8fe560"}, +] + [[package]] name = "charset-normalizer" -version = "3.0.1" +version = "3.3.2" description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -category = "main" optional = false -python-versions = "*" +python-versions = ">=3.7.0" files = [ - {file = "charset-normalizer-3.0.1.tar.gz", hash = "sha256:ebea339af930f8ca5d7a699b921106c6e29c617fe9606fa7baa043c1cdae326f"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:88600c72ef7587fe1708fd242b385b6ed4b8904976d5da0893e31df8b3480cb6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c75ffc45f25324e68ab238cb4b5c0a38cd1c3d7f1fb1f72b5541de469e2247db"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:db72b07027db150f468fbada4d85b3b2729a3db39178abf5c543b784c1254539"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:62595ab75873d50d57323a91dd03e6966eb79c41fa834b7a1661ed043b2d404d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ff6f3db31555657f3163b15a6b7c6938d08df7adbfc9dd13d9d19edad678f1e8"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:772b87914ff1152b92a197ef4ea40efe27a378606c39446ded52c8f80f79702e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70990b9c51340e4044cfc394a81f614f3f90d41397104d226f21e66de668730d"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:292d5e8ba896bbfd6334b096e34bffb56161c81408d6d036a7dfa6929cff8783"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2edb64ee7bf1ed524a1da60cdcd2e1f6e2b4f66ef7c077680739f1641f62f555"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:31a9ddf4718d10ae04d9b18801bd776693487cbb57d74cc3458a7673f6f34639"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:44ba614de5361b3e5278e1241fda3dc1838deed864b50a10d7ce92983797fa76"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:12db3b2c533c23ab812c2b25934f60383361f8a376ae272665f8e48b88e8e1c6"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c512accbd6ff0270939b9ac214b84fb5ada5f0409c44298361b2f5e13f9aed9e"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win32.whl", hash = "sha256:502218f52498a36d6bf5ea77081844017bf7982cdbe521ad85e64cabee1b608b"}, - {file = "charset_normalizer-3.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:601f36512f9e28f029d9481bdaf8e89e5148ac5d89cffd3b05cd533eeb423b59"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0298eafff88c99982a4cf66ba2efa1128e4ddaca0b05eec4c456bbc7db691d8d"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a8d0fc946c784ff7f7c3742310cc8a57c5c6dc31631269876a88b809dbeff3d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:87701167f2a5c930b403e9756fab1d31d4d4da52856143b609e30a1ce7160f3c"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e76c0f23218b8f46c4d87018ca2e441535aed3632ca134b10239dfb6dadd6b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0c0a590235ccd933d9892c627dec5bc7511ce6ad6c1011fdf5b11363022746c1"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c7fe7afa480e3e82eed58e0ca89f751cd14d767638e2550c77a92a9e749c317"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79909e27e8e4fcc9db4addea88aa63f6423ebb171db091fb4373e3312cb6d603"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ac7b6a045b814cf0c47f3623d21ebd88b3e8cf216a14790b455ea7ff0135d18"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:72966d1b297c741541ca8cf1223ff262a6febe52481af742036a0b296e35fa5a"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:f9d0c5c045a3ca9bedfc35dca8526798eb91a07aa7a2c0fee134c6c6f321cbd7"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5995f0164fa7df59db4746112fec3f49c461dd6b31b841873443bdb077c13cfc"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4a8fcf28c05c1f6d7e177a9a46a1c52798bfe2ad80681d275b10dcf317deaf0b"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:761e8904c07ad053d285670f36dd94e1b6ab7f16ce62b9805c475b7aa1cffde6"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win32.whl", hash = "sha256:71140351489970dfe5e60fc621ada3e0f41104a5eddaca47a7acb3c1b851d6d3"}, - {file = "charset_normalizer-3.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:9ab77acb98eba3fd2a85cd160851816bfce6871d944d885febf012713f06659c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:84c3990934bae40ea69a82034912ffe5a62c60bbf6ec5bc9691419641d7d5c9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:74292fc76c905c0ef095fe11e188a32ebd03bc38f3f3e9bcb85e4e6db177b7ea"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c95a03c79bbe30eec3ec2b7f076074f4281526724c8685a42872974ef4d36b72"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f4c39b0e3eac288fedc2b43055cfc2ca7a60362d0e5e87a637beac5d801ef478"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:df2c707231459e8a4028eabcd3cfc827befd635b3ef72eada84ab13b52e1574d"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93ad6d87ac18e2a90b0fe89df7c65263b9a99a0eb98f0a3d2e079f12a0735837"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:59e5686dd847347e55dffcc191a96622f016bc0ad89105e24c14e0d6305acbc6"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:cd6056167405314a4dc3c173943f11249fa0f1b204f8b51ed4bde1a9cd1834dc"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:083c8d17153ecb403e5e1eb76a7ef4babfc2c48d58899c98fcaa04833e7a2f9a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_s390x.whl", hash = "sha256:f5057856d21e7586765171eac8b9fc3f7d44ef39425f85dbcccb13b3ebea806c"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:7eb33a30d75562222b64f569c642ff3dc6689e09adda43a082208397f016c39a"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win32.whl", hash = "sha256:95dea361dd73757c6f1c0a1480ac499952c16ac83f7f5f4f84f0658a01b8ef41"}, - {file = "charset_normalizer-3.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:eaa379fcd227ca235d04152ca6704c7cb55564116f8bc52545ff357628e10602"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:3e45867f1f2ab0711d60c6c71746ac53537f1684baa699f4f668d4c6f6ce8e14"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cadaeaba78750d58d3cc6ac4d1fd867da6fc73c88156b7a3212a3cd4819d679d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:911d8a40b2bef5b8bbae2e36a0b103f142ac53557ab421dc16ac4aafee6f53dc"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:503e65837c71b875ecdd733877d852adbc465bd82c768a067badd953bf1bc5a3"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a60332922359f920193b1d4826953c507a877b523b2395ad7bc716ddd386d866"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:16a8663d6e281208d78806dbe14ee9903715361cf81f6d4309944e4d1e59ac5b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:a16418ecf1329f71df119e8a65f3aa68004a3f9383821edcb20f0702934d8087"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:9d9153257a3f70d5f69edf2325357251ed20f772b12e593f3b3377b5f78e7ef8"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:02a51034802cbf38db3f89c66fb5d2ec57e6fe7ef2f4a44d070a593c3688667b"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:2e396d70bc4ef5325b72b593a72c8979999aa52fb8bcf03f701c1b03e1166918"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:11b53acf2411c3b09e6af37e4b9005cba376c872503c8f28218c7243582df45d"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win32.whl", hash = "sha256:0bf2dae5291758b6f84cf923bfaa285632816007db0330002fa1de38bfcb7154"}, - {file = "charset_normalizer-3.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:2c03cc56021a4bd59be889c2b9257dae13bf55041a3372d3295416f86b295fb5"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:024e606be3ed92216e2b6952ed859d86b4cfa52cd5bc5f050e7dc28f9b43ec42"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:4b0d02d7102dd0f997580b51edc4cebcf2ab6397a7edf89f1c73b586c614272c"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:358a7c4cb8ba9b46c453b1dd8d9e431452d5249072e4f56cfda3149f6ab1405e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81d6741ab457d14fdedc215516665050f3822d3e56508921cc7239f8c8e66a58"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8b8af03d2e37866d023ad0ddea594edefc31e827fee64f8de5611a1dbc373174"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cf4e8ad252f7c38dd1f676b46514f92dc0ebeb0db5552f5f403509705e24753"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e696f0dd336161fca9adbb846875d40752e6eba585843c768935ba5c9960722b"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c22d3fe05ce11d3671297dc8973267daa0f938b93ec716e12e0f6dee81591dc1"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:109487860ef6a328f3eec66f2bf78b0b72400280d8f8ea05f69c51644ba6521a"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:37f8febc8ec50c14f3ec9637505f28e58d4f66752207ea177c1d67df25da5aed"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:f97e83fa6c25693c7a35de154681fcc257c1c41b38beb0304b9c4d2d9e164479"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:a152f5f33d64a6be73f1d30c9cc82dfc73cec6477ec268e7c6e4c7d23c2d2291"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:39049da0ffb96c8cbb65cbf5c5f3ca3168990adf3551bd1dee10c48fce8ae820"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win32.whl", hash = "sha256:4457ea6774b5611f4bed5eaa5df55f70abde42364d498c5134b7ef4c6958e20e"}, - {file = "charset_normalizer-3.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:e62164b50f84e20601c1ff8eb55620d2ad25fb81b59e3cd776a1902527a788af"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8eade758719add78ec36dc13201483f8e9b5d940329285edcd5f70c0a9edbd7f"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:8499ca8f4502af841f68135133d8258f7b32a53a1d594aa98cc52013fff55678"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:3fc1c4a2ffd64890aebdb3f97e1278b0cc72579a08ca4de8cd2c04799a3a22be"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:00d3ffdaafe92a5dc603cb9bd5111aaa36dfa187c8285c543be562e61b755f6b"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c2ac1b08635a8cd4e0cbeaf6f5e922085908d48eb05d44c5ae9eabab148512ca"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f6f45710b4459401609ebebdbcfb34515da4fc2aa886f95107f556ac69a9147e"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ae1de54a77dc0d6d5fcf623290af4266412a7c4be0b1ff7444394f03f5c54e3"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3b590df687e3c5ee0deef9fc8c547d81986d9a1b56073d82de008744452d6541"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab5de034a886f616a5668aa5d098af2b5385ed70142090e2a31bcbd0af0fdb3d"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9cb3032517f1627cc012dbc80a8ec976ae76d93ea2b5feaa9d2a5b8882597579"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:608862a7bf6957f2333fc54ab4399e405baad0163dc9f8d99cb236816db169d4"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:0f438ae3532723fb6ead77e7c604be7c8374094ef4ee2c5e03a3a17f1fca256c"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:356541bf4381fa35856dafa6a965916e54bed415ad8a24ee6de6e37deccf2786"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win32.whl", hash = "sha256:39cf9ed17fe3b1bc81f33c9ceb6ce67683ee7526e65fde1447c772afc54a1bb8"}, - {file = "charset_normalizer-3.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:0a11e971ed097d24c534c037d298ad32c6ce81a45736d31e0ff0ad37ab437d59"}, - {file = "charset_normalizer-3.0.1-py3-none-any.whl", hash = "sha256:7e189e2e1d3ed2f4aebabd2d5b0f931e883676e51c7624826e0a4e5fe8a0bf24"}, + {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"}, + {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"}, + {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"}, + {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"}, + {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"}, + {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"}, + {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"}, + {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"}, ] [[package]] name = "colorama" version = "0.4.6" description = "Cross-platform colored terminal text." -category = "main" optional = false python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" files = [ @@ -241,77 +277,166 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "colorlog" +version = "6.8.2" +description = "Add colours to the output of Python's logging module." +optional = false +python-versions = ">=3.6" +files = [ + {file = "colorlog-6.8.2-py3-none-any.whl", hash = "sha256:4dcbb62368e2800cb3c5abd348da7e53f6c362dda502ec27c560b2e58a66bd33"}, + {file = "colorlog-6.8.2.tar.gz", hash = "sha256:3e3e079a41feb5a1b64f978b5ea4f46040a94f11f0e8bbb8261e3dbbeca64d44"}, +] + +[package.dependencies] +colorama = {version = "*", markers = "sys_platform == \"win32\""} + +[package.extras] +development = ["black", "flake8", "mypy", "pytest", "types-colorama"] + +[[package]] +name = "coverage" +version = "7.4.3" +description = "Code coverage measurement for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "coverage-7.4.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8580b827d4746d47294c0e0b92854c85a92c2227927433998f0d3320ae8a71b6"}, + {file = "coverage-7.4.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:718187eeb9849fc6cc23e0d9b092bc2348821c5e1a901c9f8975df0bc785bfd4"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:767b35c3a246bcb55b8044fd3a43b8cd553dd1f9f2c1eeb87a302b1f8daa0524"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae7f19afe0cce50039e2c782bff379c7e347cba335429678450b8fe81c4ef96d"}, + {file = "coverage-7.4.3-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba3a8aaed13770e970b3df46980cb068d1c24af1a1968b7818b69af8c4347efb"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:ee866acc0861caebb4f2ab79f0b94dbfbdbfadc19f82e6e9c93930f74e11d7a0"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:506edb1dd49e13a2d4cac6a5173317b82a23c9d6e8df63efb4f0380de0fbccbc"}, + {file = "coverage-7.4.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd6545d97c98a192c5ac995d21c894b581f1fd14cf389be90724d21808b657e2"}, + {file = "coverage-7.4.3-cp310-cp310-win32.whl", hash = "sha256:f6a09b360d67e589236a44f0c39218a8efba2593b6abdccc300a8862cffc2f94"}, + {file = "coverage-7.4.3-cp310-cp310-win_amd64.whl", hash = "sha256:18d90523ce7553dd0b7e23cbb28865db23cddfd683a38fb224115f7826de78d0"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cbbe5e739d45a52f3200a771c6d2c7acf89eb2524890a4a3aa1a7fa0695d2a47"}, + {file = "coverage-7.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:489763b2d037b164846ebac0cbd368b8a4ca56385c4090807ff9fad817de4113"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:451f433ad901b3bb00184d83fd83d135fb682d780b38af7944c9faeecb1e0bfe"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fcc66e222cf4c719fe7722a403888b1f5e1682d1679bd780e2b26c18bb648cdc"}, + {file = "coverage-7.4.3-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b3ec74cfef2d985e145baae90d9b1b32f85e1741b04cd967aaf9cfa84c1334f3"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:abbbd8093c5229c72d4c2926afaee0e6e3140de69d5dcd918b2921f2f0c8baba"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:35eb581efdacf7b7422af677b92170da4ef34500467381e805944a3201df2079"}, + {file = "coverage-7.4.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8249b1c7334be8f8c3abcaaa996e1e4927b0e5a23b65f5bf6cfe3180d8ca7840"}, + {file = "coverage-7.4.3-cp311-cp311-win32.whl", hash = "sha256:cf30900aa1ba595312ae41978b95e256e419d8a823af79ce670835409fc02ad3"}, + {file = "coverage-7.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:18c7320695c949de11a351742ee001849912fd57e62a706d83dfc1581897fa2e"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b51bfc348925e92a9bd9b2e48dad13431b57011fd1038f08316e6bf1df107d10"}, + {file = "coverage-7.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d6cdecaedea1ea9e033d8adf6a0ab11107b49571bbb9737175444cea6eb72328"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b2eccb883368f9e972e216c7b4c7c06cabda925b5f06dde0650281cb7666a30"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6c00cdc8fa4e50e1cc1f941a7f2e3e0f26cb2a1233c9696f26963ff58445bac7"}, + {file = "coverage-7.4.3-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9a4a8dd3dcf4cbd3165737358e4d7dfbd9d59902ad11e3b15eebb6393b0446e"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:062b0a75d9261e2f9c6d071753f7eef0fc9caf3a2c82d36d76667ba7b6470003"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:ebe7c9e67a2d15fa97b77ea6571ce5e1e1f6b0db71d1d5e96f8d2bf134303c1d"}, + {file = "coverage-7.4.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:c0a120238dd71c68484f02562f6d446d736adcc6ca0993712289b102705a9a3a"}, + {file = "coverage-7.4.3-cp312-cp312-win32.whl", hash = "sha256:37389611ba54fd6d278fde86eb2c013c8e50232e38f5c68235d09d0a3f8aa352"}, + {file = "coverage-7.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:d25b937a5d9ffa857d41be042b4238dd61db888533b53bc76dc082cb5a15e914"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:28ca2098939eabab044ad68850aac8f8db6bf0b29bc7f2887d05889b17346454"}, + {file = "coverage-7.4.3-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:280459f0a03cecbe8800786cdc23067a8fc64c0bd51dc614008d9c36e1659d7e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c0cdedd3500e0511eac1517bf560149764b7d8e65cb800d8bf1c63ebf39edd2"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9a9babb9466fe1da12417a4aed923e90124a534736de6201794a3aea9d98484e"}, + {file = "coverage-7.4.3-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dec9de46a33cf2dd87a5254af095a409ea3bf952d85ad339751e7de6d962cde6"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:16bae383a9cc5abab9bb05c10a3e5a52e0a788325dc9ba8499e821885928968c"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:2c854ce44e1ee31bda4e318af1dbcfc929026d12c5ed030095ad98197eeeaed0"}, + {file = "coverage-7.4.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:ce8c50520f57ec57aa21a63ea4f325c7b657386b3f02ccaedeccf9ebe27686e1"}, + {file = "coverage-7.4.3-cp38-cp38-win32.whl", hash = "sha256:708a3369dcf055c00ddeeaa2b20f0dd1ce664eeabde6623e516c5228b753654f"}, + {file = "coverage-7.4.3-cp38-cp38-win_amd64.whl", hash = "sha256:1bf25fbca0c8d121a3e92a2a0555c7e5bc981aee5c3fdaf4bb7809f410f696b9"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:3b253094dbe1b431d3a4ac2f053b6d7ede2664ac559705a704f621742e034f1f"}, + {file = "coverage-7.4.3-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:77fbfc5720cceac9c200054b9fab50cb2a7d79660609200ab83f5db96162d20c"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6679060424faa9c11808598504c3ab472de4531c571ab2befa32f4971835788e"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4af154d617c875b52651dd8dd17a31270c495082f3d55f6128e7629658d63765"}, + {file = "coverage-7.4.3-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8640f1fde5e1b8e3439fe482cdc2b0bb6c329f4bb161927c28d2e8879c6029ee"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:69b9f6f66c0af29642e73a520b6fed25ff9fd69a25975ebe6acb297234eda501"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:0842571634f39016a6c03e9d4aba502be652a6e4455fadb73cd3a3a49173e38f"}, + {file = "coverage-7.4.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a78ed23b08e8ab524551f52953a8a05d61c3a760781762aac49f8de6eede8c45"}, + {file = "coverage-7.4.3-cp39-cp39-win32.whl", hash = "sha256:c0524de3ff096e15fcbfe8f056fdb4ea0bf497d584454f344d59fce069d3e6e9"}, + {file = "coverage-7.4.3-cp39-cp39-win_amd64.whl", hash = "sha256:0209a6369ccce576b43bb227dc8322d8ef9e323d089c6f3f26a597b09cb4d2aa"}, + {file = "coverage-7.4.3-pp38.pp39.pp310-none-any.whl", hash = "sha256:7cbde573904625509a3f37b6fecea974e363460b556a627c60dc2f47e2fffa51"}, + {file = "coverage-7.4.3.tar.gz", hash = "sha256:276f6077a5c61447a48d133ed13e759c09e62aff0dc84274a68dc18660104d52"}, +] + +[package.extras] +toml = ["tomli"] + [[package]] name = "cryptography" -version = "39.0.0" +version = "42.0.5" description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_universal2.whl", hash = "sha256:c52a1a6f81e738d07f43dab57831c29e57d21c81a942f4602fac7ee21b27f288"}, - {file = "cryptography-39.0.0-cp36-abi3-macosx_10_12_x86_64.whl", hash = "sha256:80ee674c08aaef194bc4627b7f2956e5ba7ef29c3cc3ca488cf15854838a8f72"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.manylinux_2_24_aarch64.whl", hash = "sha256:887cbc1ea60786e534b00ba8b04d1095f4272d380ebd5f7a7eb4cc274710fad9"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6f97109336df5c178ee7c9c711b264c502b905c2d2a29ace99ed761533a3460f"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1a6915075c6d3a5e1215eab5d99bcec0da26036ff2102a1038401d6ef5bef25b"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:76c24dd4fd196a80f9f2f5405a778a8ca132f16b10af113474005635fe7e066c"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bae6c7f4a36a25291b619ad064a30a07110a805d08dc89984f4f441f6c1f3f96"}, - {file = "cryptography-39.0.0-cp36-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:875aea1039d78557c7c6b4db2fe0e9d2413439f4676310a5f269dd342ca7a717"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:f6c0db08d81ead9576c4d94bbb27aed8d7a430fa27890f39084c2d0e2ec6b0df"}, - {file = "cryptography-39.0.0-cp36-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:f3ed2d864a2fa1666e749fe52fb8e23d8e06b8012e8bd8147c73797c506e86f1"}, - {file = "cryptography-39.0.0-cp36-abi3-win32.whl", hash = "sha256:f671c1bb0d6088e94d61d80c606d65baacc0d374e67bf895148883461cd848de"}, - {file = "cryptography-39.0.0-cp36-abi3-win_amd64.whl", hash = "sha256:e324de6972b151f99dc078defe8fb1b0a82c6498e37bff335f5bc6b1e3ab5a1e"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-macosx_10_12_x86_64.whl", hash = "sha256:754978da4d0457e7ca176f58c57b1f9de6556591c19b25b8bcce3c77d314f5eb"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1ee1fd0de9851ff32dbbb9362a4d833b579b4a6cc96883e8e6d2ff2a6bc7104f"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:fec8b932f51ae245121c4671b4bbc030880f363354b2f0e0bd1366017d891458"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:407cec680e811b4fc829de966f88a7c62a596faa250fc1a4b520a0355b9bc190"}, - {file = "cryptography-39.0.0-pp38-pypy38_pp73-win_amd64.whl", hash = "sha256:7dacfdeee048814563eaaec7c4743c8aea529fe3dd53127313a792f0dadc1773"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ad04f413436b0781f20c52a661660f1e23bcd89a0e9bb1d6d20822d048cf2856"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:50386acb40fbabbceeb2986332f0287f50f29ccf1497bae31cf5c3e7b4f4b34f"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_24_x86_64.whl", hash = "sha256:e5d71c5d5bd5b5c3eebcf7c5c2bb332d62ec68921a8c593bea8c394911a005ce"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:844ad4d7c3850081dffba91cdd91950038ee4ac525c575509a42d3fc806b83c8"}, - {file = "cryptography-39.0.0-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:e0a05aee6a82d944f9b4edd6a001178787d1546ec7c6223ee9a848a7ade92e39"}, - {file = "cryptography-39.0.0.tar.gz", hash = "sha256:f964c7dcf7802d133e8dbd1565914fa0194f9d683d82411989889ecd701e8adf"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_universal2.whl", hash = "sha256:a30596bae9403a342c978fb47d9b0ee277699fa53bbafad14706af51fe543d16"}, + {file = "cryptography-42.0.5-cp37-abi3-macosx_10_12_x86_64.whl", hash = "sha256:b7ffe927ee6531c78f81aa17e684e2ff617daeba7f189f911065b2ea2d526dec"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2424ff4c4ac7f6b8177b53c17ed5d8fa74ae5955656867f5a8affaca36a27abb"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:329906dcc7b20ff3cad13c069a78124ed8247adcac44b10bea1130e36caae0b4"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:b03c2ae5d2f0fc05f9a2c0c997e1bc18c8229f392234e8a0194f202169ccd278"}, + {file = "cryptography-42.0.5-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:f8837fe1d6ac4a8052a9a8ddab256bc006242696f03368a4009be7ee3075cdb7"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:0270572b8bd2c833c3981724b8ee9747b3ec96f699a9665470018594301439ee"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:b8cac287fafc4ad485b8a9b67d0ee80c66bf3574f655d3b97ef2e1082360faf1"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:16a48c23a62a2f4a285699dba2e4ff2d1cff3115b9df052cdd976a18856d8e3d"}, + {file = "cryptography-42.0.5-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2bce03af1ce5a5567ab89bd90d11e7bbdff56b8af3acbbec1faded8f44cb06da"}, + {file = "cryptography-42.0.5-cp37-abi3-win32.whl", hash = "sha256:b6cd2203306b63e41acdf39aa93b86fb566049aeb6dc489b70e34bcd07adca74"}, + {file = "cryptography-42.0.5-cp37-abi3-win_amd64.whl", hash = "sha256:98d8dc6d012b82287f2c3d26ce1d2dd130ec200c8679b6213b3c73c08b2b7940"}, + {file = "cryptography-42.0.5-cp39-abi3-macosx_10_12_universal2.whl", hash = "sha256:5e6275c09d2badf57aea3afa80d975444f4be8d3bc58f7f80d2a484c6f9485c8"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e4985a790f921508f36f81831817cbc03b102d643b5fcb81cd33df3fa291a1a1"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7cde5f38e614f55e28d831754e8a3bacf9ace5d1566235e39d91b35502d6936e"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:7367d7b2eca6513681127ebad53b2582911d1736dc2ffc19f2c3ae49997496bc"}, + {file = "cryptography-42.0.5-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:cd2030f6650c089aeb304cf093f3244d34745ce0cfcc39f20c6fbfe030102e2a"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:a2913c5375154b6ef2e91c10b5720ea6e21007412f6437504ffea2109b5a33d7"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:c41fb5e6a5fe9ebcd58ca3abfeb51dffb5d83d6775405305bfa8715b76521922"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:3eaafe47ec0d0ffcc9349e1708be2aaea4c6dd4978d76bf6eb0cb2c13636c6fc"}, + {file = "cryptography-42.0.5-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1b95b98b0d2af784078fa69f637135e3c317091b615cd0905f8b8a087e86fa30"}, + {file = "cryptography-42.0.5-cp39-abi3-win32.whl", hash = "sha256:1f71c10d1e88467126f0efd484bd44bca5e14c664ec2ede64c32f20875c0d413"}, + {file = "cryptography-42.0.5-cp39-abi3-win_amd64.whl", hash = "sha256:a011a644f6d7d03736214d38832e030d8268bcff4a41f728e6030325fea3e400"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:9481ffe3cf013b71b2428b905c4f7a9a4f76ec03065b05ff499bb5682a8d9ad8"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:ba334e6e4b1d92442b75ddacc615c5476d4ad55cc29b15d590cc6b86efa487e2"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:ba3e4a42397c25b7ff88cdec6e2a16c2be18720f317506ee25210f6d31925f9c"}, + {file = "cryptography-42.0.5-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:111a0d8553afcf8eb02a4fea6ca4f59d48ddb34497aa8706a6cf536f1a5ec576"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:cd65d75953847815962c84a4654a84850b2bb4aed3f26fadcc1c13892e1e29f6"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:e807b3188f9eb0eaa7bbb579b462c5ace579f1cedb28107ce8b48a9f7ad3679e"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:f12764b8fffc7a123f641d7d049d382b73f96a34117e0b637b80643169cec8ac"}, + {file = "cryptography-42.0.5-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:37dd623507659e08be98eec89323469e8c7b4c1407c85112634ae3dbdb926fdd"}, + {file = "cryptography-42.0.5.tar.gz", hash = "sha256:6fe07eec95dfd477eb9530aef5bead34fec819b3aaf6c5bd6d20565da607bfe1"}, ] [package.dependencies] -cffi = ">=1.12" +cffi = {version = ">=1.12", markers = "platform_python_implementation != \"PyPy\""} [package.extras] -docs = ["sphinx (>=1.6.5,!=1.8.0,!=3.1.0,!=3.1.1,!=5.2.0,!=5.2.0.post0)", "sphinx-rtd-theme"] -docstest = ["pyenchant (>=1.6.11)", "sphinxcontrib-spelling (>=4.0.1)", "twine (>=1.12.0)"] -pep8test = ["black", "ruff"] -sdist = ["setuptools-rust (>=0.11.4)"] +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"] +docstest = ["pyenchant (>=1.6.11)", "readme-renderer", "sphinxcontrib-spelling (>=4.0.1)"] +nox = ["nox"] +pep8test = ["check-sdist", "click", "mypy", "ruff"] +sdist = ["build"] ssh = ["bcrypt (>=3.1.5)"] -test = ["hypothesis (>=1.11.4,!=3.79.2)", "iso8601", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pytz"] +test = ["certifi", "pretend", "pytest (>=6.2.0)", "pytest-benchmark", "pytest-cov", "pytest-xdist"] +test-randomorder = ["pytest-randomly"] [[package]] -name = "defusedxml" -version = "0.7.1" -description = "XML bomb protection for Python stdlib modules" -category = "main" +name = "distlib" +version = "0.3.8" +description = "Distribution utilities" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*" +python-versions = "*" files = [ - {file = "defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61"}, - {file = "defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69"}, + {file = "distlib-0.3.8-py2.py3-none-any.whl", hash = "sha256:034db59a0b96f8ca18035f36290806a9a6e6bd9d1ff91e45a7f172eb17e51784"}, + {file = "distlib-0.3.8.tar.gz", hash = "sha256:1530ea13e350031b6312d8580ddb6b27a104275a31106523b8f123787f494f64"}, ] [[package]] name = "django" -version = "4.1.5" +version = "5.0.2" description = "A high-level Python web framework that encourages rapid development and clean, pragmatic design." -category = "main" optional = false -python-versions = ">=3.8" +python-versions = ">=3.10" files = [ - {file = "Django-4.1.5-py3-none-any.whl", hash = "sha256:4b214a05fe4c99476e99e2445c8b978c8369c18d4dea8e22ec412862715ad763"}, - {file = "Django-4.1.5.tar.gz", hash = "sha256:ff56ebd7ead0fd5dbe06fe157b0024a7aaea2e0593bb3785fb594cf94dad58ef"}, + {file = "Django-5.0.2-py3-none-any.whl", hash = "sha256:56ab63a105e8bb06ee67381d7b65fe6774f057e41a8bab06c8020c8882d8ecd4"}, + {file = "Django-5.0.2.tar.gz", hash = "sha256:b5bb1d11b2518a5f91372a282f24662f58f66749666b0a286ab057029f728080"}, ] [package.dependencies] -asgiref = ">=3.5.2,<4" -sqlparse = ">=0.2.2" +asgiref = ">=3.7.0,<4" +sqlparse = ">=0.3.1" tzdata = {version = "*", markers = "sys_platform == \"win32\""} [package.extras] @@ -319,33 +444,29 @@ argon2 = ["argon2-cffi (>=19.1.0)"] bcrypt = ["bcrypt"] [[package]] -name = "django-allauth" -version = "0.51.0" -description = "Integrated set of Django applications addressing authentication, registration, account management as well as 3rd party (social) account authentication." -category = "main" +name = "django-cors-headers" +version = "4.3.1" +description = "django-cors-headers is a Django application for handling the server headers required for Cross-Origin Resource Sharing (CORS)." optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "django-allauth-0.51.0.tar.gz", hash = "sha256:ca1622733b6faa591580ccd3984042f12d8c79ade93438212de249b7ffb6f91f"}, + {file = "django-cors-headers-4.3.1.tar.gz", hash = "sha256:0bf65ef45e606aff1994d35503e6b677c0b26cafff6506f8fd7187f3be840207"}, + {file = "django_cors_headers-4.3.1-py3-none-any.whl", hash = "sha256:0b1fd19297e37417fc9f835d39e45c8c642938ddba1acce0c1753d3edef04f36"}, ] [package.dependencies] -Django = ">=2.0" -pyjwt = {version = ">=1.7", extras = ["crypto"]} -python3-openid = ">=3.0.8" -requests = "*" -requests-oauthlib = ">=0.3.0" +asgiref = ">=3.6" +Django = ">=3.2" [[package]] name = "django-dbbackup" -version = "4.0.2" +version = "4.1.0" description = "Management commands to help backup and restore a project database and media." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "django-dbbackup-4.0.2.tar.gz", hash = "sha256:1874d684abc22260972a67668a6db3331b24d7e1e8af89eaffdcd61eb27dbc2a"}, - {file = "django_dbbackup-4.0.2-py3-none-any.whl", hash = "sha256:3ccde831f1a8268fb031b37a8e7e2de3abb556623023af1e859cd7104c09ea2a"}, + {file = "django-dbbackup-4.1.0.tar.gz", hash = "sha256:c411d38d0f8e60ab3254017278c14ebd75d4001b5634fc73be7fbe8a5260583b"}, + {file = "django_dbbackup-4.1.0-py3-none-any.whl", hash = "sha256:c539b5246b429a22a8efadbab3719ee6b8eda45c66c4ff6592056c590d51c782"}, ] [package.dependencies] @@ -354,48 +475,116 @@ pytz = "*" [[package]] name = "django-extensions" -version = "3.2.1" +version = "3.2.3" description = "Extensions for Django" -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "django-extensions-3.2.1.tar.gz", hash = "sha256:2a4f4d757be2563cd1ff7cfdf2e57468f5f931cc88b23cf82ca75717aae504a4"}, - {file = "django_extensions-3.2.1-py3-none-any.whl", hash = "sha256:421464be390289513f86cb5e18eb43e5dc1de8b4c27ba9faa3b91261b0d67e09"}, + {file = "django-extensions-3.2.3.tar.gz", hash = "sha256:44d27919d04e23b3f40231c4ab7af4e61ce832ef46d610cc650d53e68328410a"}, + {file = "django_extensions-3.2.3-py3-none-any.whl", hash = "sha256:9600b7562f79a92cbf1fde6403c04fee314608fefbb595502e34383ae8203401"}, ] [package.dependencies] Django = ">=3.2" +[[package]] +name = "django-split-settings" +version = "1.2.0" +description = "Organize Django settings into multiple files and directories. Easily override and modify settings. Use wildcards and optional settings files." +optional = false +python-versions = ">=3.7,<4.0" +files = [ + {file = "django-split-settings-1.2.0.tar.gz", hash = "sha256:31415a618256b54c5cee8662cbaa72a890683b8b7465d64ba88fdd3affdd6c60"}, + {file = "django_split_settings-1.2.0-py3-none-any.whl", hash = "sha256:4b3be146776d49c61bd9dcf89fad40edb1544f13ab27a87a0b1aecf5a0d636f4"}, +] + +[[package]] +name = "django-widget-tweaks" +version = "1.5.0" +description = "Tweak the form field rendering in templates, not in python-level form definitions." +optional = false +python-versions = ">=3.8" +files = [ + {file = "django-widget-tweaks-1.5.0.tar.gz", hash = "sha256:1c2180681ebb994e922c754804c7ffebbe1245014777ac47897a81f57cc629c7"}, + {file = "django_widget_tweaks-1.5.0-py3-none-any.whl", hash = "sha256:a41b7b2f05bd44d673d11ebd6c09a96f1d013ee98121cb98c384fe84e33b881e"}, +] + [[package]] name = "docutils" -version = "0.19" +version = "0.20.1" description = "Docutils -- Python Documentation Utilities" -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "docutils-0.19-py3-none-any.whl", hash = "sha256:5e1de4d849fee02c63b040a4a3fd567f4ab104defd8a5511fbbc24a8a017efbc"}, - {file = "docutils-0.19.tar.gz", hash = "sha256:33995a6753c30b7f577febfc2c50411fec6aac7f7ffeb7c4cfe5991072dcf9e6"}, + {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"}, + {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"}, +] + +[[package]] +name = "filelock" +version = "3.13.1" +description = "A platform independent file lock." +optional = false +python-versions = ">=3.8" +files = [ + {file = "filelock-3.13.1-py3-none-any.whl", hash = "sha256:57dbda9b35157b05fb3e58ee91448612eb674172fab98ee235ccb0b5bee19a1c"}, + {file = "filelock-3.13.1.tar.gz", hash = "sha256:521f5f56c50f8426f5e03ad3b281b490a87ef15bc6c526f168290f0c7148d44e"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.24)"] +testing = ["covdefaults (>=2.3)", "coverage (>=7.3.2)", "diff-cover (>=8)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)", "pytest-timeout (>=2.2)"] +typing = ["typing-extensions (>=4.8)"] + +[[package]] +name = "gunicorn" +version = "21.2.0" +description = "WSGI HTTP Server for UNIX" +optional = false +python-versions = ">=3.5" +files = [ + {file = "gunicorn-21.2.0-py3-none-any.whl", hash = "sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0"}, + {file = "gunicorn-21.2.0.tar.gz", hash = "sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033"}, +] + +[package.dependencies] +packaging = "*" + +[package.extras] +eventlet = ["eventlet (>=0.24.1)"] +gevent = ["gevent (>=1.4.0)"] +setproctitle = ["setproctitle"] +tornado = ["tornado (>=0.2)"] + +[[package]] +name = "identify" +version = "2.5.35" +description = "File identification library for Python" +optional = false +python-versions = ">=3.8" +files = [ + {file = "identify-2.5.35-py2.py3-none-any.whl", hash = "sha256:c4de0081837b211594f8e877a6b4fad7ca32bbfc1a9307fdd61c28bfe923f13e"}, + {file = "identify-2.5.35.tar.gz", hash = "sha256:10a7ca245cfcd756a554a7288159f72ff105ad233c7c4b9c6f0f4d108f5f6791"}, ] +[package.extras] +license = ["ukkonen"] + [[package]] name = "idna" -version = "3.4" +version = "3.6" description = "Internationalized Domain Names in Applications (IDNA)" -category = "main" optional = false python-versions = ">=3.5" files = [ - {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"}, - {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"}, + {file = "idna-3.6-py3-none-any.whl", hash = "sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f"}, + {file = "idna-3.6.tar.gz", hash = "sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca"}, ] [[package]] name = "imagesize" version = "1.4.1" description = "Getting image size from png/jpeg/jpeg2000/gif file" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -404,208 +593,298 @@ files = [ ] [[package]] -name = "importlib-metadata" -version = "6.0.0" -description = "Read metadata from Python packages" -category = "main" +name = "jinja2" +version = "3.1.3" +description = "A very fast and expressive template engine." optional = false python-versions = ">=3.7" files = [ - {file = "importlib_metadata-6.0.0-py3-none-any.whl", hash = "sha256:7efb448ec9a5e313a57655d35aa54cd3e01b7e1fbcf72dce1bf06119420f5bad"}, - {file = "importlib_metadata-6.0.0.tar.gz", hash = "sha256:e354bedeb60efa6affdcc8ae121b73544a7aa74156d047311948f6d711cd378d"}, + {file = "Jinja2-3.1.3-py3-none-any.whl", hash = "sha256:7d6d50dd97d52cbc355597bd845fabfbac3f551e1f99619e39a35ce8c370b5fa"}, + {file = "Jinja2-3.1.3.tar.gz", hash = "sha256:ac8bd6544d4bb2c9792bf3a159e80bba8fda7f07e81bc3aed565432d5925ba90"}, ] [package.dependencies] -zipp = ">=0.5" +MarkupSafe = ">=2.0" [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -perf = ["ipython"] -testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"] +i18n = ["Babel (>=2.7)"] [[package]] -name = "jinja2" -version = "3.1.2" -description = "A very fast and expressive template engine." -category = "main" +name = "josepy" +version = "1.14.0" +description = "JOSE protocol implementation in Python" optional = false -python-versions = ">=3.7" +python-versions = ">=3.7,<4.0" files = [ - {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"}, - {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"}, + {file = "josepy-1.14.0-py3-none-any.whl", hash = "sha256:d2b36a30f316269f3242f4c2e45e15890784178af5ec54fa3e49cf9234ee22e0"}, + {file = "josepy-1.14.0.tar.gz", hash = "sha256:308b3bf9ce825ad4d4bba76372cf19b5dc1c2ce96a9d298f9642975e64bd13dd"}, ] [package.dependencies] -MarkupSafe = ">=2.0" +cryptography = ">=1.5" +pyopenssl = ">=0.13" [package.extras] -i18n = ["Babel (>=2.7)"] +docs = ["sphinx (>=4.3.0)", "sphinx-rtd-theme (>=1.0)"] + +[[package]] +name = "markdown" +version = "3.5.2" +description = "Python implementation of John Gruber's Markdown." +optional = false +python-versions = ">=3.8" +files = [ + {file = "Markdown-3.5.2-py3-none-any.whl", hash = "sha256:d43323865d89fc0cb9b20c75fc8ad313af307cc087e84b657d9eec768eddeadd"}, + {file = "Markdown-3.5.2.tar.gz", hash = "sha256:e1ac7b3dc550ee80e602e71c1d168002f062e49f1b11e26a36264dafd4df2ef8"}, +] + +[package.extras] +docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] +testing = ["coverage", "pyyaml"] [[package]] name = "markupsafe" -version = "2.1.1" +version = "2.1.5" description = "Safely add untrusted strings to HTML/XML markup." -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:86b1f75c4e7c2ac2ccdaec2b9022845dbb81880ca318bb7a0a01fbf7813e3812"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f121a1420d4e173a5d96e47e9a0c0dcff965afdf1626d28de1460815f7c4ee7a"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a49907dd8420c5685cfa064a1335b6754b74541bbb3706c259c02ed65b644b3e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:10c1bfff05d95783da83491be968e8fe789263689c02724e0c691933c52994f5"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b7bd98b796e2b6553da7225aeb61f447f80a1ca64f41d83612e6139ca5213aa4"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:b09bf97215625a311f669476f44b8b318b075847b49316d3e28c08e41a7a573f"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:694deca8d702d5db21ec83983ce0bb4b26a578e71fbdbd4fdcd387daa90e4d5e"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:efc1913fd2ca4f334418481c7e595c00aad186563bbc1ec76067848c7ca0a933"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win32.whl", hash = "sha256:4a33dea2b688b3190ee12bd7cfa29d39c9ed176bda40bfa11099a3ce5d3a7ac6"}, - {file = "MarkupSafe-2.1.1-cp310-cp310-win_amd64.whl", hash = "sha256:dda30ba7e87fbbb7eab1ec9f58678558fd9a6b8b853530e176eabd064da81417"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:671cd1187ed5e62818414afe79ed29da836dde67166a9fac6d435873c44fdd02"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3799351e2336dc91ea70b034983ee71cf2f9533cdff7c14c90ea126bfd95d65a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e72591e9ecd94d7feb70c1cbd7be7b3ebea3f548870aa91e2732960fa4d57a37"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6fbf47b5d3728c6aea2abb0589b5d30459e369baa772e0f37a0320185e87c980"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:d5ee4f386140395a2c818d149221149c54849dfcfcb9f1debfe07a8b8bd63f9a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:bcb3ed405ed3222f9904899563d6fc492ff75cce56cba05e32eff40e6acbeaa3"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:e1c0b87e09fa55a220f058d1d49d3fb8df88fbfab58558f1198e08c1e1de842a"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win32.whl", hash = "sha256:8dc1c72a69aa7e082593c4a203dcf94ddb74bb5c8a731e4e1eb68d031e8498ff"}, - {file = "MarkupSafe-2.1.1-cp37-cp37m-win_amd64.whl", hash = "sha256:97a68e6ada378df82bc9f16b800ab77cbf4b2fada0081794318520138c088e4a"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:e8c843bbcda3a2f1e3c2ab25913c80a3c5376cd00c6e8c4a86a89a28c8dc5452"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0212a68688482dc52b2d45013df70d169f542b7394fc744c02a57374a4207003"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8e576a51ad59e4bfaac456023a78f6b5e6e7651dcd383bcc3e18d06f9b55d6d1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4b9fe39a2ccc108a4accc2676e77da025ce383c108593d65cc909add5c3bd601"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:96e37a3dc86e80bf81758c152fe66dbf60ed5eca3d26305edf01892257049925"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:6d0072fea50feec76a4c418096652f2c3238eaa014b2f94aeb1d56a66b41403f"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:089cf3dbf0cd6c100f02945abeb18484bd1ee57a079aefd52cffd17fba910b88"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:6a074d34ee7a5ce3effbc526b7083ec9731bb3cbf921bbe1d3005d4d2bdb3a63"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win32.whl", hash = "sha256:421be9fbf0ffe9ffd7a378aafebbf6f4602d564d34be190fc19a193232fd12b1"}, - {file = "MarkupSafe-2.1.1-cp38-cp38-win_amd64.whl", hash = "sha256:fc7b548b17d238737688817ab67deebb30e8073c95749d55538ed473130ec0c7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e04e26803c9c3851c931eac40c695602c6295b8d432cbe78609649ad9bd2da8a"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:b87db4360013327109564f0e591bd2a3b318547bcef31b468a92ee504d07ae4f"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:99a2a507ed3ac881b975a2976d59f38c19386d128e7a9a18b7df6fff1fd4c1d6"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:56442863ed2b06d19c37f94d999035e15ee982988920e12a5b4ba29b62ad1f77"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3ce11ee3f23f79dbd06fb3d63e2f6af7b12db1d46932fe7bd8afa259a5996603"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:33b74d289bd2f5e527beadcaa3f401e0df0a89927c1559c8566c066fa4248ab7"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:43093fb83d8343aac0b1baa75516da6092f58f41200907ef92448ecab8825135"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:8e3dcf21f367459434c18e71b2a9532d96547aef8a871872a5bd69a715c15f96"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win32.whl", hash = "sha256:d4306c36ca495956b6d568d276ac11fdd9c30a36f1b6eb928070dc5360b22e1c"}, - {file = "MarkupSafe-2.1.1-cp39-cp39-win_amd64.whl", hash = "sha256:46d00d6cfecdde84d40e572d63735ef81423ad31184100411e6e3388d405e247"}, - {file = "MarkupSafe-2.1.1.tar.gz", hash = "sha256:7f91197cc9e48f989d12e4e6fbc46495c446636dfc81b9ccf50bb0ec74b91d4b"}, -] - -[[package]] -name = "oauthlib" -version = "3.2.2" -description = "A generic, spec-compliant, thorough implementation of the OAuth request-signing logic" -category = "main" + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a17a92de5231666cfbe003f0e4b9b3a7ae3afb1ec2845aadc2bacc93ff85febc"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:72b6be590cc35924b02c78ef34b467da4ba07e4e0f0454a2c5907f473fc50ce5"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e61659ba32cf2cf1481e575d0462554625196a1f2fc06a1c777d3f48e8865d46"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2174c595a0d73a3080ca3257b40096db99799265e1c27cc5a610743acd86d62f"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae2ad8ae6ebee9d2d94b17fb62763125f3f374c25618198f40cbb8b525411900"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:075202fa5b72c86ad32dc7d0b56024ebdbcf2048c0ba09f1cde31bfdd57bcfff"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:598e3276b64aff0e7b3451b72e94fa3c238d452e7ddcd893c3ab324717456bad"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fce659a462a1be54d2ffcacea5e3ba2d74daa74f30f5f143fe0c58636e355fdd"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win32.whl", hash = "sha256:d9fad5155d72433c921b782e58892377c44bd6252b5af2f67f16b194987338a4"}, + {file = "MarkupSafe-2.1.5-cp310-cp310-win_amd64.whl", hash = "sha256:bf50cd79a75d181c9181df03572cdce0fbb75cc353bc350712073108cba98de5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:629ddd2ca402ae6dbedfceeba9c46d5f7b2a61d9749597d4307f943ef198fc1f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5b7b716f97b52c5a14bffdf688f971b2d5ef4029127f1ad7a513973cfd818df2"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ec585f69cec0aa07d945b20805be741395e28ac1627333b1c5b0105962ffced"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b91c037585eba9095565a3556f611e3cbfaa42ca1e865f7b8015fe5c7336d5a5"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7502934a33b54030eaf1194c21c692a534196063db72176b0c4028e140f8f32c"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:0e397ac966fdf721b2c528cf028494e86172b4feba51d65f81ffd65c63798f3f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:c061bb86a71b42465156a3ee7bd58c8c2ceacdbeb95d05a99893e08b8467359a"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3a57fdd7ce31c7ff06cdfbf31dafa96cc533c21e443d57f5b1ecc6cdc668ec7f"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win32.whl", hash = "sha256:397081c1a0bfb5124355710fe79478cdbeb39626492b15d399526ae53422b906"}, + {file = "MarkupSafe-2.1.5-cp311-cp311-win_amd64.whl", hash = "sha256:2b7c57a4dfc4f16f7142221afe5ba4e093e09e728ca65c51f5620c9aaeb9a617"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:8dec4936e9c3100156f8a2dc89c4b88d5c435175ff03413b443469c7c8c5f4d1"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:3c6b973f22eb18a789b1460b4b91bf04ae3f0c4234a0a6aa6b0a92f6f7b951d4"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ac07bad82163452a6884fe8fa0963fb98c2346ba78d779ec06bd7a6262132aee"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f5dfb42c4604dddc8e4305050aa6deb084540643ed5804d7455b5df8fe16f5e5"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ea3d8a3d18833cf4304cd2fc9cbb1efe188ca9b5efef2bdac7adc20594a0e46b"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:d050b3361367a06d752db6ead6e7edeb0009be66bc3bae0ee9d97fb326badc2a"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:bec0a414d016ac1a18862a519e54b2fd0fc8bbfd6890376898a6c0891dd82e9f"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:58c98fee265677f63a4385256a6d7683ab1832f3ddd1e66fe948d5880c21a169"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win32.whl", hash = "sha256:8590b4ae07a35970728874632fed7bd57b26b0102df2d2b233b6d9d82f6c62ad"}, + {file = "MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:c8b29db45f8fe46ad280a7294f5c3ec36dbac9491f2d1c17345be8e69cc5928f"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec6a563cff360b50eed26f13adc43e61bc0c04d94b8be985e6fb24b81f6dcfdf"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a549b9c31bec33820e885335b451286e2969a2d9e24879f83fe904a5ce59d70a"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4f11aa001c540f62c6166c7726f71f7573b52c68c31f014c25cc7901deea0b52"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7b2e5a267c855eea6b4283940daa6e88a285f5f2a67f2220203786dfa59b37e9"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:2d2d793e36e230fd32babe143b04cec8a8b3eb8a3122d2aceb4a371e6b09b8df"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:ce409136744f6521e39fd8e2a24c53fa18ad67aa5bc7c2cf83645cce5b5c4e50"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win32.whl", hash = "sha256:4096e9de5c6fdf43fb4f04c26fb114f61ef0bf2e5604b6ee3019d51b69e8c371"}, + {file = "MarkupSafe-2.1.5-cp37-cp37m-win_amd64.whl", hash = "sha256:4275d846e41ecefa46e2015117a9f491e57a71ddd59bbead77e904dc02b1bed2"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:656f7526c69fac7f600bd1f400991cc282b417d17539a1b228617081106feb4a"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:97cafb1f3cbcd3fd2b6fbfb99ae11cdb14deea0736fc2b0952ee177f2b813a46"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f3fbcb7ef1f16e48246f704ab79d79da8a46891e2da03f8783a5b6fa41a9532"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fa9db3f79de01457b03d4f01b34cf91bc0048eb2c3846ff26f66687c2f6d16ab"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffee1f21e5ef0d712f9033568f8344d5da8cc2869dbd08d87c84656e6a2d2f68"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:5dedb4db619ba5a2787a94d877bc8ffc0566f92a01c0ef214865e54ecc9ee5e0"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:30b600cf0a7ac9234b2638fbc0fb6158ba5bdcdf46aeb631ead21248b9affbc4"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:8dd717634f5a044f860435c1d8c16a270ddf0ef8588d4887037c5028b859b0c3"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win32.whl", hash = "sha256:daa4ee5a243f0f20d528d939d06670a298dd39b1ad5f8a72a4275124a7819eff"}, + {file = "MarkupSafe-2.1.5-cp38-cp38-win_amd64.whl", hash = "sha256:619bc166c4f2de5caa5a633b8b7326fbe98e0ccbfacabd87268a2b15ff73a029"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:7a68b554d356a91cce1236aa7682dc01df0edba8d043fd1ce607c49dd3c1edcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:db0b55e0f3cc0be60c1f19efdde9a637c32740486004f20d1cff53c3c0ece4d2"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3e53af139f8579a6d5f7b76549125f0d94d7e630761a2111bc431fd820e163b8"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17b950fccb810b3293638215058e432159d2b71005c74371d784862b7e4683f3"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c31f53cdae6ecfa91a77820e8b151dba54ab528ba65dfd235c80b086d68a465"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:bff1b4290a66b490a2f4719358c0cdcd9bafb6b8f061e45c7a2460866bf50c2e"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:bc1667f8b83f48511b94671e0e441401371dfd0f0a795c7daa4a3cd1dde55bea"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:5049256f536511ee3f7e1b3f87d1d1209d327e818e6ae1365e8653d7e3abb6a6"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win32.whl", hash = "sha256:00e046b6dd71aa03a41079792f8473dc494d564611a8f89bbbd7cb93295ebdcf"}, + {file = "MarkupSafe-2.1.5-cp39-cp39-win_amd64.whl", hash = "sha256:fa173ec60341d6bb97a89f5ea19c85c5643c1e7dedebc22f5181eb73573142c5"}, + {file = "MarkupSafe-2.1.5.tar.gz", hash = "sha256:d283d37a890ba4c1ae73ffadf8046435c76e7bc2247bbb63c00bd1a709c6544b"}, +] + +[[package]] +name = "mozilla-django-oidc" +version = "4.0.0" +description = "A lightweight authentication and access management library for integration with OpenID Connect enabled authentication services." optional = false -python-versions = ">=3.6" +python-versions = "*" files = [ - {file = "oauthlib-3.2.2-py3-none-any.whl", hash = "sha256:8139f29aac13e25d502680e9e19963e83f16838d48a0d71c287fe40e7067fbca"}, - {file = "oauthlib-3.2.2.tar.gz", hash = "sha256:9859c40929662bec5d64f34d01c99e093149682a3f38915dc0655d5a633dd918"}, + {file = "mozilla-django-oidc-4.0.0.tar.gz", hash = "sha256:7eb9d05a033ac61a74ea3be33d3f822818bc8dcab81c471fef94c0d65c7cbe1c"}, + {file = "mozilla_django_oidc-4.0.0-py2.py3-none-any.whl", hash = "sha256:01a4ab19341503ec06fcbe6ea12329ed709d4226bf1d2c7541b0b1c4c4e8a738"}, ] -[package.extras] -rsa = ["cryptography (>=3.0.0)"] -signals = ["blinker (>=1.4.0)"] -signedtoken = ["cryptography (>=3.0.0)", "pyjwt (>=2.0.0,<3)"] +[package.dependencies] +cryptography = "*" +Django = ">=3.2" +josepy = "*" +requests = "*" + +[[package]] +name = "nodeenv" +version = "1.8.0" +description = "Node.js virtual environment builder" +optional = false +python-versions = ">=2.7,!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*" +files = [ + {file = "nodeenv-1.8.0-py2.py3-none-any.whl", hash = "sha256:df865724bb3c3adc86b3876fa209771517b0cfe596beff01a92700e0e8be4cec"}, + {file = "nodeenv-1.8.0.tar.gz", hash = "sha256:d51e0c37e64fbf47d017feac3145cdbb58836d7eee8c6f6d3b6880c5456227d2"}, +] + +[package.dependencies] +setuptools = "*" [[package]] name = "packaging" -version = "23.0" +version = "23.2" description = "Core utilities for Python packages" -category = "main" optional = false python-versions = ">=3.7" files = [ - {file = "packaging-23.0-py3-none-any.whl", hash = "sha256:714ac14496c3e68c99c29b00845f7a2b85f3bb6f1078fd9f72fd20f0570002b2"}, - {file = "packaging-23.0.tar.gz", hash = "sha256:b6ad297f8907de0fa2fe1ccbd26fdaf387f5f47c7275fedf8cce89f99446cf97"}, + {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"}, + {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"}, +] + +[[package]] +name = "parameterized" +version = "0.9.0" +description = "Parameterized testing with any Python test framework" +optional = false +python-versions = ">=3.7" +files = [ + {file = "parameterized-0.9.0-py2.py3-none-any.whl", hash = "sha256:4e0758e3d41bea3bbd05ec14fc2c24736723f243b28d702081aef438c9372b1b"}, + {file = "parameterized-0.9.0.tar.gz", hash = "sha256:7fc905272cefa4f364c1a3429cbbe9c0f98b793988efb5bf90aac80f08db09b1"}, ] +[package.extras] +dev = ["jinja2"] + +[[package]] +name = "platformdirs" +version = "4.2.0" +description = "A small Python package for determining appropriate platform-specific dirs, e.g. a \"user data dir\"." +optional = false +python-versions = ">=3.8" +files = [ + {file = "platformdirs-4.2.0-py3-none-any.whl", hash = "sha256:0614df2a2f37e1a662acbd8e2b25b92ccf8632929bc6d43467e17fe89c75e068"}, + {file = "platformdirs-4.2.0.tar.gz", hash = "sha256:ef0cc731df711022c174543cb70a9b5bd22e5a9337c8624ef2c2ceb8ddad8768"}, +] + +[package.extras] +docs = ["furo (>=2023.9.10)", "proselint (>=0.13)", "sphinx (>=7.2.6)", "sphinx-autodoc-typehints (>=1.25.2)"] +test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=7.4.3)", "pytest-cov (>=4.1)", "pytest-mock (>=3.12)"] + +[[package]] +name = "pre-commit" +version = "3.6.2" +description = "A framework for managing and maintaining multi-language pre-commit hooks." +optional = false +python-versions = ">=3.9" +files = [ + {file = "pre_commit-3.6.2-py2.py3-none-any.whl", hash = "sha256:ba637c2d7a670c10daedc059f5c49b5bd0aadbccfcd7ec15592cf9665117532c"}, + {file = "pre_commit-3.6.2.tar.gz", hash = "sha256:c3ef34f463045c88658c5b99f38c1e297abdcc0ff13f98d3370055fbbfabc67e"}, +] + +[package.dependencies] +cfgv = ">=2.0.0" +identify = ">=1.0.0" +nodeenv = ">=0.11.1" +pyyaml = ">=5.1" +virtualenv = ">=20.10.0" + [[package]] name = "psycopg2-binary" -version = "2.9.5" +version = "2.9.9" description = "psycopg2 - Python-PostgreSQL Database Adapter" -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "psycopg2-binary-2.9.5.tar.gz", hash = "sha256:33e632d0885b95a8b97165899006c40e9ecdc634a529dca7b991eb7de4ece41c"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:0775d6252ccb22b15da3b5d7adbbf8cfe284916b14b6dc0ff503a23edb01ee85"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2ec46ed947801652c9643e0b1dc334cfb2781232e375ba97312c2fc256597632"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3520d7af1ebc838cc6084a3281145d5cd5bdd43fdef139e6db5af01b92596cb7"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5cbc554ba47ecca8cd3396ddaca85e1ecfe3e48dd57dc5e415e59551affe568e"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_aarch64.whl", hash = "sha256:5d28ecdf191db558d0c07d0f16524ee9d67896edf2b7990eea800abeb23ebd61"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-manylinux_2_24_ppc64le.whl", hash = "sha256:b9c33d4aef08dfecbd1736ceab8b7b3c4358bf10a0121483e5cd60d3d308cc64"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:05b3d479425e047c848b9782cd7aac9c6727ce23181eb9647baf64ffdfc3da41"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:1e491e6489a6cb1d079df8eaa15957c277fdedb102b6a68cfbf40c4994412fd0"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:9e32cedc389bcb76d9f24ea8a012b3cb8385ee362ea437e1d012ffaed106c17d"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:46850a640df62ae940e34a163f72e26aca1f88e2da79148e1862faaac985c302"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win32.whl", hash = "sha256:3d790f84201c3698d1bfb404c917f36e40531577a6dda02e45ba29b64d539867"}, - {file = "psycopg2_binary-2.9.5-cp310-cp310-win_amd64.whl", hash = "sha256:1764546ffeaed4f9428707be61d68972eb5ede81239b46a45843e0071104d0dd"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_10_9_universal2.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:426c2ae999135d64e6a18849a7d1ad0e1bd007277e4a8f4752eaa40a96b550ff"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7cf1d44e710ca3a9ce952bda2855830fe9f9017ed6259e01fcd71ea6287565f5"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:024030b13bdcbd53d8a93891a2cf07719715724fc9fee40243f3bd78b4264b8f"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bcda1c84a1c533c528356da5490d464a139b6e84eb77cc0b432e38c5c6dd7882"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_aarch64.whl", hash = "sha256:2ef892cabdccefe577088a79580301f09f2a713eb239f4f9f62b2b29cafb0577"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-manylinux_2_24_ppc64le.whl", hash = "sha256:af0516e1711995cb08dc19bbd05bec7dbdebf4185f68870595156718d237df3e"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e72c91bda9880f097c8aa3601a2c0de6c708763ba8128006151f496ca9065935"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:e67b3c26e9b6d37b370c83aa790bbc121775c57bfb096c2e77eacca25fd0233b"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:5fc447058d083b8c6ac076fc26b446d44f0145308465d745fba93a28c14c9e32"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d892bfa1d023c3781a3cab8dd5af76b626c483484d782e8bd047c180db590e4c"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win32.whl", hash = "sha256:2abccab84d057723d2ca8f99ff7b619285d40da6814d50366f61f0fc385c3903"}, - {file = "psycopg2_binary-2.9.5-cp311-cp311-win_amd64.whl", hash = "sha256:bef7e3f9dc6f0c13afdd671008534be5744e0e682fb851584c8c3a025ec09720"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-macosx_10_14_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:6e63814ec71db9bdb42905c925639f319c80e7909fb76c3b84edc79dadef8d60"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:212757ffcecb3e1a5338d4e6761bf9c04f750e7d027117e74aa3cd8a75bb6fbd"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f8a9bcab7b6db2e3dbf65b214dfc795b4c6b3bb3af922901b6a67f7cb47d5f8"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_aarch64.whl", hash = "sha256:56b2957a145f816726b109ee3d4e6822c23f919a7d91af5a94593723ed667835"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-manylinux_2_24_ppc64le.whl", hash = "sha256:f95b8aca2703d6a30249f83f4fe6a9abf2e627aa892a5caaab2267d56be7ab69"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_aarch64.whl", hash = "sha256:70831e03bd53702c941da1a1ad36c17d825a24fbb26857b40913d58df82ec18b"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_i686.whl", hash = "sha256:dbc332beaf8492b5731229a881807cd7b91b50dbbbaf7fe2faf46942eda64a24"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_ppc64le.whl", hash = "sha256:2d964eb24c8b021623df1c93c626671420c6efadbdb8655cb2bd5e0c6fa422ba"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-musllinux_1_1_x86_64.whl", hash = "sha256:95076399ec3b27a8f7fa1cc9a83417b1c920d55cf7a97f718a94efbb96c7f503"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win32.whl", hash = "sha256:3fc33295cfccad697a97a76dec3f1e94ad848b7b163c3228c1636977966b51e2"}, - {file = "psycopg2_binary-2.9.5-cp36-cp36m-win_amd64.whl", hash = "sha256:02551647542f2bf89073d129c73c05a25c372fc0a49aa50e0de65c3c143d8bd0"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:63e318dbe52709ed10d516a356f22a635e07a2e34c68145484ed96a19b0c4c68"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a7e518a0911c50f60313cb9e74a169a65b5d293770db4770ebf004245f24b5c5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b9d38a4656e4e715d637abdf7296e98d6267df0cc0a8e9a016f8ba07e4aa3eeb"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_aarch64.whl", hash = "sha256:68d81a2fe184030aa0c5c11e518292e15d342a667184d91e30644c9d533e53e1"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-manylinux_2_24_ppc64le.whl", hash = "sha256:7ee3095d02d6f38bd7d9a5358fcc9ea78fcdb7176921528dd709cc63f40184f5"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:46512486be6fbceef51d7660dec017394ba3e170299d1dc30928cbedebbf103a"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:b911dfb727e247340d36ae20c4b9259e4a64013ab9888ccb3cbba69b77fd9636"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:422e3d43b47ac20141bc84b3d342eead8d8099a62881a501e97d15f6addabfe9"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:c5682a45df7d9642eff590abc73157c887a68f016df0a8ad722dcc0f888f56d7"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win32.whl", hash = "sha256:b8104f709590fff72af801e916817560dbe1698028cd0afe5a52d75ceb1fce5f"}, - {file = "psycopg2_binary-2.9.5-cp37-cp37m-win_amd64.whl", hash = "sha256:7b3751857da3e224f5629400736a7b11e940b5da5f95fa631d86219a1beaafec"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:043a9fd45a03858ff72364b4b75090679bd875ee44df9c0613dc862ca6b98460"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9ffdc51001136b699f9563b1c74cc1f8c07f66ef7219beb6417a4c8aaa896c28"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c15ba5982c177bc4b23a7940c7e4394197e2d6a424a2d282e7c236b66da6d896"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc85b3777068ed30aff8242be2813038a929f2084f69e43ef869daddae50f6ee"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_aarch64.whl", hash = "sha256:215d6bf7e66732a514f47614f828d8c0aaac9a648c46a831955cb103473c7147"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-manylinux_2_24_ppc64le.whl", hash = "sha256:7d07f552d1e412f4b4e64ce386d4c777a41da3b33f7098b6219012ba534fb2c2"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:a0adef094c49f242122bb145c3c8af442070dc0e4312db17e49058c1702606d4"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:00475004e5ed3e3bf5e056d66e5dcdf41a0dc62efcd57997acd9135c40a08a50"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:7d88db096fa19d94f433420eaaf9f3c45382da2dd014b93e4bf3215639047c16"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:902844f9c4fb19b17dfa84d9e2ca053d4a4ba265723d62ea5c9c26b38e0aa1e6"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win32.whl", hash = "sha256:4e7904d1920c0c89105c0517dc7e3f5c20fb4e56ba9cdef13048db76947f1d79"}, - {file = "psycopg2_binary-2.9.5-cp38-cp38-win_amd64.whl", hash = "sha256:a36a0e791805aa136e9cbd0ffa040d09adec8610453ee8a753f23481a0057af5"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_10_15_x86_64.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl", hash = "sha256:25382c7d174c679ce6927c16b6fbb68b10e56ee44b1acb40671e02d29f2fce7c"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9c38d3869238e9d3409239bc05bc27d6b7c99c2a460ea337d2814b35fb4fea1b"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:5c6527c8efa5226a9e787507652dd5ba97b62d29b53c371a85cd13f957fe4d42"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e59137cdb970249ae60be2a49774c6dfb015bd0403f05af1fe61862e9626642d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_aarch64.whl", hash = "sha256:d4c7b3a31502184e856df1f7bbb2c3735a05a8ce0ade34c5277e1577738a5c91"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-manylinux_2_24_ppc64le.whl", hash = "sha256:b9a794cef1d9c1772b94a72eec6da144c18e18041d294a9ab47669bc77a80c1d"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:c5254cbd4f4855e11cebf678c1a848a3042d455a22a4ce61349c36aafd4c2267"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:c5e65c6ac0ae4bf5bef1667029f81010b6017795dcb817ba5c7b8a8d61fab76f"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:74eddec4537ab1f701a1647214734bc52cee2794df748f6ae5908e00771f180a"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:01ad49d68dd8c5362e4bfb4158f2896dc6e0c02e87b8a3770fc003459f1a4425"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win32.whl", hash = "sha256:937880290775033a743f4836aa253087b85e62784b63fd099ee725d567a48aa1"}, - {file = "psycopg2_binary-2.9.5-cp39-cp39-win_amd64.whl", hash = "sha256:484405b883630f3e74ed32041a87456c5e0e63a8e3429aa93e8714c366d62bd1"}, + {file = "psycopg2-binary-2.9.9.tar.gz", hash = "sha256:7f01846810177d829c7692f1f5ada8096762d9172af1b1a28d4ab5b77c923c1c"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:c2470da5418b76232f02a2fcd2229537bb2d5a7096674ce61859c3229f2eb202"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c6af2a6d4b7ee9615cbb162b0738f6e1fd1f5c3eda7e5da17861eacf4c717ea7"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:75723c3c0fbbf34350b46a3199eb50638ab22a0228f93fb472ef4d9becc2382b"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:83791a65b51ad6ee6cf0845634859d69a038ea9b03d7b26e703f94c7e93dbcf9"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0ef4854e82c09e84cc63084a9e4ccd6d9b154f1dbdd283efb92ecd0b5e2b8c84"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ed1184ab8f113e8d660ce49a56390ca181f2981066acc27cf637d5c1e10ce46e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:d2997c458c690ec2bc6b0b7ecbafd02b029b7b4283078d3b32a852a7ce3ddd98"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:b58b4710c7f4161b5e9dcbe73bb7c62d65670a87df7bcce9e1faaad43e715245"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:0c009475ee389757e6e34611d75f6e4f05f0cf5ebb76c6037508318e1a1e0d7e"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:8dbf6d1bc73f1d04ec1734bae3b4fb0ee3cb2a493d35ede9badbeb901fb40f6f"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win32.whl", hash = "sha256:3f78fd71c4f43a13d342be74ebbc0666fe1f555b8837eb113cb7416856c79682"}, + {file = "psycopg2_binary-2.9.9-cp310-cp310-win_amd64.whl", hash = "sha256:876801744b0dee379e4e3c38b76fc89f88834bb15bf92ee07d94acd06ec890a0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ee825e70b1a209475622f7f7b776785bd68f34af6e7a46e2e42f27b659b5bc26"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1ea665f8ce695bcc37a90ee52de7a7980be5161375d42a0b6c6abedbf0d81f0f"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:143072318f793f53819048fdfe30c321890af0c3ec7cb1dfc9cc87aa88241de2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c332c8d69fb64979ebf76613c66b985414927a40f8defa16cf1bc028b7b0a7b0"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f7fc5a5acafb7d6ccca13bfa8c90f8c51f13d8fb87d95656d3950f0158d3ce53"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:977646e05232579d2e7b9c59e21dbe5261f403a88417f6a6512e70d3f8a046be"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:b6356793b84728d9d50ead16ab43c187673831e9d4019013f1402c41b1db9b27"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:bc7bb56d04601d443f24094e9e31ae6deec9ccb23581f75343feebaf30423359"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:77853062a2c45be16fd6b8d6de2a99278ee1d985a7bd8b103e97e41c034006d2"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:78151aa3ec21dccd5cdef6c74c3e73386dcdfaf19bced944169697d7ac7482fc"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win32.whl", hash = "sha256:dc4926288b2a3e9fd7b50dc6a1909a13bbdadfc67d93f3374d984e56f885579d"}, + {file = "psycopg2_binary-2.9.9-cp311-cp311-win_amd64.whl", hash = "sha256:b76bedd166805480ab069612119ea636f5ab8f8771e640ae103e05a4aae3e417"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:8532fd6e6e2dc57bcb3bc90b079c60de896d2128c5d9d6f24a63875a95a088cf"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0605eaed3eb239e87df0d5e3c6489daae3f7388d455d0c0b4df899519c6a38d"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f8544b092a29a6ddd72f3556a9fcf249ec412e10ad28be6a0c0d948924f2212"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2d423c8d8a3c82d08fe8af900ad5b613ce3632a1249fd6a223941d0735fce493"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e5afae772c00980525f6d6ecf7cbca55676296b580c0e6abb407f15f3706996"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e6f98446430fdf41bd36d4faa6cb409f5140c1c2cf58ce0bbdaf16af7d3f119"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c77e3d1862452565875eb31bdb45ac62502feabbd53429fdc39a1cc341d681ba"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:cb16c65dcb648d0a43a2521f2f0a2300f40639f6f8c1ecbc662141e4e3e1ee07"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:911dda9c487075abd54e644ccdf5e5c16773470a6a5d3826fda76699410066fb"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:57fede879f08d23c85140a360c6a77709113efd1c993923c59fde17aa27599fe"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win32.whl", hash = "sha256:64cf30263844fa208851ebb13b0732ce674d8ec6a0c86a4e160495d299ba3c93"}, + {file = "psycopg2_binary-2.9.9-cp312-cp312-win_amd64.whl", hash = "sha256:81ff62668af011f9a48787564ab7eded4e9fb17a4a6a74af5ffa6a457400d2ab"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:2293b001e319ab0d869d660a704942c9e2cce19745262a8aba2115ef41a0a42a"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:03ef7df18daf2c4c07e2695e8cfd5ee7f748a1d54d802330985a78d2a5a6dca9"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a602ea5aff39bb9fac6308e9c9d82b9a35c2bf288e184a816002c9fae930b77"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8359bf4791968c5a78c56103702000105501adb557f3cf772b2c207284273984"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:275ff571376626195ab95a746e6a04c7df8ea34638b99fc11160de91f2fef503"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:f9b5571d33660d5009a8b3c25dc1db560206e2d2f89d3df1cb32d72c0d117d52"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:420f9bbf47a02616e8554e825208cb947969451978dceb77f95ad09c37791dae"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:4154ad09dac630a0f13f37b583eae260c6aa885d67dfbccb5b02c33f31a6d420"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:a148c5d507bb9b4f2030a2025c545fccb0e1ef317393eaba42e7eabd28eb6041"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win32.whl", hash = "sha256:68fc1f1ba168724771e38bee37d940d2865cb0f562380a1fb1ffb428b75cb692"}, + {file = "psycopg2_binary-2.9.9-cp37-cp37m-win_amd64.whl", hash = "sha256:281309265596e388ef483250db3640e5f414168c5a67e9c665cafce9492eda2f"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:60989127da422b74a04345096c10d416c2b41bd7bf2a380eb541059e4e999980"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:246b123cc54bb5361588acc54218c8c9fb73068bf227a4a531d8ed56fa3ca7d6"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:34eccd14566f8fe14b2b95bb13b11572f7c7d5c36da61caf414d23b91fcc5d94"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d0ef97766055fec15b5de2c06dd8e7654705ce3e5e5eed3b6651a1d2a9a152"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3f82c171b4ccd83bbaf35aa05e44e690113bd4f3b7b6cc54d2219b132f3ae55"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ead20f7913a9c1e894aebe47cccf9dc834e1618b7aa96155d2091a626e59c972"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:ca49a8119c6cbd77375ae303b0cfd8c11f011abbbd64601167ecca18a87e7cdd"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:323ba25b92454adb36fa425dc5cf6f8f19f78948cbad2e7bc6cdf7b0d7982e59"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:1236ed0952fbd919c100bc839eaa4a39ebc397ed1c08a97fc45fee2a595aa1b3"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:729177eaf0aefca0994ce4cffe96ad3c75e377c7b6f4efa59ebf003b6d398716"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win32.whl", hash = "sha256:804d99b24ad523a1fe18cc707bf741670332f7c7412e9d49cb5eab67e886b9b5"}, + {file = "psycopg2_binary-2.9.9-cp38-cp38-win_amd64.whl", hash = "sha256:a6cdcc3ede532f4a4b96000b6362099591ab4a3e913d70bcbac2b56c872446f7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:72dffbd8b4194858d0941062a9766f8297e8868e1dd07a7b36212aaa90f49472"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:30dcc86377618a4c8f3b72418df92e77be4254d8f89f14b8e8f57d6d43603c0f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:31a34c508c003a4347d389a9e6fcc2307cc2150eb516462a7a17512130de109e"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:15208be1c50b99203fe88d15695f22a5bed95ab3f84354c494bcb1d08557df67"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1873aade94b74715be2246321c8650cabf5a0d098a95bab81145ffffa4c13876"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3a58c98a7e9c021f357348867f537017057c2ed7f77337fd914d0bedb35dace7"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:4686818798f9194d03c9129a4d9a702d9e113a89cb03bffe08c6cf799e053291"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:ebdc36bea43063116f0486869652cb2ed7032dbc59fbcb4445c4862b5c1ecf7f"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:ca08decd2697fdea0aea364b370b1249d47336aec935f87b8bbfd7da5b2ee9c1"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ac05fb791acf5e1a3e39402641827780fe44d27e72567a000412c648a85ba860"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win32.whl", hash = "sha256:9dba73be7305b399924709b91682299794887cbbd88e38226ed9f6712eabee90"}, + {file = "psycopg2_binary-2.9.9-cp39-cp39-win_amd64.whl", hash = "sha256:f7ae5d65ccfbebdfa761585228eb4d0df3a8b15cfb53bd953e713e09fbb12957"}, ] [[package]] name = "pycparser" version = "2.21" description = "C parser in Python" -category = "main" optional = false python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" files = [ @@ -615,167 +894,202 @@ files = [ [[package]] name = "pygments" -version = "2.14.0" +version = "2.17.2" description = "Pygments is a syntax highlighting package written in Python." -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.7" files = [ - {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"}, - {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"}, + {file = "pygments-2.17.2-py3-none-any.whl", hash = "sha256:b27c2826c47d0f3219f29554824c30c5e8945175d888647acd804ddd04af846c"}, + {file = "pygments-2.17.2.tar.gz", hash = "sha256:da46cec9fd2de5be3a8a784f434e4c4ab670b4ff54d605c4c2717e9d49c4c367"}, ] [package.extras] plugins = ["importlib-metadata"] +windows-terminal = ["colorama (>=0.4.6)"] [[package]] -name = "pyjwt" -version = "2.6.0" -description = "JSON Web Token implementation in Python" -category = "main" +name = "pyopenssl" +version = "24.0.0" +description = "Python wrapper module around the OpenSSL library" optional = false python-versions = ">=3.7" files = [ - {file = "PyJWT-2.6.0-py3-none-any.whl", hash = "sha256:d83c3d892a77bbb74d3e1a2cfa90afaadb60945205d1095d9221f04466f64c14"}, - {file = "PyJWT-2.6.0.tar.gz", hash = "sha256:69285c7e31fc44f68a1feb309e948e0df53259d579295e6cfe2b1792329f05fd"}, + {file = "pyOpenSSL-24.0.0-py3-none-any.whl", hash = "sha256:ba07553fb6fd6a7a2259adb9b84e12302a9a8a75c44046e8bb5d3e5ee887e3c3"}, + {file = "pyOpenSSL-24.0.0.tar.gz", hash = "sha256:6aa33039a93fffa4563e655b61d11364d01264be8ccb49906101e02a334530bf"}, ] [package.dependencies] -cryptography = {version = ">=3.4.0", optional = true, markers = "extra == \"crypto\""} +cryptography = ">=41.0.5,<43" [package.extras] -crypto = ["cryptography (>=3.4.0)"] -dev = ["coverage[toml] (==5.0.4)", "cryptography (>=3.4.0)", "pre-commit", "pytest (>=6.0.0,<7.0.0)", "sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -docs = ["sphinx (>=4.5.0,<5.0.0)", "sphinx-rtd-theme", "zope.interface"] -tests = ["coverage[toml] (==5.0.4)", "pytest (>=6.0.0,<7.0.0)"] +docs = ["sphinx (!=5.2.0,!=5.2.0.post0,!=7.2.5)", "sphinx-rtd-theme"] +test = ["flaky", "pretend", "pytest (>=3.0.1)"] [[package]] -name = "python3-openid" -version = "3.2.0" -description = "OpenID support for modern servers and consumers." -category = "main" +name = "python-dotenv" +version = "1.0.1" +description = "Read key-value pairs from a .env file and set them as environment variables" optional = false -python-versions = "*" +python-versions = ">=3.8" files = [ - {file = "python3-openid-3.2.0.tar.gz", hash = "sha256:33fbf6928f401e0b790151ed2b5290b02545e8775f982485205a066f874aaeaf"}, - {file = "python3_openid-3.2.0-py3-none-any.whl", hash = "sha256:6626f771e0417486701e0b4daff762e7212e820ca5b29fcc0d05f6f8736dfa6b"}, + {file = "python-dotenv-1.0.1.tar.gz", hash = "sha256:e324ee90a023d808f1959c46bcbc04446a10ced277783dc6ee09987c37ec10ca"}, + {file = "python_dotenv-1.0.1-py3-none-any.whl", hash = "sha256:f7b63ef50f1b690dddf550d03497b66d609393b40b564ed0d674909a68ebf16a"}, ] -[package.dependencies] -defusedxml = "*" - [package.extras] -mysql = ["mysql-connector-python"] -postgresql = ["psycopg2"] +cli = ["click (>=5.0)"] + +[[package]] +name = "python-markdown-math" +version = "0.8" +description = "Math extension for Python-Markdown" +optional = false +python-versions = ">=3.6" +files = [ + {file = "python-markdown-math-0.8.tar.gz", hash = "sha256:8564212af679fc18d53f38681f16080fcd3d186073f23825c7ce86fadd3e3635"}, + {file = "python_markdown_math-0.8-py3-none-any.whl", hash = "sha256:c685249d84b5b697e9114d7beb352bd8ca2e07fd268fd4057ffca888c14641e5"}, +] + +[package.dependencies] +Markdown = ">=3.0" [[package]] name = "pytz" -version = "2022.7" +version = "2024.1" description = "World timezone definitions, modern and historical" -category = "main" optional = false python-versions = "*" files = [ - {file = "pytz-2022.7-py2.py3-none-any.whl", hash = "sha256:93007def75ae22f7cd991c84e02d434876818661f8df9ad5df9e950ff4e52cfd"}, - {file = "pytz-2022.7.tar.gz", hash = "sha256:7ccfae7b4b2c067464a6733c6261673fdb8fd1be905460396b97a073e9fa683a"}, + {file = "pytz-2024.1-py2.py3-none-any.whl", hash = "sha256:328171f4e3623139da4983451950b28e95ac706e13f3f2630a879749e7a8b319"}, + {file = "pytz-2024.1.tar.gz", hash = "sha256:2a29735ea9c18baf14b448846bde5a48030ed267578472d8955cd0e7443a9812"}, ] [[package]] name = "pyyaml" -version = "6.0" +version = "6.0.1" description = "YAML parser and emitter for Python" -category = "main" optional = false python-versions = ">=3.6" files = [ - {file = "PyYAML-6.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d4db7c7aef085872ef65a8fd7d6d09a14ae91f691dec3e87ee5ee0539d516f53"}, - {file = "PyYAML-6.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9df7ed3b3d2e0ecfe09e14741b857df43adb5a3ddadc919a2d94fbdf78fea53c"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:77f396e6ef4c73fdc33a9157446466f1cff553d979bd00ecb64385760c6babdc"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a80a78046a72361de73f8f395f1f1e49f956c6be882eed58505a15f3e430962b"}, - {file = "PyYAML-6.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f84fbc98b019fef2ee9a1cb3ce93e3187a6df0b2538a651bfb890254ba9f90b5"}, - {file = "PyYAML-6.0-cp310-cp310-win32.whl", hash = "sha256:2cd5df3de48857ed0544b34e2d40e9fac445930039f3cfe4bcc592a1f836d513"}, - {file = "PyYAML-6.0-cp310-cp310-win_amd64.whl", hash = "sha256:daf496c58a8c52083df09b80c860005194014c3698698d1a57cbcfa182142a3a"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:d4b0ba9512519522b118090257be113b9468d804b19d63c71dbcf4a48fa32358"}, - {file = "PyYAML-6.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:81957921f441d50af23654aa6c5e5eaf9b06aba7f0a19c18a538dc7ef291c5a1"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afa17f5bc4d1b10afd4466fd3a44dc0e245382deca5b3c353d8b757f9e3ecb8d"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dbad0e9d368bb989f4515da330b88a057617d16b6a8245084f1b05400f24609f"}, - {file = "PyYAML-6.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432557aa2c09802be39460360ddffd48156e30721f5e8d917f01d31694216782"}, - {file = "PyYAML-6.0-cp311-cp311-win32.whl", hash = "sha256:bfaef573a63ba8923503d27530362590ff4f576c626d86a9fed95822a8255fd7"}, - {file = "PyYAML-6.0-cp311-cp311-win_amd64.whl", hash = "sha256:01b45c0191e6d66c470b6cf1b9531a771a83c1c4208272ead47a3ae4f2f603bf"}, - {file = "PyYAML-6.0-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:897b80890765f037df3403d22bab41627ca8811ae55e9a722fd0392850ec4d86"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:50602afada6d6cbfad699b0c7bb50d5ccffa7e46a3d738092afddc1f9758427f"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:48c346915c114f5fdb3ead70312bd042a953a8ce5c7106d5bfb1a5254e47da92"}, - {file = "PyYAML-6.0-cp36-cp36m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:98c4d36e99714e55cfbaaee6dd5badbc9a1ec339ebfc3b1f52e293aee6bb71a4"}, - {file = "PyYAML-6.0-cp36-cp36m-win32.whl", hash = "sha256:0283c35a6a9fbf047493e3a0ce8d79ef5030852c51e9d911a27badfde0605293"}, - {file = "PyYAML-6.0-cp36-cp36m-win_amd64.whl", hash = "sha256:07751360502caac1c067a8132d150cf3d61339af5691fe9e87803040dbc5db57"}, - {file = "PyYAML-6.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:819b3830a1543db06c4d4b865e70ded25be52a2e0631ccd2f6a47a2822f2fd7c"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:473f9edb243cb1935ab5a084eb238d842fb8f404ed2193a915d1784b5a6b5fc0"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0ce82d761c532fe4ec3f87fc45688bdd3a4c1dc5e0b4a19814b9009a29baefd4"}, - {file = "PyYAML-6.0-cp37-cp37m-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:231710d57adfd809ef5d34183b8ed1eeae3f76459c18fb4a0b373ad56bedcdd9"}, - {file = "PyYAML-6.0-cp37-cp37m-win32.whl", hash = "sha256:c5687b8d43cf58545ade1fe3e055f70eac7a5a1a0bf42824308d868289a95737"}, - {file = "PyYAML-6.0-cp37-cp37m-win_amd64.whl", hash = "sha256:d15a181d1ecd0d4270dc32edb46f7cb7733c7c508857278d3d378d14d606db2d"}, - {file = "PyYAML-6.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b4624f379dab24d3725ffde76559cff63d9ec94e1736b556dacdfebe5ab6d4b"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:213c60cd50106436cc818accf5baa1aba61c0189ff610f64f4a3e8c6726218ba"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9fa600030013c4de8165339db93d182b9431076eb98eb40ee068700c9c813e34"}, - {file = "PyYAML-6.0-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:277a0ef2981ca40581a47093e9e2d13b3f1fbbeffae064c1d21bfceba2030287"}, - {file = "PyYAML-6.0-cp38-cp38-win32.whl", hash = "sha256:d4eccecf9adf6fbcc6861a38015c2a64f38b9d94838ac1810a9023a0609e1b78"}, - {file = "PyYAML-6.0-cp38-cp38-win_amd64.whl", hash = "sha256:1e4747bc279b4f613a09eb64bba2ba602d8a6664c6ce6396a4d0cd413a50ce07"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:055d937d65826939cb044fc8c9b08889e8c743fdc6a32b33e2390f66013e449b"}, - {file = "PyYAML-6.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e61ceaab6f49fb8bdfaa0f92c4b57bcfbea54c09277b1b4f7ac376bfb7a7c174"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d67d839ede4ed1b28a4e8909735fc992a923cdb84e618544973d7dfc71540803"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cba8c411ef271aa037d7357a2bc8f9ee8b58b9965831d9e51baf703280dc73d3"}, - {file = "PyYAML-6.0-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:40527857252b61eacd1d9af500c3337ba8deb8fc298940291486c465c8b46ec0"}, - {file = "PyYAML-6.0-cp39-cp39-win32.whl", hash = "sha256:b5b9eccad747aabaaffbc6064800670f0c297e52c12754eb1d976c57e4f74dcb"}, - {file = "PyYAML-6.0-cp39-cp39-win_amd64.whl", hash = "sha256:b3d267842bf12586ba6c734f89d1f5b871df0273157918b0ccefa29deb05c21c"}, - {file = "PyYAML-6.0.tar.gz", hash = "sha256:68fb519c14306fec9720a2a5b45bc9f0c8d1b9c72adf45c37baedfcd949c35a2"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:d858aa552c999bc8a8d57426ed01e40bef403cd8ccdd0fc5f6f04a00414cac2a"}, + {file = "PyYAML-6.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:fd66fc5d0da6d9815ba2cebeb4205f95818ff4b79c3ebe268e75d961704af52f"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, + {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, + {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, + {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, + {file = "PyYAML-6.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f003ed9ad21d6a4713f0a9b5a7a0a79e08dd0f221aff4525a2be4c346ee60aab"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, + {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, + {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, + {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a08c6f0fe150303c1c6b71ebcd7213c2858041a7e01975da3a99aed1e7a378ef"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, + {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, + {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:afd7e57eddb1a54f0f1a974bc4391af8bcce0b444685d936840f125cf046d5bd"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win32.whl", hash = "sha256:fca0e3a251908a499833aa292323f32437106001d436eca0e6e7833256674585"}, + {file = "PyYAML-6.0.1-cp36-cp36m-win_amd64.whl", hash = "sha256:f22ac1c3cac4dbc50079e965eba2c1058622631e526bd9afd45fedd49ba781fa"}, + {file = "PyYAML-6.0.1-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:b1275ad35a5d18c62a7220633c913e1b42d44b46ee12554e5fd39c70a243d6a3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:18aeb1bf9a78867dc38b259769503436b7c72f7a1f1f4c93ff9a17de54319b27"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:596106435fa6ad000c2991a98fa58eeb8656ef2325d7e158344fb33864ed87e3"}, + {file = "PyYAML-6.0.1-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:baa90d3f661d43131ca170712d903e6295d1f7a0f595074f151c0aed377c9b9c"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win32.whl", hash = "sha256:9046c58c4395dff28dd494285c82ba00b546adfc7ef001486fbf0324bc174fba"}, + {file = "PyYAML-6.0.1-cp37-cp37m-win_amd64.whl", hash = "sha256:4fb147e7a67ef577a588a0e2c17b6db51dda102c71de36f8549b6816a96e1867"}, + {file = "PyYAML-6.0.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:1d4c7e777c441b20e32f52bd377e0c409713e8bb1386e1099c2415f26e479595"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, + {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, + {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, + {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, + {file = "PyYAML-6.0.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:c8098ddcc2a85b61647b2590f825f3db38891662cfc2fc776415143f599bb859"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, + {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, + {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, + {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, + {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, +] + +[[package]] +name = "rauth" +version = "0.7.3" +description = "A Python library for OAuth 1.0/a, 2.0, and Ofly." +optional = false +python-versions = "*" +files = [ + {file = "rauth-0.7.3-py2-none-any.whl", hash = "sha256:b18590fbd77bc3d871936bbdb851377d1b0c08e337b219c303f8fc2b5a42ef2d"}, + {file = "rauth-0.7.3.tar.gz", hash = "sha256:524cdbc1c28560eacfc9a9d40c59525eb8d00fdf07fbad86107ea24411477b0a"}, ] +[package.dependencies] +requests = ">=1.2.3" + [[package]] name = "requests" -version = "2.28.2" +version = "2.31.0" description = "Python HTTP for Humans." -category = "main" optional = false -python-versions = ">=3.7, <4" +python-versions = ">=3.7" files = [ - {file = "requests-2.28.2-py3-none-any.whl", hash = "sha256:64299f4909223da747622c030b781c0d7811e359c37124b4bd368fb8c6518baa"}, - {file = "requests-2.28.2.tar.gz", hash = "sha256:98b1b2782e3c6c4904938b84c0eb932721069dfdb9134313beff7c83c2df24bf"}, + {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"}, + {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"}, ] [package.dependencies] certifi = ">=2017.4.17" charset-normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<1.27" +urllib3 = ">=1.21.1,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] [[package]] -name = "requests-oauthlib" -version = "1.3.1" -description = "OAuthlib authentication support for Requests." -category = "main" +name = "setuptools" +version = "69.1.1" +description = "Easily download, build, install, upgrade, and uninstall Python packages" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*" +python-versions = ">=3.8" files = [ - {file = "requests-oauthlib-1.3.1.tar.gz", hash = "sha256:75beac4a47881eeb94d5ea5d6ad31ef88856affe2332b9aafb52c6452ccf0d7a"}, - {file = "requests_oauthlib-1.3.1-py2.py3-none-any.whl", hash = "sha256:2577c501a2fb8d05a304c09d090d6e47c306fef15809d102b327cf8364bddab5"}, + {file = "setuptools-69.1.1-py3-none-any.whl", hash = "sha256:02fa291a0471b3a18b2b2481ed902af520c69e8ae0919c13da936542754b4c56"}, + {file = "setuptools-69.1.1.tar.gz", hash = "sha256:5c0806c7d9af348e6dd3777b4f4dbb42c7ad85b190104837488eab9a7c945cf8"}, ] -[package.dependencies] -oauthlib = ">=3.0.0" -requests = ">=2.0.0" - [package.extras] -rsa = ["oauthlib[signedtoken] (>=3.0.0)"] +docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (<7.2.5)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"] +testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pip (>=19.1)", "pytest (>=6)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-home (>=0.5)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff (>=0.2.1)", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"] +testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.2)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"] + +[[package]] +name = "six" +version = "1.16.0" +description = "Python 2 and 3 compatibility utilities" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*" +files = [ + {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"}, + {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"}, +] [[package]] name = "snowballstemmer" version = "2.2.0" description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms." -category = "main" optional = false python-versions = "*" files = [ @@ -785,26 +1099,24 @@ files = [ [[package]] name = "sphinx" -version = "6.1.3" +version = "7.2.6" description = "Python documentation generator" -category = "main" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "Sphinx-6.1.3.tar.gz", hash = "sha256:0dac3b698538ffef41716cf97ba26c1c7788dba73ce6f150c1ff5b4720786dd2"}, - {file = "sphinx-6.1.3-py3-none-any.whl", hash = "sha256:807d1cb3d6be87eb78a381c3e70ebd8d346b9a25f3753e9947e866b2786865fc"}, + {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"}, + {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"}, ] [package.dependencies] alabaster = ">=0.7,<0.8" babel = ">=2.9" colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""} -docutils = ">=0.18,<0.20" +docutils = ">=0.18.1,<0.21" imagesize = ">=1.3" -importlib-metadata = {version = ">=4.8", markers = "python_version < \"3.10\""} Jinja2 = ">=3.0" packaging = ">=21.0" -Pygments = ">=2.13" +Pygments = ">=2.14" requests = ">=2.25.0" snowballstemmer = ">=2.0" sphinxcontrib-applehelp = "*" @@ -812,66 +1124,98 @@ sphinxcontrib-devhelp = "*" sphinxcontrib-htmlhelp = ">=2.0.0" sphinxcontrib-jsmath = "*" sphinxcontrib-qthelp = "*" -sphinxcontrib-serializinghtml = ">=1.1.5" +sphinxcontrib-serializinghtml = ">=1.1.9" [package.extras] docs = ["sphinxcontrib-websupport"] lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"] -test = ["cython", "html5lib", "pytest (>=4.6)"] +test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"] + +[[package]] +name = "sphinx-rtd-theme" +version = "2.0.0" +description = "Read the Docs theme for Sphinx" +optional = false +python-versions = ">=3.6" +files = [ + {file = "sphinx_rtd_theme-2.0.0-py2.py3-none-any.whl", hash = "sha256:ec93d0856dc280cf3aee9a4c9807c60e027c7f7b461b77aeffed682e68f0e586"}, + {file = "sphinx_rtd_theme-2.0.0.tar.gz", hash = "sha256:bd5d7b80622406762073a04ef8fadc5f9151261563d47027de09910ce03afe6b"}, +] + +[package.dependencies] +docutils = "<0.21" +sphinx = ">=5,<8" +sphinxcontrib-jquery = ">=4,<5" + +[package.extras] +dev = ["bump2version", "sphinxcontrib-httpdomain", "transifex-client", "wheel"] [[package]] name = "sphinxcontrib-applehelp" -version = "1.0.3" +version = "1.0.8" description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books" -category = "main" optional = false -python-versions = ">=3.8" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib.applehelp-1.0.3-py3-none-any.whl", hash = "sha256:ba0f2a22e6eeada8da6428d0d520215ee8864253f32facf958cca81e426f661d"}, - {file = "sphinxcontrib.applehelp-1.0.3.tar.gz", hash = "sha256:83749f09f6ac843b8cb685277dbc818a8bf2d76cc19602699094fe9a74db529e"}, + {file = "sphinxcontrib_applehelp-1.0.8-py3-none-any.whl", hash = "sha256:cb61eb0ec1b61f349e5cc36b2028e9e7ca765be05e49641c97241274753067b4"}, + {file = "sphinxcontrib_applehelp-1.0.8.tar.gz", hash = "sha256:c40a4f96f3776c4393d933412053962fac2b84f4c99a7982ba42e09576a70619"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-devhelp" -version = "1.0.2" -description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp document." -category = "main" +version = "1.0.6" +description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents" optional = false -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-devhelp-1.0.2.tar.gz", hash = "sha256:ff7f1afa7b9642e7060379360a67e9c41e8f3121f2ce9164266f61b9f4b338e4"}, - {file = "sphinxcontrib_devhelp-1.0.2-py2.py3-none-any.whl", hash = "sha256:8165223f9a335cc1af7ffe1ed31d2871f325254c0423bc0c4c7cd1c1e4734a2e"}, + {file = "sphinxcontrib_devhelp-1.0.6-py3-none-any.whl", hash = "sha256:6485d09629944511c893fa11355bda18b742b83a2b181f9a009f7e500595c90f"}, + {file = "sphinxcontrib_devhelp-1.0.6.tar.gz", hash = "sha256:9893fd3f90506bc4b97bdb977ceb8fbd823989f4316b28c3841ec128544372d3"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-htmlhelp" -version = "2.0.0" +version = "2.0.5" description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files" -category = "main" optional = false -python-versions = ">=3.6" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-htmlhelp-2.0.0.tar.gz", hash = "sha256:f5f8bb2d0d629f398bf47d0d69c07bc13b65f75a81ad9e2f71a63d4b7a2f6db2"}, - {file = "sphinxcontrib_htmlhelp-2.0.0-py2.py3-none-any.whl", hash = "sha256:d412243dfb797ae3ec2b59eca0e52dac12e75a241bf0e4eb861e450d06c6ed07"}, + {file = "sphinxcontrib_htmlhelp-2.0.5-py3-none-any.whl", hash = "sha256:393f04f112b4d2f53d93448d4bce35842f62b307ccdc549ec1585e950bc35e04"}, + {file = "sphinxcontrib_htmlhelp-2.0.5.tar.gz", hash = "sha256:0dc87637d5de53dd5eec3a6a01753b1ccf99494bd756aafecd74b4fa9e729015"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["html5lib", "pytest"] +[[package]] +name = "sphinxcontrib-jquery" +version = "4.1" +description = "Extension to include jQuery on newer Sphinx releases" +optional = false +python-versions = ">=2.7" +files = [ + {file = "sphinxcontrib-jquery-4.1.tar.gz", hash = "sha256:1620739f04e36a2c779f1a131a2dfd49b2fd07351bf1968ced074365933abc7a"}, + {file = "sphinxcontrib_jquery-4.1-py2.py3-none-any.whl", hash = "sha256:f936030d7d0147dd026a4f2b5a57343d233f1fc7b363f68b3d4f1cb0993878ae"}, +] + +[package.dependencies] +Sphinx = ">=1.8" + [[package]] name = "sphinxcontrib-jsmath" version = "1.0.1" description = "A sphinx extension which renders display math in HTML via JavaScript" -category = "main" optional = false python-versions = ">=3.5" files = [ @@ -884,94 +1228,135 @@ test = ["flake8", "mypy", "pytest"] [[package]] name = "sphinxcontrib-qthelp" -version = "1.0.3" -description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp document." -category = "main" +version = "1.0.7" +description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents" optional = false -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-qthelp-1.0.3.tar.gz", hash = "sha256:4c33767ee058b70dba89a6fc5c1892c0d57a54be67ddd3e7875a18d14cba5a72"}, - {file = "sphinxcontrib_qthelp-1.0.3-py2.py3-none-any.whl", hash = "sha256:bd9fc24bcb748a8d51fd4ecaade681350aa63009a347a8c14e637895444dfab6"}, + {file = "sphinxcontrib_qthelp-1.0.7-py3-none-any.whl", hash = "sha256:e2ae3b5c492d58fcbd73281fbd27e34b8393ec34a073c792642cd8e529288182"}, + {file = "sphinxcontrib_qthelp-1.0.7.tar.gz", hash = "sha256:053dedc38823a80a7209a80860b16b722e9e0209e32fea98c90e4e6624588ed6"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sphinxcontrib-serializinghtml" -version = "1.1.5" -description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)." -category = "main" +version = "1.1.10" +description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)" optional = false -python-versions = ">=3.5" +python-versions = ">=3.9" files = [ - {file = "sphinxcontrib-serializinghtml-1.1.5.tar.gz", hash = "sha256:aa5f6de5dfdf809ef505c4895e51ef5c9eac17d0f287933eb49ec495280b6952"}, - {file = "sphinxcontrib_serializinghtml-1.1.5-py2.py3-none-any.whl", hash = "sha256:352a9a00ae864471d3a7ead8d7d79f5fc0b57e8b3f95e9867eb9eb28999b92fd"}, + {file = "sphinxcontrib_serializinghtml-1.1.10-py3-none-any.whl", hash = "sha256:326369b8df80a7d2d8d7f99aa5ac577f51ea51556ed974e7716cfd4fca3f6cb7"}, + {file = "sphinxcontrib_serializinghtml-1.1.10.tar.gz", hash = "sha256:93f3f5dc458b91b192fe10c397e324f262cf163d79f3282c158e8436a2c4511f"}, ] [package.extras] lint = ["docutils-stubs", "flake8", "mypy"] +standalone = ["Sphinx (>=5)"] test = ["pytest"] [[package]] name = "sqlparse" -version = "0.4.3" +version = "0.4.4" description = "A non-validating SQL parser." -category = "main" optional = false python-versions = ">=3.5" files = [ - {file = "sqlparse-0.4.3-py3-none-any.whl", hash = "sha256:0323c0ec29cd52bceabc1b4d9d579e311f3e4961b98d174201d5622a23b85e34"}, - {file = "sqlparse-0.4.3.tar.gz", hash = "sha256:69ca804846bb114d2ec380e4360a8a340db83f0ccf3afceeb1404df028f57268"}, + {file = "sqlparse-0.4.4-py3-none-any.whl", hash = "sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3"}, + {file = "sqlparse-0.4.4.tar.gz", hash = "sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c"}, ] +[package.extras] +dev = ["build", "flake8"] +doc = ["sphinx"] +test = ["pytest", "pytest-cov"] + [[package]] name = "tzdata" -version = "2022.7" +version = "2024.1" description = "Provider of IANA time zone data" -category = "main" optional = false python-versions = ">=2" files = [ - {file = "tzdata-2022.7-py2.py3-none-any.whl", hash = "sha256:2b88858b0e3120792a3c0635c23daf36a7d7eeeca657c323da299d2094402a0d"}, - {file = "tzdata-2022.7.tar.gz", hash = "sha256:fe5f866eddd8b96e9fcba978f8e503c909b19ea7efda11e52e39494bad3a7bfa"}, + {file = "tzdata-2024.1-py2.py3-none-any.whl", hash = "sha256:9068bc196136463f5245e51efda838afa15aaeca9903f49050dfa2679db4d252"}, + {file = "tzdata-2024.1.tar.gz", hash = "sha256:2674120f8d891909751c38abcdfd386ac0a5a1127954fbc332af6b5ceae07efd"}, ] +[[package]] +name = "tzlocal" +version = "5.2" +description = "tzinfo object for the local timezone" +optional = false +python-versions = ">=3.8" +files = [ + {file = "tzlocal-5.2-py3-none-any.whl", hash = "sha256:49816ef2fe65ea8ac19d19aa7a1ae0551c834303d5014c6d5a62e4cbda8047b8"}, + {file = "tzlocal-5.2.tar.gz", hash = "sha256:8d399205578f1a9342816409cc1e46a93ebd5755e39ea2d85334bea911bf0e6e"}, +] + +[package.dependencies] +tzdata = {version = "*", markers = "platform_system == \"Windows\""} + +[package.extras] +devenv = ["check-manifest", "pytest (>=4.3)", "pytest-cov", "pytest-mock (>=3.3)", "zest.releaser"] + [[package]] name = "urllib3" -version = "1.26.14" +version = "2.2.1" description = "HTTP library with thread-safe connection pooling, file post, and more." -category = "main" optional = false -python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, !=3.4.*, !=3.5.*" +python-versions = ">=3.8" files = [ - {file = "urllib3-1.26.14-py2.py3-none-any.whl", hash = "sha256:75edcdc2f7d85b137124a6c3c9fc3933cdeaa12ecb9a6a959f22797a0feca7e1"}, - {file = "urllib3-1.26.14.tar.gz", hash = "sha256:076907bf8fd355cde77728471316625a4d2f7e713c125f51953bb5b3eecf4f72"}, + {file = "urllib3-2.2.1-py3-none-any.whl", hash = "sha256:450b20ec296a467077128bff42b73080516e71b56ff59a60a02bef2232c4fa9d"}, + {file = "urllib3-2.2.1.tar.gz", hash = "sha256:d0570876c61ab9e520d776c38acbbb5b05a776d3f9ff98a5c8fd5162a444cf19"}, ] [package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)", "brotlipy (>=0.6.0)"] -secure = ["certifi", "cryptography (>=1.3.4)", "idna (>=2.0.0)", "ipaddress", "pyOpenSSL (>=0.14)", "urllib3-secure-extra"] -socks = ["PySocks (>=1.5.6,!=1.5.7,<2.0)"] +brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] +h2 = ["h2 (>=4,<5)"] +socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] +zstd = ["zstandard (>=0.18.0)"] + +[[package]] +name = "usosapi" +version = "1.0.0" +description = "Simple helper module for interacting with USOS API." +optional = false +python-versions = "*" +files = [] +develop = false + +[package.dependencies] +rauth = "*" +requests = "*" + +[package.source] +type = "directory" +url = "lib/usosapi-python" [[package]] -name = "zipp" -version = "3.11.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -category = "main" +name = "virtualenv" +version = "20.25.1" +description = "Virtual Python Environment builder" optional = false python-versions = ">=3.7" files = [ - {file = "zipp-3.11.0-py3-none-any.whl", hash = "sha256:83a28fcb75844b5c0cdaf5aa4003c2d728c77e05f5aeabe8e95e56727005fbaa"}, - {file = "zipp-3.11.0.tar.gz", hash = "sha256:a7a22e05929290a67401440b39690ae6563279bced5f314609d9d03798f56766"}, + {file = "virtualenv-20.25.1-py3-none-any.whl", hash = "sha256:961c026ac520bac5f69acb8ea063e8a4f071bcc9457b9c1f28f6b085c511583a"}, + {file = "virtualenv-20.25.1.tar.gz", hash = "sha256:e08e13ecdca7a0bd53798f356d5831434afa5b07b93f0abdf0797b7a06ffe197"}, ] +[package.dependencies] +distlib = ">=0.3.7,<1" +filelock = ">=3.12.2,<4" +platformdirs = ">=3.9.1,<5" + [package.extras] -docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] -testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +docs = ["furo (>=2023.7.26)", "proselint (>=0.13)", "sphinx (>=7.1.2)", "sphinx-argparse (>=0.4)", "sphinxcontrib-towncrier (>=0.2.1a0)", "towncrier (>=23.6)"] +test = ["covdefaults (>=2.3)", "coverage (>=7.2.7)", "coverage-enable-subprocess (>=1)", "flaky (>=3.7)", "packaging (>=23.1)", "pytest (>=7.4)", "pytest-env (>=0.8.2)", "pytest-freezer (>=0.4.8)", "pytest-mock (>=3.11.1)", "pytest-randomly (>=3.12)", "pytest-timeout (>=2.1)", "setuptools (>=68)", "time-machine (>=2.10)"] [metadata] lock-version = "2.0" -python-versions = "^3.9" -content-hash = "bf9aea63f82b38a71b8910a78939084935c8c9d06423ea240d222f8df1df5c75" +python-versions = "^3.11" +content-hash = "3815de84a421df75462b1fd8640cfd5b1724928b1a35bb7c3846f230300874a5" diff --git a/pyproject.toml b/pyproject.toml index e8189773..b056aea3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,20 +1,46 @@ [tool.poetry] name = "baca2" -version = "0.1.1" +version = "1.0.1-beta" description = "Bachelor project: automated programming task checking and scoring system BaCa2" authors = ["BaCa2 Team"] readme = "README.md" [tool.poetry.dependencies] -python = "^3.9" -django = "^4.1.3" +python = "^3.11" +django = "^5.0.0" psycopg2-binary = "^2.9.5" -django-allauth = "^0.51.0" django-extensions = "^3.2.1" django-dbbackup = "^4.0.2" pyyaml = "^6.0" -sphinx = "^6.1.3" +sphinx = "^7.2.6" +baca2-package-manager = "^0.3.2" +urllib3 = "^2.0.0" +django-widget-tweaks = "^1.4.12" +gunicorn = "^21.2.0" +python-dotenv = "^1.0.0" +django-cors-headers = "^4.3.1" +usosapi = { path = "lib/usosapi-python" } +apscheduler = "^3.10.4" +coverage = "^7.3.2" +sphinx-rtd-theme = "^2.0.0" +parameterized = "^0.9.0" +requests = "^2.31.0" +mozilla-django-oidc = "^4.0.0" +colorlog = "^6.8.2" +django-split-settings = "^1.2.0" +markdown = "^3.5.2" +python-markdown-math = "^0.8" +django-bootstrap-icons = "^0.8.7" + +[tool.poetry.group.dev.dependencies] +pre-commit = "^3.6.0" + +[tool.isort] +multi_line_output = 3 +line_length = 100 +known_django="django" +sections="FUTURE,STDLIB,DJANGO,FIRSTPARTY,THIRDPARTY,LOCALFOLDER" [build-system] requires = ["poetry-core"] diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 3b31ba59..00000000 --- a/requirements.txt +++ /dev/null @@ -1,11 +0,0 @@ -blinker==1.5 -greenlet==2.0.1 -inflect==6.0.2 -Mako==1.2.4 -MarkupSafe==2.1.1 -pydantic==1.10.4 -python-dateutil==2.8.2 -six==1.16.0 -typing_extensions==4.4.0 -wincertstore==0.2 -sphinx-rtd-theme==1.1.1