├── app ├── auth │ ├── __init__.py │ ├── schemas.py │ ├── models.py │ └── handlers.py ├── files │ ├── __init__.py │ └── handlers.py ├── nosql │ ├── __init__.py │ ├── models.py │ └── handlers.py ├── songs │ ├── __init__.py │ ├── tables.py │ ├── test_handlers.py │ ├── schemas.py │ ├── models.py │ └── handlers.py ├── tasks │ ├── __init__.py │ └── tasks.py ├── live_socket │ ├── __init__.py │ └── handlers.py ├── __init__.py ├── mongo.py ├── redis.py ├── db.py ├── celery.py ├── templates │ └── index.html ├── conftest.py ├── main.py ├── utils │ └── pagination.py └── static │ └── bootstrap.bundle.min.js ├── scripts ├── __init__.py ├── start.sh ├── prestart.sh └── backend_pre_start.py ├── alembic ├── README ├── script.py.mako ├── versions │ └── 180cc3782c77_init.py └── env.py ├── nginx ├── Dockerfile └── nginx.conf ├── .pre-commit-config.yaml ├── .dockerignore ├── .env ├── .gitignore ├── LICENSE ├── Dockerfile ├── docker-compose.yml ├── .github └── workflows │ └── dev.yml ├── README.md ├── pyproject.toml ├── CHANGELOG.md └── alembic.ini /app/auth/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/files/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/nosql/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/songs/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/tasks/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /app/live_socket/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /alembic/README: -------------------------------------------------------------------------------- 1 | Generic single-database configuration with an async dbapi. -------------------------------------------------------------------------------- /nginx/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM nginx:1.25.4-alpine 2 | 3 | RUN rm /etc/nginx/conf.d/default.conf 4 | COPY nginx.conf /etc/nginx/conf.d 5 | 6 | -------------------------------------------------------------------------------- /scripts/start.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | /files/scripts/prestart.sh 4 | /bin/sh -c "uvicorn app.main:app --reload --host 0.0.0.0 --port 8001" -------------------------------------------------------------------------------- /app/__init__.py: -------------------------------------------------------------------------------- 1 | """ 2 | FastAPI boilerPlate Package 3 | """ 4 | __author__ = "Payam Taheri" 5 | __homepage__ = "" 6 | __version__ = "0.0.3" 7 | __license__ = "MIT" 8 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.2.2 4 | hooks: 5 | - id: ruff 6 | args: [ --fix ] 7 | - id: ruff-format 8 | -------------------------------------------------------------------------------- /app/mongo.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import motor.motor_asyncio 4 | 5 | client = motor.motor_asyncio.AsyncIOMotorClient( 6 | os.environ.get("MONGODB_URL") or "mongodb://localhost:27017" 7 | ) 8 | db = client.college 9 | -------------------------------------------------------------------------------- /app/redis.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import redis 4 | 5 | host = os.environ.get("REDIS_HOST") or "127.0.0.1" 6 | port = int(os.environ.get("REDIS_PORT") or 6379) 7 | 8 | r = redis.Redis(host=host, port=port, decode_responses=True) 9 | -------------------------------------------------------------------------------- /scripts/prestart.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Let the DB start 4 | python /files/scripts/backend_pre_start.py 5 | 6 | # Run migrations 7 | alembic upgrade head 8 | 9 | # Create initial data in DB 10 | #python /files/scripts/insert_admin_user.py -------------------------------------------------------------------------------- /app/songs/tables.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | 3 | from ..db import Base 4 | 5 | 6 | class Song(Base): 7 | __tablename__ = "songs" 8 | 9 | id = Column(Integer, primary_key=True) 10 | name = Column(String) 11 | -------------------------------------------------------------------------------- /app/auth/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | 4 | class UserCreate(BaseModel): 5 | username: str 6 | email: str 7 | full_name: str 8 | password: str 9 | 10 | 11 | class UserRead(BaseModel): 12 | id: int 13 | username: str 14 | email: str 15 | full_name: str 16 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | .venv 2 | database.db 3 | __pycache__ 4 | *.pyc 5 | *.pyo 6 | *.pyd 7 | .Python 8 | env 9 | pip-log.txt 10 | pip-delete-this-directory.txt 11 | .tox 12 | .coverage 13 | .coverage.* 14 | .cache 15 | nosetests.xml 16 | coverage.xml 17 | *.cover 18 | *.log 19 | .git 20 | .mypy_cache 21 | .pytest_cache 22 | .hypothesis 23 | .idea -------------------------------------------------------------------------------- /.env: -------------------------------------------------------------------------------- 1 | POSTGRES_DB=foo 2 | POSTGRES_USER=payam 3 | POSTGRES_PASSWORD=payam 4 | DATABASE_URL=postgresql+asyncpg://payam:payam@pdb:5432/foo 5 | # Redis 6 | REDIS_HOST=redis 7 | REDIS_PORT=6379 8 | REDIS_URL=redis://redis:6379/0 9 | #MongoDB 10 | MONGODB_URL=mongodb://mongo:27017 11 | #Secrets 12 | SECRET_KEY="YourSecretKeyHere" 13 | ALGORITHM="HS256" 14 | ACCESS_TOKEN_EXPIRE_MINUTES=30 15 | -------------------------------------------------------------------------------- /nginx/nginx.conf: -------------------------------------------------------------------------------- 1 | upstream web { 2 | server api:8001; 3 | } 4 | 5 | server { 6 | 7 | listen 80; 8 | 9 | location / { 10 | proxy_pass http://web; 11 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 12 | proxy_set_header Host $host; 13 | proxy_redirect off; 14 | } 15 | 16 | location /static/ { 17 | autoindex on; 18 | alias /app/app/static/; 19 | } 20 | 21 | } 22 | 23 | -------------------------------------------------------------------------------- /app/auth/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, Integer, String 2 | from sqlalchemy.orm import Mapped 3 | 4 | from ..db import Base 5 | 6 | 7 | class User(Base): 8 | __tablename__ = "users" 9 | 10 | id: Mapped[int] = Column(Integer, primary_key=True) 11 | username: Mapped[str] = Column(String) 12 | email: Mapped[str] = Column(String) 13 | full_name: Mapped[str | None] = Column(String) 14 | password: Mapped[str] = Column(String) 15 | -------------------------------------------------------------------------------- /app/songs/test_handlers.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | 4 | @pytest.mark.asyncio 5 | async def test_add_song(client): 6 | response = await client.post( 7 | "/songs", 8 | json={"name": "Alen", "artist": "test", "year": 1960}, 9 | ) 10 | assert response.status_code == 200, response.text 11 | data = response.json() 12 | assert data["name"] == "Alen" 13 | assert data["artist"] == "test" 14 | assert data["year"] == 1960 15 | assert "id" in data 16 | -------------------------------------------------------------------------------- /app/db.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine 4 | from sqlalchemy.orm import declarative_base 5 | 6 | Base = declarative_base() 7 | DATABASE_URL = os.environ.get("DATABASE_URL") or "sqlite+aiosqlite:///database.db" 8 | 9 | engine = create_async_engine(DATABASE_URL) 10 | async_session = async_sessionmaker(engine, expire_on_commit=False) 11 | 12 | 13 | async def get_db_session(): 14 | db = async_session() 15 | try: 16 | yield db 17 | finally: 18 | await db.close() 19 | -------------------------------------------------------------------------------- /app/celery.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import timedelta 3 | 4 | from celery import Celery 5 | 6 | celery_app = Celery( 7 | __name__, 8 | broker=os.environ.get("REDIS_URL") or "redis://localhost:6379", 9 | backend=os.environ.get("REDIS_URL") or "redis://localhost:6379", 10 | include=["app.tasks.tasks"], 11 | ) 12 | celery_app.autodiscover_tasks() 13 | 14 | 15 | @celery_app.task(bind=True) 16 | def debug_task(self) -> None: 17 | print(f"Request: {self.request!r}") 18 | 19 | 20 | celery_app.conf.beat_schedule = { 21 | "scrap_task": { 22 | "task": "app.tasks.tasks.sample_task", 23 | "schedule": timedelta(seconds=5), 24 | }, 25 | } 26 | -------------------------------------------------------------------------------- /app/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Bootstrap demo 7 | 9 | 10 | 11 |

Hello, world ! user {{id}}

12 | 15 | 16 | -------------------------------------------------------------------------------- /app/files/handlers.py: -------------------------------------------------------------------------------- 1 | import shutil 2 | from pathlib import Path 3 | 4 | from fastapi import APIRouter, File, UploadFile 5 | 6 | router = APIRouter() 7 | 8 | 9 | @router.post("/upload-file") 10 | async def create_upload_file(file: UploadFile = File(...)): 11 | # Create the destination folder if it doesn't exist 12 | upload_folder = "./uploads" 13 | Path(upload_folder).mkdir(parents=True, exist_ok=True) 14 | 15 | # Save the file to the destination folder 16 | file_path = Path(upload_folder) / file.filename 17 | with file_path.open("wb") as buffer: 18 | shutil.copyfileobj(file.file, buffer) 19 | 20 | return {"filename": file.filename, "file_path": str(file_path)} 21 | -------------------------------------------------------------------------------- /app/tasks/tasks.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | 3 | from celery.utils.log import get_task_logger 4 | from sqlalchemy import update 5 | 6 | from ..celery import celery_app 7 | from ..db import async_session 8 | from ..songs.models import Song 9 | 10 | logger = get_task_logger(__name__) 11 | 12 | 13 | async def get_songs(): 14 | async with async_session() as session: 15 | await session.execute( 16 | update(Song).where(Song.id == 1).values(year=Song.year + 1) 17 | ) 18 | await session.commit() 19 | 20 | 21 | @celery_app.task 22 | def sample_task() -> None: 23 | logger.info("Doing some sample task 😄") 24 | loop = asyncio.get_event_loop() 25 | loop.run_until_complete(get_songs()) 26 | -------------------------------------------------------------------------------- /alembic/script.py.mako: -------------------------------------------------------------------------------- 1 | """${message} 2 | 3 | Revision ID: ${up_revision} 4 | Revises: ${down_revision | comma,n} 5 | Create Date: ${create_date} 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | ${imports if imports else ""} 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = ${repr(up_revision)} 16 | down_revision: Union[str, None] = ${repr(down_revision)} 17 | branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} 18 | depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} 19 | 20 | 21 | def upgrade() -> None: 22 | ${upgrades if upgrades else "pass"} 23 | 24 | 25 | def downgrade() -> None: 26 | ${downgrades if downgrades else "pass"} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.AppleDouble 2 | *.pyc 3 | *.swn 4 | *.swo 5 | *.swp 6 | *~ 7 | .DS_Store 8 | .idea 9 | .settings 10 | .tags 11 | .ctrlp 12 | .ycm_extra_conf.py 13 | webpack-stats.json 14 | npm-debug.log 15 | Thumbs.db 16 | __pycache__/ 17 | build/ 18 | db.sqlite3 19 | node_modules/ 20 | webpack-stats.json 21 | yarn-error.log 22 | 23 | /ethica_server/settings/** 24 | !/ethica_server/settings/__init__.py 25 | !/ethica_server/settings/base.py 26 | !/ethica_server/settings/example.py 27 | /.venv/ 28 | /.test/ 29 | /.out/ 30 | /.build/ 31 | /pyrobuf_generated.egg-info/ 32 | /dist/ 33 | /learn/ 34 | /activate 35 | logs* 36 | /media/EthicaLogger_NightlyBuild.apk 37 | /media/media/ 38 | /media/study 39 | /media/resp_files 40 | /media/ds_upload/ 41 | .log/ 42 | .profiler/ 43 | .vscode/ 44 | celerybeat-schedule.bak 45 | celerybeat-schedule.dat 46 | celerybeat-schedule.dir 47 | .database.db -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /app/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest_asyncio 2 | from httpx import AsyncClient 3 | from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine 4 | 5 | from .db import Base, get_db_session 6 | from .main import app 7 | 8 | 9 | @pytest_asyncio.fixture(name="session") 10 | async def session_fixture(): 11 | engine = create_async_engine( 12 | "sqlite+aiosqlite:///database2.db", connect_args={"check_same_thread": False} 13 | ) 14 | async_session = async_sessionmaker(engine, expire_on_commit=False) 15 | async with engine.begin() as conn: 16 | await conn.run_sync(Base.metadata.create_all) 17 | session = async_session() 18 | try: 19 | yield session 20 | finally: 21 | await session.close() 22 | 23 | 24 | @pytest_asyncio.fixture(name="client") 25 | async def client_fixture(session: AsyncSession): 26 | def get_session_override(): 27 | return session 28 | 29 | app.dependency_overrides[get_db_session] = get_session_override 30 | 31 | async with AsyncClient(app=app, base_url="http://127.0.0.1:8000") as async_client: 32 | yield async_client 33 | 34 | app.dependency_overrides.clear() 35 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # pull official slim base image 2 | FROM python:3.11-slim 3 | 4 | # set working directory 5 | WORKDIR /files/ 6 | 7 | # set environment variables 8 | ENV PYTHONDONTWRITEBYTECODE 1 9 | ENV PYTHONUNBUFFERED 1 10 | 11 | # Install dependencies 12 | RUN apt-get update && \ 13 | apt-get install -y dos2unix &&\ 14 | apt-get install -y --no-install-recommends curl && \ 15 | curl -sSL https://install.python-poetry.org | POETRY_HOME=/opt/poetry python && \ 16 | ln -s /opt/poetry/bin/poetry /usr/local/bin/poetry && \ 17 | poetry config virtualenvs.create false && \ 18 | apt-get remove -y curl && \ 19 | apt-get clean && \ 20 | rm -rf /var/lib/apt/lists/* 21 | 22 | # Copy only the necessary files for dependency installation 23 | COPY pyproject.toml poetry.lock* /files/ 24 | 25 | # Allow installing dev dependencies to run tests 26 | ARG INSTALL_DEV=false 27 | RUN if [ "$INSTALL_DEV" = "true" ] ; then poetry install --no-root ; else poetry install --no-root --only main ; fi 28 | 29 | # add the rest of the application files 30 | COPY . . 31 | 32 | # set executable permissions in a single RUN command 33 | RUN chmod +x /files/scripts/start.sh /files/scripts/prestart.sh 34 | RUN dos2unix /files/scripts/start.sh /files/scripts/prestart.sh 35 | 36 | # define the command to run the application 37 | CMD ["/files/scripts/start.sh"] -------------------------------------------------------------------------------- /scripts/backend_pre_start.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | 5 | from sqlalchemy import MetaData, select 6 | from sqlalchemy.ext.asyncio import create_async_engine 7 | from tenacity import ( 8 | after_log, 9 | before_log, 10 | retry, 11 | stop_after_attempt, 12 | wait_fixed, 13 | ) 14 | 15 | DATABASE_URL = os.environ.get("DATABASE_URL") or "sqlite+aiosqlite:///database.db" 16 | engine = create_async_engine(DATABASE_URL, echo=True) 17 | meta = MetaData() 18 | 19 | logging.basicConfig(level=logging.INFO) 20 | logger = logging.getLogger(__name__) 21 | 22 | max_tries = 60 * 5 # 5 minutes 23 | wait_seconds = 1 24 | 25 | 26 | @retry( 27 | stop=stop_after_attempt(max_tries), 28 | wait=wait_fixed(wait_seconds), 29 | before=before_log(logger, logging.INFO), 30 | after=after_log(logger, logging.WARN), 31 | ) 32 | async def init() -> None: 33 | try: 34 | async with engine.begin() as conn: 35 | await conn.run_sync(meta.create_all) 36 | 37 | await conn.execute(select(1)) 38 | except Exception as e: 39 | print(e) 40 | logger.error(e) 41 | raise e 42 | 43 | 44 | async def main() -> None: 45 | logger.info("Initializing database service") 46 | await init() 47 | logger.info("Database Initialized successfully") 48 | 49 | 50 | if __name__ == "__main__": 51 | asyncio.run(main()) 52 | -------------------------------------------------------------------------------- /app/songs/schemas.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, field_validator 2 | 3 | 4 | class SongCreate(BaseModel): 5 | name: str 6 | artist: str 7 | description: str | None = None 8 | year: int | None = None 9 | 10 | @field_validator("year") 11 | @classmethod 12 | def validate_year(cls, value): 13 | if value is not None and value < 1900: 14 | raise ValueError("Year must be 1900 or later") 15 | return value 16 | 17 | @field_validator("description") 18 | @classmethod 19 | def validate_description(cls, value): 20 | if value is not None and len(value) < 5: 21 | raise ValueError("Description must be at least 5 characters long") 22 | return value 23 | 24 | 25 | class CityCreate(BaseModel): 26 | name: str 27 | 28 | 29 | class CityRead(CityCreate): 30 | id: int 31 | 32 | 33 | class TagCreate(BaseModel): 34 | title: str 35 | description: str 36 | 37 | 38 | class TagRead(TagCreate): 39 | id: int 40 | 41 | 42 | class SongRead(BaseModel): 43 | id: int 44 | name: str 45 | artist: str 46 | description: str | None = None 47 | year: int | None = None 48 | city: CityRead | None 49 | tags: list[TagRead] 50 | 51 | # class Config: 52 | # from_attributes = True 53 | 54 | 55 | class PaginatedSong(BaseModel): 56 | data: list[SongRead] 57 | meta: dict[str, int | None] 58 | -------------------------------------------------------------------------------- /app/main.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI, Request 2 | from fastapi.middleware.cors import CORSMiddleware 3 | from fastapi.responses import HTMLResponse 4 | from fastapi.templating import Jinja2Templates 5 | 6 | from .auth.handlers import router as auth_router 7 | from .files.handlers import router as files_router 8 | from .live_socket.handlers import router as ws_router 9 | from .nosql.handlers import router as nosql_router 10 | from .songs.handlers import router as song_router 11 | 12 | app = FastAPI() 13 | 14 | app.include_router(song_router) 15 | app.include_router(auth_router) 16 | app.include_router(files_router) 17 | app.include_router(ws_router) 18 | app.include_router(nosql_router) 19 | 20 | origins = [ 21 | "http://localhost.tiangolo.com", 22 | "https://localhost.tiangolo.com", 23 | "http://localhost", 24 | "http://localhost:8080", 25 | ] 26 | app.add_middleware( 27 | CORSMiddleware, 28 | allow_origins=origins, 29 | allow_credentials=True, 30 | allow_methods=["*"], 31 | allow_headers=["*"], 32 | ) 33 | 34 | 35 | @app.get("/ping") 36 | async def pong(): 37 | return {"ping": "pong!"} 38 | 39 | 40 | @app.get("/bootstrap", response_class=HTMLResponse) 41 | async def read_item(request: Request, user_id: str): 42 | templates = Jinja2Templates(directory="app/templates") 43 | return templates.TemplateResponse( 44 | request=request, name="index.html", context={"id": user_id} 45 | ) 46 | -------------------------------------------------------------------------------- /app/utils/pagination.py: -------------------------------------------------------------------------------- 1 | from fastapi import HTTPException 2 | from sqlalchemy import func, select 3 | from sqlalchemy.ext.asyncio import AsyncSession 4 | 5 | 6 | async def paginate( 7 | session: AsyncSession, 8 | query, 9 | page: int = 1, 10 | per_page: int = 10, 11 | ): 12 | """ 13 | Paginate a SQLModel query. 14 | :param session: The SQLAlchemy session instance. 15 | :param query: The SQLModel query to paginate. 16 | :param page: The page number to retrieve. 17 | :param per_page: The number of items per page. 18 | :return: Tuple containing the paginated data and pagination information. 19 | """ 20 | total_items = await session.execute(select(func.count()).select_from(query)) 21 | count = total_items.scalar() 22 | 23 | if count == 0: 24 | return [], {"total_pages": 0, "current_page": 0, "next_page": None} 25 | 26 | total_pages = (count - 1) // per_page + 1 27 | 28 | if page > total_pages: 29 | raise HTTPException(status_code=404, detail="Page not found") 30 | 31 | offset = (page - 1) * per_page 32 | items = await session.execute(query.offset(offset).limit(per_page)) 33 | result = items.scalars().all() 34 | 35 | next_page = page + 1 if page < total_pages else None 36 | 37 | pagination = { 38 | "current_page": page, 39 | "total_pages": total_pages, 40 | "next_page": next_page, 41 | } 42 | 43 | return result, pagination 44 | -------------------------------------------------------------------------------- /app/songs/models.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import Column, ForeignKey, Integer, String 2 | from sqlalchemy.orm import Mapped, mapped_column, relationship 3 | 4 | from ..db import Base 5 | 6 | 7 | class SongTag(Base): 8 | __tablename__ = "song_tag" 9 | 10 | song_id: Mapped[int] = mapped_column(ForeignKey("songs.id"), primary_key=True) 11 | tag_id: Mapped[int] = mapped_column(ForeignKey("tags.id"), primary_key=True) 12 | 13 | 14 | class Song(Base): 15 | __tablename__ = "songs" 16 | 17 | id: Mapped[int] = Column(Integer, primary_key=True) 18 | name: Mapped[str] = Column(String) 19 | artist: Mapped[str] = Column(String) 20 | description: Mapped[str] = Column(String) 21 | year: Mapped[int] = Column(Integer) 22 | city_id: Mapped[int | None] = mapped_column(ForeignKey("cities.id")) 23 | 24 | city: Mapped["City"] = relationship("City", back_populates="songs") 25 | tags: Mapped[list["Tag"]] = relationship( 26 | "Tag", back_populates="songs", secondary=SongTag.__table__ 27 | ) 28 | 29 | 30 | class Tag(Base): 31 | __tablename__ = "tags" 32 | id: Mapped[int] = Column(Integer, primary_key=True) 33 | 34 | title: Mapped[str] = Column(String) 35 | description: Mapped[str] = Column(String) 36 | 37 | songs: Mapped[list["Song"]] = relationship( 38 | back_populates="tags", secondary=SongTag.__table__ 39 | ) 40 | 41 | 42 | class City(Base): 43 | __tablename__ = "cities" 44 | 45 | id: Mapped[int] = Column(Integer, primary_key=True) 46 | name: Mapped[str] = Column(String) 47 | 48 | songs: Mapped[list["Song"]] = relationship("Song", back_populates="city") 49 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | api: 5 | build: 6 | context: . 7 | dockerfile: Dockerfile 8 | ports: 9 | - "8001:8001" 10 | env_file: 11 | - .env 12 | volumes: 13 | - static_volume:/app/app/static 14 | depends_on: 15 | - pdb 16 | pdb: 17 | image: postgres:15.4 18 | env_file: 19 | - .env 20 | ports: 21 | - "5432:5432" 22 | volumes: 23 | - postgres_data:/var/lib/postgresql/data 24 | redis: 25 | image: redis:7.2 26 | ports: 27 | - "6379:6379" 28 | worker: 29 | build: 30 | context: . 31 | dockerfile: Dockerfile 32 | command: celery -A app.celery.celery_app worker --loglevel=info 33 | env_file: 34 | - .env 35 | depends_on: 36 | - redis 37 | beat: 38 | build: 39 | context: . 40 | dockerfile: Dockerfile 41 | command: celery -A app.celery.celery_app beat --loglevel=info 42 | env_file: 43 | - .env 44 | depends_on: 45 | - redis 46 | flower: 47 | image: mher/flower:0.9.7 48 | command: [ 'flower', '--broker=redis://redis:6379', '--port=5555' ] 49 | ports: 50 | - "5557:5555" 51 | depends_on: 52 | - redis 53 | mongo: 54 | image: mongo:7.0.5 55 | restart: always 56 | ports: 57 | - "27017:27017" 58 | volumes: 59 | - mongodb-data:/data/db 60 | nginx: 61 | build: ./nginx 62 | volumes: 63 | - static_volume:/app/app/static 64 | ports: 65 | - "8080:80" 66 | depends_on: 67 | - api 68 | 69 | volumes: 70 | static_volume: 71 | postgres_data: 72 | mongodb-data: -------------------------------------------------------------------------------- /app/live_socket/handlers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, WebSocket 2 | from fastapi.responses import HTMLResponse 3 | 4 | router = APIRouter() 5 | 6 | html = """ 7 | 8 | 9 | 10 | Chat 11 | 12 | 13 |

WebSocket Chat

14 |
15 | 16 | 17 |
18 | 20 | 36 | 37 | 38 | """ 39 | 40 | 41 | @router.get("/ws-page") 42 | async def get(): 43 | return HTMLResponse(html) 44 | 45 | 46 | @router.websocket("/ws") 47 | async def websocket_endpoint(websocket: WebSocket): 48 | await websocket.accept() 49 | while True: 50 | data = await websocket.receive_text() 51 | await websocket.send_text(f"Message text was: Kir + {data}") 52 | -------------------------------------------------------------------------------- /.github/workflows/dev.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: 5 | - main 6 | jobs: 7 | pri-commit: 8 | runs-on: ubuntu-22.04 9 | steps: 10 | - uses: actions/checkout@v3 11 | - uses: actions/setup-python@v4 12 | with: 13 | python-version: 3.x 14 | - uses: pre-commit/action@v3.0.1 15 | - uses: pre-commit-ci/lite-action@v1.0.2 16 | if: always() 17 | ci: 18 | strategy: 19 | fail-fast: false 20 | matrix: 21 | python-version: [ "3.11" ] 22 | poetry-version: [ "1.7.1" ] 23 | os: [ ubuntu-22.04 ] 24 | runs-on: ${{ matrix.os }} 25 | 26 | services: 27 | # Label used to access the service container 28 | pdb: 29 | # Docker Hub image 30 | image: postgres:15.4 31 | ports: 32 | - 5432:5432 33 | env: 34 | POSTGRES_USER: payam 35 | POSTGRES_PASSWORD: payam 36 | POSTGRES_DB: foo 37 | # Set health checks to wait until postgres has started 38 | options: >- 39 | --health-cmd pg_isready 40 | --health-interval 10s 41 | --health-timeout 5s 42 | --health-retries 5 43 | steps: 44 | - uses: actions/checkout@v4 45 | - uses: actions/setup-python@v4 46 | with: 47 | python-version: ${{ matrix.python-version }} 48 | - name: Run image 49 | uses: abatilo/actions-poetry@v2 50 | with: 51 | poetry-version: ${{ matrix.poetry-version }} 52 | - name: Install dependencies 53 | run: poetry install 54 | - name: Run Database Migrations 55 | run: | 56 | poetry run alembic upgrade head 57 | env: 58 | DATABASE_URL: postgresql+asyncpg://payam:payam@localhost:5432/foo 59 | - name: Run the automated tests 60 | env: 61 | DATABASE_URL: postgresql+asyncpg://payam:payam@localhost:5432/foo 62 | run: | 63 | poetry run pytest -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # FastAPI + Async SQLAlchemy + Alembic + Celery + MongoDB + Redis + jwt Auth 2 | 3 | This project is an opinionated boilerplate for **FastAPI** micro framework that uses, 4 | _**Asynchronous SQLAlchemy**_, **_PostgresSQL_**, _**Alembic**_, **_Celery_**, **_MongoDB_**, _**Redis**_, **_Docker_** and **_jwt Authentication_**. You can use this ready to 5 | use sample and don't worry about CI pipelines and running database migrations and tests inside a FastAPI project. 6 | 7 | ## Add new tables to PostgresSQL database : 8 | 9 | ```sh 10 | git clone https://github.com/payamt007/FastAPI-Toolkit 11 | cd FastAPI-Toolkit/app 12 | ``` 13 | 14 | Then create new folder (for example artists) in `app` directory 15 | 16 | ```sh 17 | cd artists 18 | ```` 19 | 20 | Create `__init__.py` file and a empty `models.py` file inside folder 21 | and paste this sample content inside `models.py` file: 22 | 23 | ```python 24 | from sqlalchemy import Column, Integer, String 25 | from sqlalchemy.orm import Mapped, declarative_base 26 | 27 | Base = declarative_base() 28 | 29 | 30 | class Artist(Base): 31 | id: Mapped[int] = Column(Integer, primary_key=True) 32 | name: Mapped[str] = Column(String, primary_key=True) 33 | city: Mapped[str] = Column(String, primary_key=True) 34 | ``` 35 | 36 | go to `migrations/env.py` folder in root directory and add this content to it: 37 | 38 | ```python 39 | from app.artists.models import Artist 40 | ``` 41 | 42 | then run new migration command in root directory: 43 | 44 | ```sh 45 | alembic revision --autogenerate -m "added_artist_model" 46 | alembic upgrade head 47 | ```` 48 | 49 | ## Want to run this project? 50 | 51 | ```sh 52 | $ docker-compose up -d --build 53 | ``` 54 | 55 | ## Project Swagger: 56 | 57 | [http://127.0.0.0:8001/docs](http://127.0.0.0:8001) 58 | 59 | ### Add a song: 60 | 61 | ```sh 62 | $ curl -d '{"name":"ALen Fit", "artist":"Helen", "year":"2015"}' -H "Content-Type: application/json" -X POST http://127.0.0.3:8001/songs 63 | ``` 64 | 65 | ### Get all songs: 66 | 67 | [http://127.0.0.0:8001/songs](http://localhost:8004/songs) 68 | 69 | ## Run tests 70 | 71 | ```sh 72 | $ docker compose exec api pytest 73 | ``` -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "fastapi-toolkit" 3 | version = "0.0.3" 4 | description = "A Sample FastAPI app" 5 | authors = ["Payam Taheri"] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.11" 11 | fastapi = "^0.110" 12 | uvicorn = "^0.27" 13 | #psycopg2-binary = "^2" 14 | redis = "^4" 15 | celery = "^5" 16 | alembic = "^1" 17 | tenacity = "^8" 18 | aiosqlite = "^0.20" 19 | pytest = "^8" 20 | httpx = "^0.26" 21 | pre-commit = "^3" 22 | python-multipart = "0.0.9" 23 | python-jose = { version = "^3", extras = ["cryptography"] } 24 | passlib = { version = "^1", extras = ["bcrypt"] } 25 | websockets = "^12" 26 | coverage = "^7" 27 | motor = "^3" 28 | pymongo = "^4" 29 | Jinja2 = "^3" 30 | asyncpg = "^0.29" 31 | pytest-asyncio = "^0.23" 32 | 33 | [tool.poetry.group.dev.dependencies] 34 | commitizen = "^3" 35 | 36 | 37 | [tool.poetry.group.lint.dependencies] 38 | ruff = "^0.2" 39 | mypy = "^1" 40 | 41 | [tool.poetry.group.mypy.dependencies] 42 | types-python-jose = "^3" 43 | types-passlib = "^1" 44 | celery-stubs = "^0.1" 45 | 46 | 47 | [build-system] 48 | requires = ["poetry-core"] 49 | build-backend = "poetry.core.masonry.api" 50 | 51 | [tool.ruff] 52 | fix = true 53 | indent-width = 4 54 | line-length = 88 55 | target-version = "py311" 56 | exclude = ["migrations/*.*", "alembic/*.*"] 57 | 58 | lint.select = [ 59 | "E", # pycodestyle errors 60 | "W", # pycodestyle warnings 61 | "F", # pyflakes 62 | "I", # isort 63 | "B", # flake8-bugbear 64 | "C4", # flake8-comprehensions 65 | "UP", # pyupgrade 66 | ] 67 | lint.ignore = [ 68 | "E501", # line too long, handled by black 69 | "B008", # do not perform function calls in argument defaults 70 | "W191", # indentation contains tabs 71 | ] 72 | 73 | [tool.ruff.lint.isort] 74 | known-third-party = ["fastapi", "pydantic", "starlette"] 75 | 76 | 77 | [tool.mypy] 78 | python_version = 3.11 79 | exclude = ['.venv/*.*', 'app/songs/models.py', 'migrations/*.*', "alembic/*.*"] 80 | 81 | [tool.commitizen] 82 | name = "cz_conventional_commits" 83 | version = "0.0.3" 84 | tag_format = "v$version" 85 | version_files = [ 86 | "README.md", 87 | "pyproject.toml:version", 88 | "app/__init__.py:__version__", 89 | ] 90 | major_version_zero = true -------------------------------------------------------------------------------- /app/nosql/models.py: -------------------------------------------------------------------------------- 1 | from typing import Annotated 2 | 3 | from bson import ObjectId 4 | from pydantic import BaseModel, ConfigDict, Field 5 | from pydantic.functional_validators import BeforeValidator 6 | 7 | PyObjectId = Annotated[str, BeforeValidator(str)] 8 | 9 | 10 | class StudentModel(BaseModel): 11 | """ 12 | Container for a single student record. 13 | """ 14 | 15 | # The primary key for the StudentModel, stored as a `str` on the instance. 16 | # This will be aliased to `_id` when sent to MongoDB, 17 | # but provided as `id` in the API requests and responses. 18 | id: PyObjectId | None = Field(alias="_id", default=None) 19 | name: str = Field(...) 20 | email: str = Field(...) 21 | course: str = Field(...) 22 | gpa: float = Field(..., le=4.0) 23 | model_config = ConfigDict( 24 | populate_by_name=True, 25 | arbitrary_types_allowed=True, 26 | json_schema_extra={ 27 | "example": { 28 | "name": "Jane Doe", 29 | "email": "jdoe@example.com", 30 | "course": "Experiments, Science, and Fashion in Nanophotonics", 31 | "gpa": 3.0, 32 | } 33 | }, 34 | ) 35 | 36 | 37 | class UpdateStudentModel(BaseModel): 38 | """ 39 | A set of optional updates to be made to a document in the database. 40 | """ 41 | 42 | name: str | None = None 43 | email: str | None = None 44 | course: str | None = None 45 | gpa: float | None = None 46 | model_config = ConfigDict( 47 | arbitrary_types_allowed=True, 48 | json_encoders={ObjectId: str}, 49 | json_schema_extra={ 50 | "example": { 51 | "name": "Jane Doe", 52 | "email": "jdoe@example.com", 53 | "course": "Experiments, Science, and Fashion in Nanophotonics", 54 | "gpa": 3.0, 55 | } 56 | }, 57 | ) 58 | 59 | 60 | class StudentCollection(BaseModel): 61 | """ 62 | A container holding a list of `StudentModel` instances. 63 | This exists because providing a top-level array in a JSON response can be a 64 | [vulnerability](https://haacked.com/archive/2009/06/25/json-hijacking.aspx/) 65 | """ 66 | 67 | students: list[StudentModel] 68 | -------------------------------------------------------------------------------- /alembic/versions/180cc3782c77_init.py: -------------------------------------------------------------------------------- 1 | """init 2 | 3 | Revision ID: 180cc3782c77 4 | Revises: 5 | Create Date: 2024-03-07 18:57:29.839982 6 | 7 | """ 8 | from typing import Sequence, Union 9 | 10 | from alembic import op 11 | import sqlalchemy as sa 12 | 13 | 14 | # revision identifiers, used by Alembic. 15 | revision: str = '180cc3782c77' 16 | down_revision: Union[str, None] = None 17 | branch_labels: Union[str, Sequence[str], None] = None 18 | depends_on: Union[str, Sequence[str], None] = None 19 | 20 | 21 | def upgrade() -> None: 22 | # ### commands auto generated by Alembic - please adjust! ### 23 | op.create_table('cities', 24 | sa.Column('id', sa.Integer(), nullable=False), 25 | sa.Column('name', sa.String(), nullable=True), 26 | sa.PrimaryKeyConstraint('id') 27 | ) 28 | op.create_table('tags', 29 | sa.Column('id', sa.Integer(), nullable=False), 30 | sa.Column('title', sa.String(), nullable=True), 31 | sa.Column('description', sa.String(), nullable=True), 32 | sa.PrimaryKeyConstraint('id') 33 | ) 34 | op.create_table('users', 35 | sa.Column('id', sa.Integer(), nullable=False), 36 | sa.Column('username', sa.String(), nullable=True), 37 | sa.Column('email', sa.String(), nullable=True), 38 | sa.Column('full_name', sa.String(), nullable=True), 39 | sa.Column('password', sa.String(), nullable=True), 40 | sa.PrimaryKeyConstraint('id') 41 | ) 42 | op.create_table('songs', 43 | sa.Column('id', sa.Integer(), nullable=False), 44 | sa.Column('name', sa.String(), nullable=True), 45 | sa.Column('artist', sa.String(), nullable=True), 46 | sa.Column('description', sa.String(), nullable=True), 47 | sa.Column('year', sa.Integer(), nullable=True), 48 | sa.Column('city_id', sa.Integer(), nullable=True), 49 | sa.ForeignKeyConstraint(['city_id'], ['cities.id'], ), 50 | sa.PrimaryKeyConstraint('id') 51 | ) 52 | op.create_table('song_tag', 53 | sa.Column('song_id', sa.Integer(), nullable=False), 54 | sa.Column('tag_id', sa.Integer(), nullable=False), 55 | sa.ForeignKeyConstraint(['song_id'], ['songs.id'], ), 56 | sa.ForeignKeyConstraint(['tag_id'], ['tags.id'], ), 57 | sa.PrimaryKeyConstraint('song_id', 'tag_id') 58 | ) 59 | # ### end Alembic commands ### 60 | 61 | 62 | def downgrade() -> None: 63 | # ### commands auto generated by Alembic - please adjust! ### 64 | op.drop_table('song_tag') 65 | op.drop_table('songs') 66 | op.drop_table('users') 67 | op.drop_table('tags') 68 | op.drop_table('cities') 69 | # ### end Alembic commands ### 70 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## v0.3.0 (2024-02-20) 2 | 3 | ### Feat 4 | 5 | - Updated ruff settings 6 | - Added Flower for celery 7 | - Added db to celery task 8 | - Activated CORS 9 | - Added websocket 10 | - Added file upload feature 11 | - Add route validation 12 | - First stable release 13 | 14 | ### Fix 15 | 16 | - removed unused annotations 17 | - fixed celery worker error 18 | - fixed typos from pyproject.toml 19 | - fixed migration problem, added alchemy relationships 20 | 21 | ## v0.1.0 (2024-02-19) 22 | 23 | ## v0.1.0a0 (2024-02-19) 24 | 25 | ### Feat 26 | 27 | - Added commitizen auto versioning 28 | - altered pre commit 29 | - implemented mypy 30 | - added ruff pre commit and removed isort 31 | - Changed db config 32 | - Added auth with alchemy change 33 | - Added User table and a get user route 34 | - Added basic auth boilerplate 35 | 36 | ### Fix 37 | 38 | - removed async driver in ci/cd 39 | - altered ci-cd to activate in develop 40 | - added protected rout 41 | - changed all db models to sync 42 | - isort 43 | 44 | ## v0.2.0 (2024-02-19) 45 | 46 | ### Feat 47 | 48 | - First stable release 49 | 50 | ## v0.1.0a0 (2024-02-19) 51 | 52 | ### Feat 53 | 54 | - Added commitizen auto versioning 55 | - altered pre commit 56 | - implemented mypy 57 | - added ruff pre commit and removed isort 58 | - Changed db config 59 | - Added auth with alchemy change 60 | - Added User table and a get user route 61 | - Added basic auth boilerplate 62 | - Added pre commit 63 | - Used Big app model 64 | - Added foreign key to models 65 | - Updated README.md 66 | - Dynamically loaded db url in alembic.ini 67 | - Added dev github action 68 | - Added volume for postgrsql container 69 | - Added eventlet to celery worker 70 | - Added celery beat 71 | - Ran celery in docker container 72 | - Ran celery in localhost 73 | - Ran test is docker container in FastAPI way 74 | - Runned test with requests lib inside docker 75 | - Runned plain local test 76 | - added .env.test 77 | - added .env file 78 | - used postgresql 79 | - runned in sqlite mode 80 | 81 | ### Fix 82 | 83 | - removed async driver in ci/cd 84 | - altered ci-cd to activate in develop 85 | - added protected rout 86 | - changed all db models to sync 87 | - isort 88 | - updated description to song model 89 | - rebased main 90 | - Fixed typos in README.md 91 | - changed actions run in main branch 92 | - changed db driver 93 | - changed db env setting 94 | - changed alembic config to localhost 95 | - changed alembic config 96 | - changed alembic for with poetry 97 | - changed alembic for custom action 98 | - changed alembic 99 | - commented waiting for db 100 | - changed actions level 101 | - changed db host in wait for in actions 102 | - changed branch name in action file 103 | - changed ci step 104 | - removed unused files 105 | -------------------------------------------------------------------------------- /alembic/env.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import os 3 | from logging.config import fileConfig 4 | 5 | from sqlalchemy import pool 6 | from sqlalchemy.engine import Connection 7 | from sqlalchemy.ext.asyncio import async_engine_from_config 8 | 9 | from alembic import context 10 | from app.db import Base 11 | from app.auth.models import User # New 12 | from app.songs.models import Song, City, Tag, SongTag # New 13 | 14 | # this is the Alembic Config object, which provides 15 | # access to the values within the .ini file in use. 16 | config = context.config 17 | config.set_main_option( 18 | "sqlalchemy.url", os.environ.get("DATABASE_URL") or "sqlite+aiosqlite:///database.db" 19 | ) 20 | # Interpret the config file for Python logging. 21 | # This line sets up loggers basically. 22 | if config.config_file_name is not None: 23 | fileConfig(config.config_file_name) 24 | 25 | # add your model's MetaData object here 26 | # for 'autogenerate' support 27 | # from myapp import mymodel 28 | # target_metadata = mymodel.Base.metadata 29 | target_metadata = Base.metadata 30 | 31 | 32 | # other values from the config, defined by the needs of env.py, 33 | # can be acquired: 34 | # my_important_option = config.get_main_option("my_important_option") 35 | # ... etc. 36 | 37 | 38 | def run_migrations_offline() -> None: 39 | """Run migrations in 'offline' mode. 40 | 41 | This configures the context with just a URL 42 | and not an Engine, though an Engine is acceptable 43 | here as well. By skipping the Engine creation 44 | we don't even need a DBAPI to be available. 45 | 46 | Calls to context.execute() here emit the given string to the 47 | script output. 48 | 49 | """ 50 | url = config.get_main_option("sqlalchemy.url") 51 | context.configure( 52 | url=url, 53 | target_metadata=target_metadata, 54 | literal_binds=True, 55 | dialect_opts={"paramstyle": "named"}, 56 | ) 57 | 58 | with context.begin_transaction(): 59 | context.run_migrations() 60 | 61 | 62 | def do_run_migrations(connection: Connection) -> None: 63 | context.configure(connection=connection, target_metadata=target_metadata) 64 | 65 | with context.begin_transaction(): 66 | context.run_migrations() 67 | 68 | 69 | async def run_async_migrations() -> None: 70 | """In this scenario we need to create an Engine 71 | and associate a connection with the context. 72 | 73 | """ 74 | 75 | connectable = async_engine_from_config( 76 | config.get_section(config.config_ini_section, {}), 77 | prefix="sqlalchemy.", 78 | poolclass=pool.NullPool, 79 | ) 80 | 81 | async with connectable.connect() as connection: 82 | await connection.run_sync(do_run_migrations) 83 | 84 | await connectable.dispose() 85 | 86 | 87 | def run_migrations_online() -> None: 88 | """Run migrations in 'online' mode.""" 89 | 90 | asyncio.run(run_async_migrations()) 91 | 92 | 93 | if context.is_offline_mode(): 94 | run_migrations_offline() 95 | else: 96 | run_migrations_online() 97 | -------------------------------------------------------------------------------- /app/songs/handlers.py: -------------------------------------------------------------------------------- 1 | from fastapi import APIRouter, Depends, Query 2 | from fastapi.security import OAuth2PasswordBearer 3 | from sqlalchemy import select 4 | from sqlalchemy.ext.asyncio import AsyncSession 5 | from sqlalchemy.orm import selectinload 6 | 7 | from ..db import get_db_session 8 | from ..redis import r 9 | from ..utils.pagination import paginate 10 | from .models import City, Song, Tag 11 | from .schemas import ( 12 | CityCreate, 13 | CityRead, 14 | SongCreate, 15 | TagCreate, 16 | TagRead, 17 | ) 18 | 19 | router = APIRouter() 20 | 21 | oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") 22 | 23 | 24 | @router.get("/songs") 25 | async def get_songs( 26 | page: int = Query(1, ge=1), 27 | per_page: int = Query(5, le=100), 28 | session: AsyncSession = Depends(get_db_session), 29 | ): 30 | query = select(Song).options(selectinload(Song.tags), selectinload(Song.city)) 31 | items, pagination = await paginate(session, query, page, per_page) 32 | 33 | return {"meta": pagination, "data": items} 34 | 35 | 36 | @router.post("/songs") 37 | async def add_song(song: SongCreate, session: AsyncSession = Depends(get_db_session)): 38 | new_song = Song( 39 | name=song.name, 40 | artist=song.artist, 41 | year=song.year, 42 | description=song.description, 43 | ) 44 | session.add(new_song) 45 | await session.commit() 46 | return new_song 47 | 48 | 49 | @router.post("/city", response_model=CityRead) 50 | async def create_city( 51 | *, session: AsyncSession = Depends(get_db_session), city: CityCreate 52 | ): 53 | # new_city = City(**city.dict()) 54 | new_city = City(name=city.name) 55 | session.add(new_city) 56 | await session.commit() 57 | await session.refresh(new_city) 58 | return new_city 59 | 60 | 61 | @router.post("/connect_city_with_song") 62 | async def connect_city_with_song( 63 | city_title: str, song_title: str, session: AsyncSession = Depends(get_db_session) 64 | ): 65 | city_in_db = await session.scalars(select(City).where(City.name == city_title)) 66 | city = city_in_db.first() 67 | song_in_db = await session.scalars( 68 | select(Song).where(Song.name.like(f"%{song_title}%")) 69 | ) 70 | song = song_in_db.first() 71 | song.city = city 72 | session.add(song) 73 | await session.commit() 74 | await session.refresh(song) 75 | return {"done": True} 76 | 77 | 78 | @router.post("/tags", response_model=TagRead) 79 | async def create_tag( 80 | *, session: AsyncSession = Depends(get_db_session), tag: TagCreate 81 | ): 82 | input_tag = TagCreate.model_validate(tag) 83 | new_tag = Tag(**input_tag.dict()) 84 | session.add(new_tag) 85 | await session.commit() 86 | await session.refresh(new_tag) 87 | return new_tag 88 | 89 | 90 | @router.post("/attach-tags") 91 | async def attach_tag_to_song( 92 | tag_title: str, song_name: str, session: AsyncSession = Depends(get_db_session) 93 | ): 94 | tag_in_db = await session.scalars( 95 | select(Tag).where(Tag.title.like(f"%{tag_title}%")) 96 | ) 97 | tag = tag_in_db.first() 98 | song_in_db = await session.scalars( 99 | select(Song) 100 | .options(selectinload(Song.tags)) 101 | .where(Song.name.like(f"%{song_name}%")) 102 | ) 103 | song = song_in_db.first() 104 | song.tags.append(tag) 105 | session.add(tag) 106 | await session.commit() 107 | await session.refresh(song) 108 | return {"done": True} 109 | 110 | 111 | @router.post("/redis") 112 | def save_in_redis(key: str, value: str): 113 | r.set(key, value) 114 | return {"done": True} 115 | -------------------------------------------------------------------------------- /app/nosql/handlers.py: -------------------------------------------------------------------------------- 1 | from bson import ObjectId 2 | from fastapi import APIRouter, Body, HTTPException, status 3 | from fastapi.responses import Response 4 | from pymongo import ReturnDocument 5 | 6 | from ..mongo import client 7 | from .models import StudentCollection, StudentModel, UpdateStudentModel 8 | 9 | db = client.college 10 | 11 | student_collection = db.get_collection("students") 12 | 13 | router = APIRouter() 14 | 15 | 16 | @router.post( 17 | "/students/", 18 | response_description="Add new student", 19 | response_model=StudentModel, 20 | status_code=status.HTTP_201_CREATED, 21 | response_model_by_alias=False, 22 | ) 23 | async def create_student(student: StudentModel = Body(...)): 24 | """ 25 | Insert a new student record. 26 | 27 | A unique `id` will be created and provided in the response. 28 | """ 29 | new_student = await student_collection.insert_one( 30 | student.model_dump(by_alias=True, exclude=["id"]) 31 | ) 32 | created_student = await student_collection.find_one( 33 | {"_id": new_student.inserted_id} 34 | ) 35 | return created_student 36 | 37 | 38 | @router.get( 39 | "/students/", 40 | response_description="List all students", 41 | response_model=StudentCollection, 42 | response_model_by_alias=False, 43 | ) 44 | async def list_students(): 45 | """ 46 | List all of the student data in the database. 47 | 48 | The response is unpaginated and limited to 1000 results. 49 | """ 50 | return StudentCollection(students=await student_collection.find().to_list(1000)) 51 | 52 | 53 | @router.get( 54 | "/students/{id}", 55 | response_description="Get a single student", 56 | response_model=StudentModel, 57 | response_model_by_alias=False, 58 | ) 59 | async def show_student(id: str): 60 | """ 61 | Get the record for a specific student, looked up by `id`. 62 | """ 63 | if ( 64 | student := await student_collection.find_one({"_id": ObjectId(id)}) 65 | ) is not None: 66 | return student 67 | 68 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 69 | 70 | 71 | @router.put( 72 | "/students/{id}", 73 | response_description="Update a student", 74 | response_model=StudentModel, 75 | response_model_by_alias=False, 76 | ) 77 | async def update_student(id: str, student: UpdateStudentModel = Body(...)): 78 | """ 79 | Update individual fields of an existing student record. 80 | 81 | Only the provided fields will be updated. 82 | Any missing or `null` fields will be ignored. 83 | """ 84 | student = { 85 | k: v for k, v in student.model_dump(by_alias=True).items() if v is not None 86 | } 87 | 88 | if len(student) >= 1: 89 | update_result = await student_collection.find_one_and_update( 90 | {"_id": ObjectId(id)}, 91 | {"$set": student}, 92 | return_document=ReturnDocument.AFTER, 93 | ) 94 | if update_result is not None: 95 | return update_result 96 | else: 97 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 98 | 99 | # The update is empty, but we should still return the matching document: 100 | if (existing_student := await student_collection.find_one({"_id": id})) is not None: 101 | return existing_student 102 | 103 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 104 | 105 | 106 | @router.delete("/students/{id}", response_description="Delete a student") 107 | async def delete_student(id: str): 108 | """ 109 | Remove a single student record from the database. 110 | """ 111 | delete_result = await student_collection.delete_one({"_id": ObjectId(id)}) 112 | 113 | if delete_result.deleted_count == 1: 114 | return Response(status_code=status.HTTP_204_NO_CONTENT) 115 | 116 | raise HTTPException(status_code=404, detail=f"Student {id} not found") 117 | -------------------------------------------------------------------------------- /alembic.ini: -------------------------------------------------------------------------------- 1 | # A generic, single database configuration. 2 | 3 | [alembic] 4 | # path to migration scripts 5 | script_location = alembic 6 | 7 | # template used to generate migration file names; The default value is %%(rev)s_%%(slug)s 8 | # Uncomment the line below if you want the files to be prepended with date and time 9 | # file_template = %%(year)d_%%(month).2d_%%(day).2d_%%(hour).2d%%(minute).2d-%%(rev)s_%%(slug)s 10 | 11 | # sys.path path, will be prepended to sys.path if present. 12 | # defaults to the current working directory. 13 | prepend_sys_path = . 14 | 15 | # timezone to use when rendering the date within the migration file 16 | # as well as the filename. 17 | # If specified, requires the python>=3.9 or backports.zoneinfo library. 18 | # Any required deps can installed by adding `alembic[tz]` to the pip requirements 19 | # string value is passed to ZoneInfo() 20 | # leave blank for localtime 21 | # timezone = 22 | 23 | # max length of characters to apply to the 24 | # "slug" field 25 | # truncate_slug_length = 40 26 | 27 | # set to 'true' to run the environment during 28 | # the 'revision' command, regardless of autogenerate 29 | # revision_environment = false 30 | 31 | # set to 'true' to allow .pyc and .pyo files without 32 | # a source .py file to be detected as revisions in the 33 | # versions/ directory 34 | # sourceless = false 35 | 36 | # version location specification; This defaults 37 | # to alembic/versions. When using multiple version 38 | # directories, initial revisions must be specified with --version-path. 39 | # The path separator used here should be the separator specified by "version_path_separator" below. 40 | # version_locations = %(here)s/bar:%(here)s/bat:alembic/versions 41 | 42 | # version path separator; As mentioned above, this is the character used to split 43 | # version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. 44 | # If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. 45 | # Valid values for version_path_separator are: 46 | # 47 | # version_path_separator = : 48 | # version_path_separator = ; 49 | # version_path_separator = space 50 | version_path_separator = os # Use os.pathsep. Default configuration used for new projects. 51 | 52 | # set to 'true' to search source files recursively 53 | # in each "version_locations" directory 54 | # new in Alembic version 1.10 55 | # recursive_version_locations = false 56 | 57 | # the output encoding used when revision files 58 | # are written from script.py.mako 59 | # output_encoding = utf-8 60 | 61 | # sqlalchemy.url = driver://user:pass@localhost/dbname 62 | 63 | 64 | [post_write_hooks] 65 | # post_write_hooks defines scripts or Python functions that are run 66 | # on newly generated revision scripts. See the documentation for further 67 | # detail and examples 68 | 69 | # format using "black" - use the console_scripts runner, against the "black" entrypoint 70 | # hooks = black 71 | # black.type = console_scripts 72 | # black.entrypoint = black 73 | # black.options = -l 79 REVISION_SCRIPT_FILENAME 74 | 75 | # lint with attempts to fix using "ruff" - use the exec runner, execute a binary 76 | # hooks = ruff 77 | # ruff.type = exec 78 | # ruff.executable = %(here)s/.venv/bin/ruff 79 | # ruff.options = --fix REVISION_SCRIPT_FILENAME 80 | 81 | # Logging configuration 82 | [loggers] 83 | keys = root,sqlalchemy,alembic 84 | 85 | [handlers] 86 | keys = console 87 | 88 | [formatters] 89 | keys = generic 90 | 91 | [logger_root] 92 | level = WARN 93 | handlers = console 94 | qualname = 95 | 96 | [logger_sqlalchemy] 97 | level = WARN 98 | handlers = 99 | qualname = sqlalchemy.engine 100 | 101 | [logger_alembic] 102 | level = INFO 103 | handlers = 104 | qualname = alembic 105 | 106 | [handler_console] 107 | class = StreamHandler 108 | args = (sys.stderr,) 109 | level = NOTSET 110 | formatter = generic 111 | 112 | [formatter_generic] 113 | format = %(levelname)-5.5s [%(name)s] %(message)s 114 | datefmt = %H:%M:%S 115 | -------------------------------------------------------------------------------- /app/auth/handlers.py: -------------------------------------------------------------------------------- 1 | import os 2 | from datetime import UTC, datetime, timedelta 3 | from typing import Annotated 4 | 5 | import bcrypt 6 | from fastapi import APIRouter, Depends, HTTPException, Security, status 7 | from fastapi.security import ( 8 | OAuth2PasswordBearer, 9 | OAuth2PasswordRequestForm, 10 | SecurityScopes, 11 | ) 12 | from jose import JWTError, jwt 13 | from pydantic import BaseModel, ValidationError 14 | from sqlalchemy import select 15 | from sqlalchemy.ext.asyncio import AsyncSession 16 | 17 | from ..db import async_session, get_db_session 18 | from .models import User 19 | from .schemas import UserCreate, UserRead 20 | 21 | SECRET_KEY = os.environ.get("SECRET_KEY") or "sample_secret_key_here!!!" 22 | ALGORITHM = os.environ.get("ALGORITHM") or "HS256" 23 | ACCESS_TOKEN_EXPIRE_MINUTES = os.environ.get("ACCESS_TOKEN_EXPIRE_MINUTES") or 30 24 | 25 | router = APIRouter() 26 | 27 | oauth2_scheme = OAuth2PasswordBearer( 28 | tokenUrl="token", 29 | scopes={"me": "Read information about the current user.", "items": "Read items."}, 30 | ) 31 | 32 | 33 | class Token(BaseModel): 34 | access_token: str 35 | token_type: str 36 | 37 | 38 | class TokenData(BaseModel): 39 | username: str | None = None 40 | scopes: list[str] = [] 41 | 42 | 43 | def verify_password(plain_password, hashed_password): 44 | return bcrypt.checkpw(plain_password.encode("utf-8"), hashed_password) 45 | 46 | 47 | def get_password_hash(password: str): 48 | salt = bcrypt.gensalt(rounds=14) 49 | return bcrypt.hashpw(password.encode("utf-8"), salt) 50 | 51 | 52 | async def get_user(username: str | None): 53 | session = async_session() 54 | result = await session.execute(select(User).where(User.username == username)) 55 | user = result.scalar() 56 | return user 57 | 58 | 59 | async def authenticate_user(username: str, password: str): 60 | user = await get_user(username) 61 | if not user: 62 | return False 63 | if not verify_password(password, user.password): 64 | return False 65 | return user 66 | 67 | 68 | def create_access_token(data: dict, expires_delta: timedelta | None = None): 69 | to_encode = data.copy() 70 | if expires_delta: 71 | expire = datetime.now(UTC) + expires_delta 72 | else: 73 | expire = datetime.now(UTC) + timedelta(minutes=15) 74 | to_encode.update({"exp": expire}) 75 | encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) 76 | return encoded_jwt 77 | 78 | 79 | async def get_current_user( 80 | security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)] 81 | ): 82 | if security_scopes.scopes: 83 | authenticate_value = f'Bearer scope="{security_scopes.scope_str}"' 84 | else: 85 | authenticate_value = "Bearer" 86 | credentials_exception = HTTPException( 87 | status_code=status.HTTP_401_UNAUTHORIZED, 88 | detail="Could not validate credentials", 89 | headers={"WWW-Authenticate": authenticate_value}, 90 | ) 91 | try: 92 | payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) 93 | username = payload.get("sub") 94 | if username is None: 95 | raise credentials_exception 96 | token_scopes = payload.get("scopes", []) 97 | token_data = TokenData(scopes=token_scopes, username=username) 98 | except (JWTError, ValidationError) as err: 99 | raise credentials_exception from err 100 | user = get_user(username=token_data.username) 101 | if user is None: 102 | raise credentials_exception 103 | for scope in security_scopes.scopes: 104 | if scope not in token_data.scopes: 105 | raise HTTPException( 106 | status_code=status.HTTP_401_UNAUTHORIZED, 107 | detail="Not enough permissions", 108 | headers={"WWW-Authenticate": authenticate_value}, 109 | ) 110 | return user 111 | 112 | 113 | async def get_current_active_user( 114 | current_user: Annotated[User, Security(get_current_user, scopes=["me"])], 115 | ): 116 | if current_user.disabled: 117 | raise HTTPException(status_code=400, detail="Inactive user") 118 | return current_user 119 | 120 | 121 | @router.post("/token") 122 | async def login_for_access_token( 123 | form_data: Annotated[OAuth2PasswordRequestForm, Depends()], 124 | ) -> Token: 125 | user = await authenticate_user(form_data.username, form_data.password) 126 | if not user: 127 | raise HTTPException( 128 | status_code=status.HTTP_401_UNAUTHORIZED, 129 | detail="Incorrect username or password", 130 | headers={"WWW-Authenticate": "Bearer"}, 131 | ) 132 | access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) 133 | access_token = create_access_token( 134 | data={"sub": user.username, "scopes": form_data.scopes}, 135 | expires_delta=access_token_expires, 136 | ) 137 | return Token(access_token=access_token, token_type="bearer") 138 | 139 | 140 | @router.get("/user", response_model=UserRead) 141 | async def get_current_active_user_from_token( 142 | current_user: Annotated[User, Depends(get_current_active_user)], 143 | ): 144 | return current_user 145 | 146 | 147 | @router.post("/user", response_model=UserRead) 148 | async def register_user( 149 | user: UserCreate, 150 | session: AsyncSession = Depends(get_db_session), 151 | ): 152 | new_user = User( 153 | username=user.username, 154 | full_name=user.full_name, 155 | email=user.email, 156 | password=get_password_hash(user.password), 157 | ) 158 | session.add(new_user) 159 | await session.commit() 160 | await session.refresh(new_user) 161 | return new_user 162 | -------------------------------------------------------------------------------- /app/static/bootstrap.bundle.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | * Bootstrap v5.3.3 (https://getbootstrap.com/) 3 | * Copyright 2011-2024 The Bootstrap Authors (https://github.com/twbs/bootstrap/graphs/contributors) 4 | * Licensed under MIT (https://github.com/twbs/bootstrap/blob/main/LICENSE) 5 | */ 6 | !function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).bootstrap=e()}(this,(function(){"use strict";const t=new Map,e={set(e,i,n){t.has(e)||t.set(e,new Map);const s=t.get(e);s.has(i)||0===s.size?s.set(i,n):console.error(`Bootstrap doesn't allow more than one instance per element. Bound instance: ${Array.from(s.keys())[0]}.`)},get:(e,i)=>t.has(e)&&t.get(e).get(i)||null,remove(e,i){if(!t.has(e))return;const n=t.get(e);n.delete(i),0===n.size&&t.delete(e)}},i="transitionend",n=t=>(t&&window.CSS&&window.CSS.escape&&(t=t.replace(/#([^\s"#']+)/g,((t,e)=>`#${CSS.escape(e)}`))),t),s=t=>{t.dispatchEvent(new Event(i))},o=t=>!(!t||"object"!=typeof t)&&(void 0!==t.jquery&&(t=t[0]),void 0!==t.nodeType),r=t=>o(t)?t.jquery?t[0]:t:"string"==typeof t&&t.length>0?document.querySelector(n(t)):null,a=t=>{if(!o(t)||0===t.getClientRects().length)return!1;const e="visible"===getComputedStyle(t).getPropertyValue("visibility"),i=t.closest("details:not([open])");if(!i)return e;if(i!==t){const e=t.closest("summary");if(e&&e.parentNode!==i)return!1;if(null===e)return!1}return e},l=t=>!t||t.nodeType!==Node.ELEMENT_NODE||!!t.classList.contains("disabled")||(void 0!==t.disabled?t.disabled:t.hasAttribute("disabled")&&"false"!==t.getAttribute("disabled")),c=t=>{if(!document.documentElement.attachShadow)return null;if("function"==typeof t.getRootNode){const e=t.getRootNode();return e instanceof ShadowRoot?e:null}return t instanceof ShadowRoot?t:t.parentNode?c(t.parentNode):null},h=()=>{},d=t=>{t.offsetHeight},u=()=>window.jQuery&&!document.body.hasAttribute("data-bs-no-jquery")?window.jQuery:null,f=[],p=()=>"rtl"===document.documentElement.dir,m=t=>{var e;e=()=>{const e=u();if(e){const i=t.NAME,n=e.fn[i];e.fn[i]=t.jQueryInterface,e.fn[i].Constructor=t,e.fn[i].noConflict=()=>(e.fn[i]=n,t.jQueryInterface)}},"loading"===document.readyState?(f.length||document.addEventListener("DOMContentLoaded",(()=>{for(const t of f)t()})),f.push(e)):e()},g=(t,e=[],i=t)=>"function"==typeof t?t(...e):i,_=(t,e,n=!0)=>{if(!n)return void g(t);const o=(t=>{if(!t)return 0;let{transitionDuration:e,transitionDelay:i}=window.getComputedStyle(t);const n=Number.parseFloat(e),s=Number.parseFloat(i);return n||s?(e=e.split(",")[0],i=i.split(",")[0],1e3*(Number.parseFloat(e)+Number.parseFloat(i))):0})(e)+5;let r=!1;const a=({target:n})=>{n===e&&(r=!0,e.removeEventListener(i,a),g(t))};e.addEventListener(i,a),setTimeout((()=>{r||s(e)}),o)},b=(t,e,i,n)=>{const s=t.length;let o=t.indexOf(e);return-1===o?!i&&n?t[s-1]:t[0]:(o+=i?1:-1,n&&(o=(o+s)%s),t[Math.max(0,Math.min(o,s-1))])},v=/[^.]*(?=\..*)\.|.*/,y=/\..*/,w=/::\d+$/,A={};let E=1;const T={mouseenter:"mouseover",mouseleave:"mouseout"},C=new Set(["click","dblclick","mouseup","mousedown","contextmenu","mousewheel","DOMMouseScroll","mouseover","mouseout","mousemove","selectstart","selectend","keydown","keypress","keyup","orientationchange","touchstart","touchmove","touchend","touchcancel","pointerdown","pointermove","pointerup","pointerleave","pointercancel","gesturestart","gesturechange","gestureend","focus","blur","change","reset","select","submit","focusin","focusout","load","unload","beforeunload","resize","move","DOMContentLoaded","readystatechange","error","abort","scroll"]);function O(t,e){return e&&`${e}::${E++}`||t.uidEvent||E++}function x(t){const e=O(t);return t.uidEvent=e,A[e]=A[e]||{},A[e]}function k(t,e,i=null){return Object.values(t).find((t=>t.callable===e&&t.delegationSelector===i))}function L(t,e,i){const n="string"==typeof e,s=n?i:e||i;let o=I(t);return C.has(o)||(o=t),[n,s,o]}function S(t,e,i,n,s){if("string"!=typeof e||!t)return;let[o,r,a]=L(e,i,n);if(e in T){const t=t=>function(e){if(!e.relatedTarget||e.relatedTarget!==e.delegateTarget&&!e.delegateTarget.contains(e.relatedTarget))return t.call(this,e)};r=t(r)}const l=x(t),c=l[a]||(l[a]={}),h=k(c,r,o?i:null);if(h)return void(h.oneOff=h.oneOff&&s);const d=O(r,e.replace(v,"")),u=o?function(t,e,i){return function n(s){const o=t.querySelectorAll(e);for(let{target:r}=s;r&&r!==this;r=r.parentNode)for(const a of o)if(a===r)return P(s,{delegateTarget:r}),n.oneOff&&N.off(t,s.type,e,i),i.apply(r,[s])}}(t,i,r):function(t,e){return function i(n){return P(n,{delegateTarget:t}),i.oneOff&&N.off(t,n.type,e),e.apply(t,[n])}}(t,r);u.delegationSelector=o?i:null,u.callable=r,u.oneOff=s,u.uidEvent=d,c[d]=u,t.addEventListener(a,u,o)}function D(t,e,i,n,s){const o=k(e[i],n,s);o&&(t.removeEventListener(i,o,Boolean(s)),delete e[i][o.uidEvent])}function $(t,e,i,n){const s=e[i]||{};for(const[o,r]of Object.entries(s))o.includes(n)&&D(t,e,i,r.callable,r.delegationSelector)}function I(t){return t=t.replace(y,""),T[t]||t}const N={on(t,e,i,n){S(t,e,i,n,!1)},one(t,e,i,n){S(t,e,i,n,!0)},off(t,e,i,n){if("string"!=typeof e||!t)return;const[s,o,r]=L(e,i,n),a=r!==e,l=x(t),c=l[r]||{},h=e.startsWith(".");if(void 0===o){if(h)for(const i of Object.keys(l))$(t,l,i,e.slice(1));for(const[i,n]of Object.entries(c)){const s=i.replace(w,"");a&&!e.includes(s)||D(t,l,r,n.callable,n.delegationSelector)}}else{if(!Object.keys(c).length)return;D(t,l,r,o,s?i:null)}},trigger(t,e,i){if("string"!=typeof e||!t)return null;const n=u();let s=null,o=!0,r=!0,a=!1;e!==I(e)&&n&&(s=n.Event(e,i),n(t).trigger(s),o=!s.isPropagationStopped(),r=!s.isImmediatePropagationStopped(),a=s.isDefaultPrevented());const l=P(new Event(e,{bubbles:o,cancelable:!0}),i);return a&&l.preventDefault(),r&&t.dispatchEvent(l),l.defaultPrevented&&s&&s.preventDefault(),l}};function P(t,e={}){for(const[i,n]of Object.entries(e))try{t[i]=n}catch(e){Object.defineProperty(t,i,{configurable:!0,get:()=>n})}return t}function j(t){if("true"===t)return!0;if("false"===t)return!1;if(t===Number(t).toString())return Number(t);if(""===t||"null"===t)return null;if("string"!=typeof t)return t;try{return JSON.parse(decodeURIComponent(t))}catch(e){return t}}function M(t){return t.replace(/[A-Z]/g,(t=>`-${t.toLowerCase()}`))}const F={setDataAttribute(t,e,i){t.setAttribute(`data-bs-${M(e)}`,i)},removeDataAttribute(t,e){t.removeAttribute(`data-bs-${M(e)}`)},getDataAttributes(t){if(!t)return{};const e={},i=Object.keys(t.dataset).filter((t=>t.startsWith("bs")&&!t.startsWith("bsConfig")));for(const n of i){let i=n.replace(/^bs/,"");i=i.charAt(0).toLowerCase()+i.slice(1,i.length),e[i]=j(t.dataset[n])}return e},getDataAttribute:(t,e)=>j(t.getAttribute(`data-bs-${M(e)}`))};class H{static get Default(){return{}}static get DefaultType(){return{}}static get NAME(){throw new Error('You have to implement the static method "NAME", for each component!')}_getConfig(t){return t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t}_mergeConfigObj(t,e){const i=o(e)?F.getDataAttribute(e,"config"):{};return{...this.constructor.Default,..."object"==typeof i?i:{},...o(e)?F.getDataAttributes(e):{},..."object"==typeof t?t:{}}}_typeCheckConfig(t,e=this.constructor.DefaultType){for(const[n,s]of Object.entries(e)){const e=t[n],r=o(e)?"element":null==(i=e)?`${i}`:Object.prototype.toString.call(i).match(/\s([a-z]+)/i)[1].toLowerCase();if(!new RegExp(s).test(r))throw new TypeError(`${this.constructor.NAME.toUpperCase()}: Option "${n}" provided type "${r}" but expected type "${s}".`)}var i}}class W extends H{constructor(t,i){super(),(t=r(t))&&(this._element=t,this._config=this._getConfig(i),e.set(this._element,this.constructor.DATA_KEY,this))}dispose(){e.remove(this._element,this.constructor.DATA_KEY),N.off(this._element,this.constructor.EVENT_KEY);for(const t of Object.getOwnPropertyNames(this))this[t]=null}_queueCallback(t,e,i=!0){_(t,e,i)}_getConfig(t){return t=this._mergeConfigObj(t,this._element),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}static getInstance(t){return e.get(r(t),this.DATA_KEY)}static getOrCreateInstance(t,e={}){return this.getInstance(t)||new this(t,"object"==typeof e?e:null)}static get VERSION(){return"5.3.3"}static get DATA_KEY(){return`bs.${this.NAME}`}static get EVENT_KEY(){return`.${this.DATA_KEY}`}static eventName(t){return`${t}${this.EVENT_KEY}`}}const B=t=>{let e=t.getAttribute("data-bs-target");if(!e||"#"===e){let i=t.getAttribute("href");if(!i||!i.includes("#")&&!i.startsWith("."))return null;i.includes("#")&&!i.startsWith("#")&&(i=`#${i.split("#")[1]}`),e=i&&"#"!==i?i.trim():null}return e?e.split(",").map((t=>n(t))).join(","):null},z={find:(t,e=document.documentElement)=>[].concat(...Element.prototype.querySelectorAll.call(e,t)),findOne:(t,e=document.documentElement)=>Element.prototype.querySelector.call(e,t),children:(t,e)=>[].concat(...t.children).filter((t=>t.matches(e))),parents(t,e){const i=[];let n=t.parentNode.closest(e);for(;n;)i.push(n),n=n.parentNode.closest(e);return i},prev(t,e){let i=t.previousElementSibling;for(;i;){if(i.matches(e))return[i];i=i.previousElementSibling}return[]},next(t,e){let i=t.nextElementSibling;for(;i;){if(i.matches(e))return[i];i=i.nextElementSibling}return[]},focusableChildren(t){const e=["a","button","input","textarea","select","details","[tabindex]",'[contenteditable="true"]'].map((t=>`${t}:not([tabindex^="-"])`)).join(",");return this.find(e,t).filter((t=>!l(t)&&a(t)))},getSelectorFromElement(t){const e=B(t);return e&&z.findOne(e)?e:null},getElementFromSelector(t){const e=B(t);return e?z.findOne(e):null},getMultipleElementsFromSelector(t){const e=B(t);return e?z.find(e):[]}},R=(t,e="hide")=>{const i=`click.dismiss${t.EVENT_KEY}`,n=t.NAME;N.on(document,i,`[data-bs-dismiss="${n}"]`,(function(i){if(["A","AREA"].includes(this.tagName)&&i.preventDefault(),l(this))return;const s=z.getElementFromSelector(this)||this.closest(`.${n}`);t.getOrCreateInstance(s)[e]()}))},q=".bs.alert",V=`close${q}`,K=`closed${q}`;class Q extends W{static get NAME(){return"alert"}close(){if(N.trigger(this._element,V).defaultPrevented)return;this._element.classList.remove("show");const t=this._element.classList.contains("fade");this._queueCallback((()=>this._destroyElement()),this._element,t)}_destroyElement(){this._element.remove(),N.trigger(this._element,K),this.dispose()}static jQueryInterface(t){return this.each((function(){const e=Q.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}R(Q,"close"),m(Q);const X='[data-bs-toggle="button"]';class Y extends W{static get NAME(){return"button"}toggle(){this._element.setAttribute("aria-pressed",this._element.classList.toggle("active"))}static jQueryInterface(t){return this.each((function(){const e=Y.getOrCreateInstance(this);"toggle"===t&&e[t]()}))}}N.on(document,"click.bs.button.data-api",X,(t=>{t.preventDefault();const e=t.target.closest(X);Y.getOrCreateInstance(e).toggle()})),m(Y);const U=".bs.swipe",G=`touchstart${U}`,J=`touchmove${U}`,Z=`touchend${U}`,tt=`pointerdown${U}`,et=`pointerup${U}`,it={endCallback:null,leftCallback:null,rightCallback:null},nt={endCallback:"(function|null)",leftCallback:"(function|null)",rightCallback:"(function|null)"};class st extends H{constructor(t,e){super(),this._element=t,t&&st.isSupported()&&(this._config=this._getConfig(e),this._deltaX=0,this._supportPointerEvents=Boolean(window.PointerEvent),this._initEvents())}static get Default(){return it}static get DefaultType(){return nt}static get NAME(){return"swipe"}dispose(){N.off(this._element,U)}_start(t){this._supportPointerEvents?this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX):this._deltaX=t.touches[0].clientX}_end(t){this._eventIsPointerPenTouch(t)&&(this._deltaX=t.clientX-this._deltaX),this._handleSwipe(),g(this._config.endCallback)}_move(t){this._deltaX=t.touches&&t.touches.length>1?0:t.touches[0].clientX-this._deltaX}_handleSwipe(){const t=Math.abs(this._deltaX);if(t<=40)return;const e=t/this._deltaX;this._deltaX=0,e&&g(e>0?this._config.rightCallback:this._config.leftCallback)}_initEvents(){this._supportPointerEvents?(N.on(this._element,tt,(t=>this._start(t))),N.on(this._element,et,(t=>this._end(t))),this._element.classList.add("pointer-event")):(N.on(this._element,G,(t=>this._start(t))),N.on(this._element,J,(t=>this._move(t))),N.on(this._element,Z,(t=>this._end(t))))}_eventIsPointerPenTouch(t){return this._supportPointerEvents&&("pen"===t.pointerType||"touch"===t.pointerType)}static isSupported(){return"ontouchstart"in document.documentElement||navigator.maxTouchPoints>0}}const ot=".bs.carousel",rt=".data-api",at="next",lt="prev",ct="left",ht="right",dt=`slide${ot}`,ut=`slid${ot}`,ft=`keydown${ot}`,pt=`mouseenter${ot}`,mt=`mouseleave${ot}`,gt=`dragstart${ot}`,_t=`load${ot}${rt}`,bt=`click${ot}${rt}`,vt="carousel",yt="active",wt=".active",At=".carousel-item",Et=wt+At,Tt={ArrowLeft:ht,ArrowRight:ct},Ct={interval:5e3,keyboard:!0,pause:"hover",ride:!1,touch:!0,wrap:!0},Ot={interval:"(number|boolean)",keyboard:"boolean",pause:"(string|boolean)",ride:"(boolean|string)",touch:"boolean",wrap:"boolean"};class xt extends W{constructor(t,e){super(t,e),this._interval=null,this._activeElement=null,this._isSliding=!1,this.touchTimeout=null,this._swipeHelper=null,this._indicatorsElement=z.findOne(".carousel-indicators",this._element),this._addEventListeners(),this._config.ride===vt&&this.cycle()}static get Default(){return Ct}static get DefaultType(){return Ot}static get NAME(){return"carousel"}next(){this._slide(at)}nextWhenVisible(){!document.hidden&&a(this._element)&&this.next()}prev(){this._slide(lt)}pause(){this._isSliding&&s(this._element),this._clearInterval()}cycle(){this._clearInterval(),this._updateInterval(),this._interval=setInterval((()=>this.nextWhenVisible()),this._config.interval)}_maybeEnableCycle(){this._config.ride&&(this._isSliding?N.one(this._element,ut,(()=>this.cycle())):this.cycle())}to(t){const e=this._getItems();if(t>e.length-1||t<0)return;if(this._isSliding)return void N.one(this._element,ut,(()=>this.to(t)));const i=this._getItemIndex(this._getActive());if(i===t)return;const n=t>i?at:lt;this._slide(n,e[t])}dispose(){this._swipeHelper&&this._swipeHelper.dispose(),super.dispose()}_configAfterMerge(t){return t.defaultInterval=t.interval,t}_addEventListeners(){this._config.keyboard&&N.on(this._element,ft,(t=>this._keydown(t))),"hover"===this._config.pause&&(N.on(this._element,pt,(()=>this.pause())),N.on(this._element,mt,(()=>this._maybeEnableCycle()))),this._config.touch&&st.isSupported()&&this._addTouchEventListeners()}_addTouchEventListeners(){for(const t of z.find(".carousel-item img",this._element))N.on(t,gt,(t=>t.preventDefault()));const t={leftCallback:()=>this._slide(this._directionToOrder(ct)),rightCallback:()=>this._slide(this._directionToOrder(ht)),endCallback:()=>{"hover"===this._config.pause&&(this.pause(),this.touchTimeout&&clearTimeout(this.touchTimeout),this.touchTimeout=setTimeout((()=>this._maybeEnableCycle()),500+this._config.interval))}};this._swipeHelper=new st(this._element,t)}_keydown(t){if(/input|textarea/i.test(t.target.tagName))return;const e=Tt[t.key];e&&(t.preventDefault(),this._slide(this._directionToOrder(e)))}_getItemIndex(t){return this._getItems().indexOf(t)}_setActiveIndicatorElement(t){if(!this._indicatorsElement)return;const e=z.findOne(wt,this._indicatorsElement);e.classList.remove(yt),e.removeAttribute("aria-current");const i=z.findOne(`[data-bs-slide-to="${t}"]`,this._indicatorsElement);i&&(i.classList.add(yt),i.setAttribute("aria-current","true"))}_updateInterval(){const t=this._activeElement||this._getActive();if(!t)return;const e=Number.parseInt(t.getAttribute("data-bs-interval"),10);this._config.interval=e||this._config.defaultInterval}_slide(t,e=null){if(this._isSliding)return;const i=this._getActive(),n=t===at,s=e||b(this._getItems(),i,n,this._config.wrap);if(s===i)return;const o=this._getItemIndex(s),r=e=>N.trigger(this._element,e,{relatedTarget:s,direction:this._orderToDirection(t),from:this._getItemIndex(i),to:o});if(r(dt).defaultPrevented)return;if(!i||!s)return;const a=Boolean(this._interval);this.pause(),this._isSliding=!0,this._setActiveIndicatorElement(o),this._activeElement=s;const l=n?"carousel-item-start":"carousel-item-end",c=n?"carousel-item-next":"carousel-item-prev";s.classList.add(c),d(s),i.classList.add(l),s.classList.add(l),this._queueCallback((()=>{s.classList.remove(l,c),s.classList.add(yt),i.classList.remove(yt,c,l),this._isSliding=!1,r(ut)}),i,this._isAnimated()),a&&this.cycle()}_isAnimated(){return this._element.classList.contains("slide")}_getActive(){return z.findOne(Et,this._element)}_getItems(){return z.find(At,this._element)}_clearInterval(){this._interval&&(clearInterval(this._interval),this._interval=null)}_directionToOrder(t){return p()?t===ct?lt:at:t===ct?at:lt}_orderToDirection(t){return p()?t===lt?ct:ht:t===lt?ht:ct}static jQueryInterface(t){return this.each((function(){const e=xt.getOrCreateInstance(this,t);if("number"!=typeof t){if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}else e.to(t)}))}}N.on(document,bt,"[data-bs-slide], [data-bs-slide-to]",(function(t){const e=z.getElementFromSelector(this);if(!e||!e.classList.contains(vt))return;t.preventDefault();const i=xt.getOrCreateInstance(e),n=this.getAttribute("data-bs-slide-to");return n?(i.to(n),void i._maybeEnableCycle()):"next"===F.getDataAttribute(this,"slide")?(i.next(),void i._maybeEnableCycle()):(i.prev(),void i._maybeEnableCycle())})),N.on(window,_t,(()=>{const t=z.find('[data-bs-ride="carousel"]');for(const e of t)xt.getOrCreateInstance(e)})),m(xt);const kt=".bs.collapse",Lt=`show${kt}`,St=`shown${kt}`,Dt=`hide${kt}`,$t=`hidden${kt}`,It=`click${kt}.data-api`,Nt="show",Pt="collapse",jt="collapsing",Mt=`:scope .${Pt} .${Pt}`,Ft='[data-bs-toggle="collapse"]',Ht={parent:null,toggle:!0},Wt={parent:"(null|element)",toggle:"boolean"};class Bt extends W{constructor(t,e){super(t,e),this._isTransitioning=!1,this._triggerArray=[];const i=z.find(Ft);for(const t of i){const e=z.getSelectorFromElement(t),i=z.find(e).filter((t=>t===this._element));null!==e&&i.length&&this._triggerArray.push(t)}this._initializeChildren(),this._config.parent||this._addAriaAndCollapsedClass(this._triggerArray,this._isShown()),this._config.toggle&&this.toggle()}static get Default(){return Ht}static get DefaultType(){return Wt}static get NAME(){return"collapse"}toggle(){this._isShown()?this.hide():this.show()}show(){if(this._isTransitioning||this._isShown())return;let t=[];if(this._config.parent&&(t=this._getFirstLevelChildren(".collapse.show, .collapse.collapsing").filter((t=>t!==this._element)).map((t=>Bt.getOrCreateInstance(t,{toggle:!1})))),t.length&&t[0]._isTransitioning)return;if(N.trigger(this._element,Lt).defaultPrevented)return;for(const e of t)e.hide();const e=this._getDimension();this._element.classList.remove(Pt),this._element.classList.add(jt),this._element.style[e]=0,this._addAriaAndCollapsedClass(this._triggerArray,!0),this._isTransitioning=!0;const i=`scroll${e[0].toUpperCase()+e.slice(1)}`;this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt,Nt),this._element.style[e]="",N.trigger(this._element,St)}),this._element,!0),this._element.style[e]=`${this._element[i]}px`}hide(){if(this._isTransitioning||!this._isShown())return;if(N.trigger(this._element,Dt).defaultPrevented)return;const t=this._getDimension();this._element.style[t]=`${this._element.getBoundingClientRect()[t]}px`,d(this._element),this._element.classList.add(jt),this._element.classList.remove(Pt,Nt);for(const t of this._triggerArray){const e=z.getElementFromSelector(t);e&&!this._isShown(e)&&this._addAriaAndCollapsedClass([t],!1)}this._isTransitioning=!0,this._element.style[t]="",this._queueCallback((()=>{this._isTransitioning=!1,this._element.classList.remove(jt),this._element.classList.add(Pt),N.trigger(this._element,$t)}),this._element,!0)}_isShown(t=this._element){return t.classList.contains(Nt)}_configAfterMerge(t){return t.toggle=Boolean(t.toggle),t.parent=r(t.parent),t}_getDimension(){return this._element.classList.contains("collapse-horizontal")?"width":"height"}_initializeChildren(){if(!this._config.parent)return;const t=this._getFirstLevelChildren(Ft);for(const e of t){const t=z.getElementFromSelector(e);t&&this._addAriaAndCollapsedClass([e],this._isShown(t))}}_getFirstLevelChildren(t){const e=z.find(Mt,this._config.parent);return z.find(t,this._config.parent).filter((t=>!e.includes(t)))}_addAriaAndCollapsedClass(t,e){if(t.length)for(const i of t)i.classList.toggle("collapsed",!e),i.setAttribute("aria-expanded",e)}static jQueryInterface(t){const e={};return"string"==typeof t&&/show|hide/.test(t)&&(e.toggle=!1),this.each((function(){const i=Bt.getOrCreateInstance(this,e);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t]()}}))}}N.on(document,It,Ft,(function(t){("A"===t.target.tagName||t.delegateTarget&&"A"===t.delegateTarget.tagName)&&t.preventDefault();for(const t of z.getMultipleElementsFromSelector(this))Bt.getOrCreateInstance(t,{toggle:!1}).toggle()})),m(Bt);var zt="top",Rt="bottom",qt="right",Vt="left",Kt="auto",Qt=[zt,Rt,qt,Vt],Xt="start",Yt="end",Ut="clippingParents",Gt="viewport",Jt="popper",Zt="reference",te=Qt.reduce((function(t,e){return t.concat([e+"-"+Xt,e+"-"+Yt])}),[]),ee=[].concat(Qt,[Kt]).reduce((function(t,e){return t.concat([e,e+"-"+Xt,e+"-"+Yt])}),[]),ie="beforeRead",ne="read",se="afterRead",oe="beforeMain",re="main",ae="afterMain",le="beforeWrite",ce="write",he="afterWrite",de=[ie,ne,se,oe,re,ae,le,ce,he];function ue(t){return t?(t.nodeName||"").toLowerCase():null}function fe(t){if(null==t)return window;if("[object Window]"!==t.toString()){var e=t.ownerDocument;return e&&e.defaultView||window}return t}function pe(t){return t instanceof fe(t).Element||t instanceof Element}function me(t){return t instanceof fe(t).HTMLElement||t instanceof HTMLElement}function ge(t){return"undefined"!=typeof ShadowRoot&&(t instanceof fe(t).ShadowRoot||t instanceof ShadowRoot)}const _e={name:"applyStyles",enabled:!0,phase:"write",fn:function(t){var e=t.state;Object.keys(e.elements).forEach((function(t){var i=e.styles[t]||{},n=e.attributes[t]||{},s=e.elements[t];me(s)&&ue(s)&&(Object.assign(s.style,i),Object.keys(n).forEach((function(t){var e=n[t];!1===e?s.removeAttribute(t):s.setAttribute(t,!0===e?"":e)})))}))},effect:function(t){var e=t.state,i={popper:{position:e.options.strategy,left:"0",top:"0",margin:"0"},arrow:{position:"absolute"},reference:{}};return Object.assign(e.elements.popper.style,i.popper),e.styles=i,e.elements.arrow&&Object.assign(e.elements.arrow.style,i.arrow),function(){Object.keys(e.elements).forEach((function(t){var n=e.elements[t],s=e.attributes[t]||{},o=Object.keys(e.styles.hasOwnProperty(t)?e.styles[t]:i[t]).reduce((function(t,e){return t[e]="",t}),{});me(n)&&ue(n)&&(Object.assign(n.style,o),Object.keys(s).forEach((function(t){n.removeAttribute(t)})))}))}},requires:["computeStyles"]};function be(t){return t.split("-")[0]}var ve=Math.max,ye=Math.min,we=Math.round;function Ae(){var t=navigator.userAgentData;return null!=t&&t.brands&&Array.isArray(t.brands)?t.brands.map((function(t){return t.brand+"/"+t.version})).join(" "):navigator.userAgent}function Ee(){return!/^((?!chrome|android).)*safari/i.test(Ae())}function Te(t,e,i){void 0===e&&(e=!1),void 0===i&&(i=!1);var n=t.getBoundingClientRect(),s=1,o=1;e&&me(t)&&(s=t.offsetWidth>0&&we(n.width)/t.offsetWidth||1,o=t.offsetHeight>0&&we(n.height)/t.offsetHeight||1);var r=(pe(t)?fe(t):window).visualViewport,a=!Ee()&&i,l=(n.left+(a&&r?r.offsetLeft:0))/s,c=(n.top+(a&&r?r.offsetTop:0))/o,h=n.width/s,d=n.height/o;return{width:h,height:d,top:c,right:l+h,bottom:c+d,left:l,x:l,y:c}}function Ce(t){var e=Te(t),i=t.offsetWidth,n=t.offsetHeight;return Math.abs(e.width-i)<=1&&(i=e.width),Math.abs(e.height-n)<=1&&(n=e.height),{x:t.offsetLeft,y:t.offsetTop,width:i,height:n}}function Oe(t,e){var i=e.getRootNode&&e.getRootNode();if(t.contains(e))return!0;if(i&&ge(i)){var n=e;do{if(n&&t.isSameNode(n))return!0;n=n.parentNode||n.host}while(n)}return!1}function xe(t){return fe(t).getComputedStyle(t)}function ke(t){return["table","td","th"].indexOf(ue(t))>=0}function Le(t){return((pe(t)?t.ownerDocument:t.document)||window.document).documentElement}function Se(t){return"html"===ue(t)?t:t.assignedSlot||t.parentNode||(ge(t)?t.host:null)||Le(t)}function De(t){return me(t)&&"fixed"!==xe(t).position?t.offsetParent:null}function $e(t){for(var e=fe(t),i=De(t);i&&ke(i)&&"static"===xe(i).position;)i=De(i);return i&&("html"===ue(i)||"body"===ue(i)&&"static"===xe(i).position)?e:i||function(t){var e=/firefox/i.test(Ae());if(/Trident/i.test(Ae())&&me(t)&&"fixed"===xe(t).position)return null;var i=Se(t);for(ge(i)&&(i=i.host);me(i)&&["html","body"].indexOf(ue(i))<0;){var n=xe(i);if("none"!==n.transform||"none"!==n.perspective||"paint"===n.contain||-1!==["transform","perspective"].indexOf(n.willChange)||e&&"filter"===n.willChange||e&&n.filter&&"none"!==n.filter)return i;i=i.parentNode}return null}(t)||e}function Ie(t){return["top","bottom"].indexOf(t)>=0?"x":"y"}function Ne(t,e,i){return ve(t,ye(e,i))}function Pe(t){return Object.assign({},{top:0,right:0,bottom:0,left:0},t)}function je(t,e){return e.reduce((function(e,i){return e[i]=t,e}),{})}const Me={name:"arrow",enabled:!0,phase:"main",fn:function(t){var e,i=t.state,n=t.name,s=t.options,o=i.elements.arrow,r=i.modifiersData.popperOffsets,a=be(i.placement),l=Ie(a),c=[Vt,qt].indexOf(a)>=0?"height":"width";if(o&&r){var h=function(t,e){return Pe("number"!=typeof(t="function"==typeof t?t(Object.assign({},e.rects,{placement:e.placement})):t)?t:je(t,Qt))}(s.padding,i),d=Ce(o),u="y"===l?zt:Vt,f="y"===l?Rt:qt,p=i.rects.reference[c]+i.rects.reference[l]-r[l]-i.rects.popper[c],m=r[l]-i.rects.reference[l],g=$e(o),_=g?"y"===l?g.clientHeight||0:g.clientWidth||0:0,b=p/2-m/2,v=h[u],y=_-d[c]-h[f],w=_/2-d[c]/2+b,A=Ne(v,w,y),E=l;i.modifiersData[n]=((e={})[E]=A,e.centerOffset=A-w,e)}},effect:function(t){var e=t.state,i=t.options.element,n=void 0===i?"[data-popper-arrow]":i;null!=n&&("string"!=typeof n||(n=e.elements.popper.querySelector(n)))&&Oe(e.elements.popper,n)&&(e.elements.arrow=n)},requires:["popperOffsets"],requiresIfExists:["preventOverflow"]};function Fe(t){return t.split("-")[1]}var He={top:"auto",right:"auto",bottom:"auto",left:"auto"};function We(t){var e,i=t.popper,n=t.popperRect,s=t.placement,o=t.variation,r=t.offsets,a=t.position,l=t.gpuAcceleration,c=t.adaptive,h=t.roundOffsets,d=t.isFixed,u=r.x,f=void 0===u?0:u,p=r.y,m=void 0===p?0:p,g="function"==typeof h?h({x:f,y:m}):{x:f,y:m};f=g.x,m=g.y;var _=r.hasOwnProperty("x"),b=r.hasOwnProperty("y"),v=Vt,y=zt,w=window;if(c){var A=$e(i),E="clientHeight",T="clientWidth";A===fe(i)&&"static"!==xe(A=Le(i)).position&&"absolute"===a&&(E="scrollHeight",T="scrollWidth"),(s===zt||(s===Vt||s===qt)&&o===Yt)&&(y=Rt,m-=(d&&A===w&&w.visualViewport?w.visualViewport.height:A[E])-n.height,m*=l?1:-1),s!==Vt&&(s!==zt&&s!==Rt||o!==Yt)||(v=qt,f-=(d&&A===w&&w.visualViewport?w.visualViewport.width:A[T])-n.width,f*=l?1:-1)}var C,O=Object.assign({position:a},c&&He),x=!0===h?function(t,e){var i=t.x,n=t.y,s=e.devicePixelRatio||1;return{x:we(i*s)/s||0,y:we(n*s)/s||0}}({x:f,y:m},fe(i)):{x:f,y:m};return f=x.x,m=x.y,l?Object.assign({},O,((C={})[y]=b?"0":"",C[v]=_?"0":"",C.transform=(w.devicePixelRatio||1)<=1?"translate("+f+"px, "+m+"px)":"translate3d("+f+"px, "+m+"px, 0)",C)):Object.assign({},O,((e={})[y]=b?m+"px":"",e[v]=_?f+"px":"",e.transform="",e))}const Be={name:"computeStyles",enabled:!0,phase:"beforeWrite",fn:function(t){var e=t.state,i=t.options,n=i.gpuAcceleration,s=void 0===n||n,o=i.adaptive,r=void 0===o||o,a=i.roundOffsets,l=void 0===a||a,c={placement:be(e.placement),variation:Fe(e.placement),popper:e.elements.popper,popperRect:e.rects.popper,gpuAcceleration:s,isFixed:"fixed"===e.options.strategy};null!=e.modifiersData.popperOffsets&&(e.styles.popper=Object.assign({},e.styles.popper,We(Object.assign({},c,{offsets:e.modifiersData.popperOffsets,position:e.options.strategy,adaptive:r,roundOffsets:l})))),null!=e.modifiersData.arrow&&(e.styles.arrow=Object.assign({},e.styles.arrow,We(Object.assign({},c,{offsets:e.modifiersData.arrow,position:"absolute",adaptive:!1,roundOffsets:l})))),e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-placement":e.placement})},data:{}};var ze={passive:!0};const Re={name:"eventListeners",enabled:!0,phase:"write",fn:function(){},effect:function(t){var e=t.state,i=t.instance,n=t.options,s=n.scroll,o=void 0===s||s,r=n.resize,a=void 0===r||r,l=fe(e.elements.popper),c=[].concat(e.scrollParents.reference,e.scrollParents.popper);return o&&c.forEach((function(t){t.addEventListener("scroll",i.update,ze)})),a&&l.addEventListener("resize",i.update,ze),function(){o&&c.forEach((function(t){t.removeEventListener("scroll",i.update,ze)})),a&&l.removeEventListener("resize",i.update,ze)}},data:{}};var qe={left:"right",right:"left",bottom:"top",top:"bottom"};function Ve(t){return t.replace(/left|right|bottom|top/g,(function(t){return qe[t]}))}var Ke={start:"end",end:"start"};function Qe(t){return t.replace(/start|end/g,(function(t){return Ke[t]}))}function Xe(t){var e=fe(t);return{scrollLeft:e.pageXOffset,scrollTop:e.pageYOffset}}function Ye(t){return Te(Le(t)).left+Xe(t).scrollLeft}function Ue(t){var e=xe(t),i=e.overflow,n=e.overflowX,s=e.overflowY;return/auto|scroll|overlay|hidden/.test(i+s+n)}function Ge(t){return["html","body","#document"].indexOf(ue(t))>=0?t.ownerDocument.body:me(t)&&Ue(t)?t:Ge(Se(t))}function Je(t,e){var i;void 0===e&&(e=[]);var n=Ge(t),s=n===(null==(i=t.ownerDocument)?void 0:i.body),o=fe(n),r=s?[o].concat(o.visualViewport||[],Ue(n)?n:[]):n,a=e.concat(r);return s?a:a.concat(Je(Se(r)))}function Ze(t){return Object.assign({},t,{left:t.x,top:t.y,right:t.x+t.width,bottom:t.y+t.height})}function ti(t,e,i){return e===Gt?Ze(function(t,e){var i=fe(t),n=Le(t),s=i.visualViewport,o=n.clientWidth,r=n.clientHeight,a=0,l=0;if(s){o=s.width,r=s.height;var c=Ee();(c||!c&&"fixed"===e)&&(a=s.offsetLeft,l=s.offsetTop)}return{width:o,height:r,x:a+Ye(t),y:l}}(t,i)):pe(e)?function(t,e){var i=Te(t,!1,"fixed"===e);return i.top=i.top+t.clientTop,i.left=i.left+t.clientLeft,i.bottom=i.top+t.clientHeight,i.right=i.left+t.clientWidth,i.width=t.clientWidth,i.height=t.clientHeight,i.x=i.left,i.y=i.top,i}(e,i):Ze(function(t){var e,i=Le(t),n=Xe(t),s=null==(e=t.ownerDocument)?void 0:e.body,o=ve(i.scrollWidth,i.clientWidth,s?s.scrollWidth:0,s?s.clientWidth:0),r=ve(i.scrollHeight,i.clientHeight,s?s.scrollHeight:0,s?s.clientHeight:0),a=-n.scrollLeft+Ye(t),l=-n.scrollTop;return"rtl"===xe(s||i).direction&&(a+=ve(i.clientWidth,s?s.clientWidth:0)-o),{width:o,height:r,x:a,y:l}}(Le(t)))}function ei(t){var e,i=t.reference,n=t.element,s=t.placement,o=s?be(s):null,r=s?Fe(s):null,a=i.x+i.width/2-n.width/2,l=i.y+i.height/2-n.height/2;switch(o){case zt:e={x:a,y:i.y-n.height};break;case Rt:e={x:a,y:i.y+i.height};break;case qt:e={x:i.x+i.width,y:l};break;case Vt:e={x:i.x-n.width,y:l};break;default:e={x:i.x,y:i.y}}var c=o?Ie(o):null;if(null!=c){var h="y"===c?"height":"width";switch(r){case Xt:e[c]=e[c]-(i[h]/2-n[h]/2);break;case Yt:e[c]=e[c]+(i[h]/2-n[h]/2)}}return e}function ii(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=void 0===n?t.placement:n,o=i.strategy,r=void 0===o?t.strategy:o,a=i.boundary,l=void 0===a?Ut:a,c=i.rootBoundary,h=void 0===c?Gt:c,d=i.elementContext,u=void 0===d?Jt:d,f=i.altBoundary,p=void 0!==f&&f,m=i.padding,g=void 0===m?0:m,_=Pe("number"!=typeof g?g:je(g,Qt)),b=u===Jt?Zt:Jt,v=t.rects.popper,y=t.elements[p?b:u],w=function(t,e,i,n){var s="clippingParents"===e?function(t){var e=Je(Se(t)),i=["absolute","fixed"].indexOf(xe(t).position)>=0&&me(t)?$e(t):t;return pe(i)?e.filter((function(t){return pe(t)&&Oe(t,i)&&"body"!==ue(t)})):[]}(t):[].concat(e),o=[].concat(s,[i]),r=o[0],a=o.reduce((function(e,i){var s=ti(t,i,n);return e.top=ve(s.top,e.top),e.right=ye(s.right,e.right),e.bottom=ye(s.bottom,e.bottom),e.left=ve(s.left,e.left),e}),ti(t,r,n));return a.width=a.right-a.left,a.height=a.bottom-a.top,a.x=a.left,a.y=a.top,a}(pe(y)?y:y.contextElement||Le(t.elements.popper),l,h,r),A=Te(t.elements.reference),E=ei({reference:A,element:v,strategy:"absolute",placement:s}),T=Ze(Object.assign({},v,E)),C=u===Jt?T:A,O={top:w.top-C.top+_.top,bottom:C.bottom-w.bottom+_.bottom,left:w.left-C.left+_.left,right:C.right-w.right+_.right},x=t.modifiersData.offset;if(u===Jt&&x){var k=x[s];Object.keys(O).forEach((function(t){var e=[qt,Rt].indexOf(t)>=0?1:-1,i=[zt,Rt].indexOf(t)>=0?"y":"x";O[t]+=k[i]*e}))}return O}function ni(t,e){void 0===e&&(e={});var i=e,n=i.placement,s=i.boundary,o=i.rootBoundary,r=i.padding,a=i.flipVariations,l=i.allowedAutoPlacements,c=void 0===l?ee:l,h=Fe(n),d=h?a?te:te.filter((function(t){return Fe(t)===h})):Qt,u=d.filter((function(t){return c.indexOf(t)>=0}));0===u.length&&(u=d);var f=u.reduce((function(e,i){return e[i]=ii(t,{placement:i,boundary:s,rootBoundary:o,padding:r})[be(i)],e}),{});return Object.keys(f).sort((function(t,e){return f[t]-f[e]}))}const si={name:"flip",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name;if(!e.modifiersData[n]._skip){for(var s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0===r||r,l=i.fallbackPlacements,c=i.padding,h=i.boundary,d=i.rootBoundary,u=i.altBoundary,f=i.flipVariations,p=void 0===f||f,m=i.allowedAutoPlacements,g=e.options.placement,_=be(g),b=l||(_!==g&&p?function(t){if(be(t)===Kt)return[];var e=Ve(t);return[Qe(t),e,Qe(e)]}(g):[Ve(g)]),v=[g].concat(b).reduce((function(t,i){return t.concat(be(i)===Kt?ni(e,{placement:i,boundary:h,rootBoundary:d,padding:c,flipVariations:p,allowedAutoPlacements:m}):i)}),[]),y=e.rects.reference,w=e.rects.popper,A=new Map,E=!0,T=v[0],C=0;C=0,S=L?"width":"height",D=ii(e,{placement:O,boundary:h,rootBoundary:d,altBoundary:u,padding:c}),$=L?k?qt:Vt:k?Rt:zt;y[S]>w[S]&&($=Ve($));var I=Ve($),N=[];if(o&&N.push(D[x]<=0),a&&N.push(D[$]<=0,D[I]<=0),N.every((function(t){return t}))){T=O,E=!1;break}A.set(O,N)}if(E)for(var P=function(t){var e=v.find((function(e){var i=A.get(e);if(i)return i.slice(0,t).every((function(t){return t}))}));if(e)return T=e,"break"},j=p?3:1;j>0&&"break"!==P(j);j--);e.placement!==T&&(e.modifiersData[n]._skip=!0,e.placement=T,e.reset=!0)}},requiresIfExists:["offset"],data:{_skip:!1}};function oi(t,e,i){return void 0===i&&(i={x:0,y:0}),{top:t.top-e.height-i.y,right:t.right-e.width+i.x,bottom:t.bottom-e.height+i.y,left:t.left-e.width-i.x}}function ri(t){return[zt,qt,Rt,Vt].some((function(e){return t[e]>=0}))}const ai={name:"hide",enabled:!0,phase:"main",requiresIfExists:["preventOverflow"],fn:function(t){var e=t.state,i=t.name,n=e.rects.reference,s=e.rects.popper,o=e.modifiersData.preventOverflow,r=ii(e,{elementContext:"reference"}),a=ii(e,{altBoundary:!0}),l=oi(r,n),c=oi(a,s,o),h=ri(l),d=ri(c);e.modifiersData[i]={referenceClippingOffsets:l,popperEscapeOffsets:c,isReferenceHidden:h,hasPopperEscaped:d},e.attributes.popper=Object.assign({},e.attributes.popper,{"data-popper-reference-hidden":h,"data-popper-escaped":d})}},li={name:"offset",enabled:!0,phase:"main",requires:["popperOffsets"],fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.offset,o=void 0===s?[0,0]:s,r=ee.reduce((function(t,i){return t[i]=function(t,e,i){var n=be(t),s=[Vt,zt].indexOf(n)>=0?-1:1,o="function"==typeof i?i(Object.assign({},e,{placement:t})):i,r=o[0],a=o[1];return r=r||0,a=(a||0)*s,[Vt,qt].indexOf(n)>=0?{x:a,y:r}:{x:r,y:a}}(i,e.rects,o),t}),{}),a=r[e.placement],l=a.x,c=a.y;null!=e.modifiersData.popperOffsets&&(e.modifiersData.popperOffsets.x+=l,e.modifiersData.popperOffsets.y+=c),e.modifiersData[n]=r}},ci={name:"popperOffsets",enabled:!0,phase:"read",fn:function(t){var e=t.state,i=t.name;e.modifiersData[i]=ei({reference:e.rects.reference,element:e.rects.popper,strategy:"absolute",placement:e.placement})},data:{}},hi={name:"preventOverflow",enabled:!0,phase:"main",fn:function(t){var e=t.state,i=t.options,n=t.name,s=i.mainAxis,o=void 0===s||s,r=i.altAxis,a=void 0!==r&&r,l=i.boundary,c=i.rootBoundary,h=i.altBoundary,d=i.padding,u=i.tether,f=void 0===u||u,p=i.tetherOffset,m=void 0===p?0:p,g=ii(e,{boundary:l,rootBoundary:c,padding:d,altBoundary:h}),_=be(e.placement),b=Fe(e.placement),v=!b,y=Ie(_),w="x"===y?"y":"x",A=e.modifiersData.popperOffsets,E=e.rects.reference,T=e.rects.popper,C="function"==typeof m?m(Object.assign({},e.rects,{placement:e.placement})):m,O="number"==typeof C?{mainAxis:C,altAxis:C}:Object.assign({mainAxis:0,altAxis:0},C),x=e.modifiersData.offset?e.modifiersData.offset[e.placement]:null,k={x:0,y:0};if(A){if(o){var L,S="y"===y?zt:Vt,D="y"===y?Rt:qt,$="y"===y?"height":"width",I=A[y],N=I+g[S],P=I-g[D],j=f?-T[$]/2:0,M=b===Xt?E[$]:T[$],F=b===Xt?-T[$]:-E[$],H=e.elements.arrow,W=f&&H?Ce(H):{width:0,height:0},B=e.modifiersData["arrow#persistent"]?e.modifiersData["arrow#persistent"].padding:{top:0,right:0,bottom:0,left:0},z=B[S],R=B[D],q=Ne(0,E[$],W[$]),V=v?E[$]/2-j-q-z-O.mainAxis:M-q-z-O.mainAxis,K=v?-E[$]/2+j+q+R+O.mainAxis:F+q+R+O.mainAxis,Q=e.elements.arrow&&$e(e.elements.arrow),X=Q?"y"===y?Q.clientTop||0:Q.clientLeft||0:0,Y=null!=(L=null==x?void 0:x[y])?L:0,U=I+K-Y,G=Ne(f?ye(N,I+V-Y-X):N,I,f?ve(P,U):P);A[y]=G,k[y]=G-I}if(a){var J,Z="x"===y?zt:Vt,tt="x"===y?Rt:qt,et=A[w],it="y"===w?"height":"width",nt=et+g[Z],st=et-g[tt],ot=-1!==[zt,Vt].indexOf(_),rt=null!=(J=null==x?void 0:x[w])?J:0,at=ot?nt:et-E[it]-T[it]-rt+O.altAxis,lt=ot?et+E[it]+T[it]-rt-O.altAxis:st,ct=f&&ot?function(t,e,i){var n=Ne(t,e,i);return n>i?i:n}(at,et,lt):Ne(f?at:nt,et,f?lt:st);A[w]=ct,k[w]=ct-et}e.modifiersData[n]=k}},requiresIfExists:["offset"]};function di(t,e,i){void 0===i&&(i=!1);var n,s,o=me(e),r=me(e)&&function(t){var e=t.getBoundingClientRect(),i=we(e.width)/t.offsetWidth||1,n=we(e.height)/t.offsetHeight||1;return 1!==i||1!==n}(e),a=Le(e),l=Te(t,r,i),c={scrollLeft:0,scrollTop:0},h={x:0,y:0};return(o||!o&&!i)&&(("body"!==ue(e)||Ue(a))&&(c=(n=e)!==fe(n)&&me(n)?{scrollLeft:(s=n).scrollLeft,scrollTop:s.scrollTop}:Xe(n)),me(e)?((h=Te(e,!0)).x+=e.clientLeft,h.y+=e.clientTop):a&&(h.x=Ye(a))),{x:l.left+c.scrollLeft-h.x,y:l.top+c.scrollTop-h.y,width:l.width,height:l.height}}function ui(t){var e=new Map,i=new Set,n=[];function s(t){i.add(t.name),[].concat(t.requires||[],t.requiresIfExists||[]).forEach((function(t){if(!i.has(t)){var n=e.get(t);n&&s(n)}})),n.push(t)}return t.forEach((function(t){e.set(t.name,t)})),t.forEach((function(t){i.has(t.name)||s(t)})),n}var fi={placement:"bottom",modifiers:[],strategy:"absolute"};function pi(){for(var t=arguments.length,e=new Array(t),i=0;iNumber.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_getPopperConfig(){const t={placement:this._getPlacement(),modifiers:[{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"offset",options:{offset:this._getOffset()}}]};return(this._inNavbar||"static"===this._config.display)&&(F.setDataAttribute(this._menu,"popper","static"),t.modifiers=[{name:"applyStyles",enabled:!1}]),{...t,...g(this._config.popperConfig,[t])}}_selectMenuItem({key:t,target:e}){const i=z.find(".dropdown-menu .dropdown-item:not(.disabled):not(:disabled)",this._menu).filter((t=>a(t)));i.length&&b(i,e,t===Ti,!i.includes(e)).focus()}static jQueryInterface(t){return this.each((function(){const e=qi.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}static clearMenus(t){if(2===t.button||"keyup"===t.type&&"Tab"!==t.key)return;const e=z.find(Ni);for(const i of e){const e=qi.getInstance(i);if(!e||!1===e._config.autoClose)continue;const n=t.composedPath(),s=n.includes(e._menu);if(n.includes(e._element)||"inside"===e._config.autoClose&&!s||"outside"===e._config.autoClose&&s)continue;if(e._menu.contains(t.target)&&("keyup"===t.type&&"Tab"===t.key||/input|select|option|textarea|form/i.test(t.target.tagName)))continue;const o={relatedTarget:e._element};"click"===t.type&&(o.clickEvent=t),e._completeHide(o)}}static dataApiKeydownHandler(t){const e=/input|textarea/i.test(t.target.tagName),i="Escape"===t.key,n=[Ei,Ti].includes(t.key);if(!n&&!i)return;if(e&&!i)return;t.preventDefault();const s=this.matches(Ii)?this:z.prev(this,Ii)[0]||z.next(this,Ii)[0]||z.findOne(Ii,t.delegateTarget.parentNode),o=qi.getOrCreateInstance(s);if(n)return t.stopPropagation(),o.show(),void o._selectMenuItem(t);o._isShown()&&(t.stopPropagation(),o.hide(),s.focus())}}N.on(document,Si,Ii,qi.dataApiKeydownHandler),N.on(document,Si,Pi,qi.dataApiKeydownHandler),N.on(document,Li,qi.clearMenus),N.on(document,Di,qi.clearMenus),N.on(document,Li,Ii,(function(t){t.preventDefault(),qi.getOrCreateInstance(this).toggle()})),m(qi);const Vi="backdrop",Ki="show",Qi=`mousedown.bs.${Vi}`,Xi={className:"modal-backdrop",clickCallback:null,isAnimated:!1,isVisible:!0,rootElement:"body"},Yi={className:"string",clickCallback:"(function|null)",isAnimated:"boolean",isVisible:"boolean",rootElement:"(element|string)"};class Ui extends H{constructor(t){super(),this._config=this._getConfig(t),this._isAppended=!1,this._element=null}static get Default(){return Xi}static get DefaultType(){return Yi}static get NAME(){return Vi}show(t){if(!this._config.isVisible)return void g(t);this._append();const e=this._getElement();this._config.isAnimated&&d(e),e.classList.add(Ki),this._emulateAnimation((()=>{g(t)}))}hide(t){this._config.isVisible?(this._getElement().classList.remove(Ki),this._emulateAnimation((()=>{this.dispose(),g(t)}))):g(t)}dispose(){this._isAppended&&(N.off(this._element,Qi),this._element.remove(),this._isAppended=!1)}_getElement(){if(!this._element){const t=document.createElement("div");t.className=this._config.className,this._config.isAnimated&&t.classList.add("fade"),this._element=t}return this._element}_configAfterMerge(t){return t.rootElement=r(t.rootElement),t}_append(){if(this._isAppended)return;const t=this._getElement();this._config.rootElement.append(t),N.on(t,Qi,(()=>{g(this._config.clickCallback)})),this._isAppended=!0}_emulateAnimation(t){_(t,this._getElement(),this._config.isAnimated)}}const Gi=".bs.focustrap",Ji=`focusin${Gi}`,Zi=`keydown.tab${Gi}`,tn="backward",en={autofocus:!0,trapElement:null},nn={autofocus:"boolean",trapElement:"element"};class sn extends H{constructor(t){super(),this._config=this._getConfig(t),this._isActive=!1,this._lastTabNavDirection=null}static get Default(){return en}static get DefaultType(){return nn}static get NAME(){return"focustrap"}activate(){this._isActive||(this._config.autofocus&&this._config.trapElement.focus(),N.off(document,Gi),N.on(document,Ji,(t=>this._handleFocusin(t))),N.on(document,Zi,(t=>this._handleKeydown(t))),this._isActive=!0)}deactivate(){this._isActive&&(this._isActive=!1,N.off(document,Gi))}_handleFocusin(t){const{trapElement:e}=this._config;if(t.target===document||t.target===e||e.contains(t.target))return;const i=z.focusableChildren(e);0===i.length?e.focus():this._lastTabNavDirection===tn?i[i.length-1].focus():i[0].focus()}_handleKeydown(t){"Tab"===t.key&&(this._lastTabNavDirection=t.shiftKey?tn:"forward")}}const on=".fixed-top, .fixed-bottom, .is-fixed, .sticky-top",rn=".sticky-top",an="padding-right",ln="margin-right";class cn{constructor(){this._element=document.body}getWidth(){const t=document.documentElement.clientWidth;return Math.abs(window.innerWidth-t)}hide(){const t=this.getWidth();this._disableOverFlow(),this._setElementAttributes(this._element,an,(e=>e+t)),this._setElementAttributes(on,an,(e=>e+t)),this._setElementAttributes(rn,ln,(e=>e-t))}reset(){this._resetElementAttributes(this._element,"overflow"),this._resetElementAttributes(this._element,an),this._resetElementAttributes(on,an),this._resetElementAttributes(rn,ln)}isOverflowing(){return this.getWidth()>0}_disableOverFlow(){this._saveInitialAttribute(this._element,"overflow"),this._element.style.overflow="hidden"}_setElementAttributes(t,e,i){const n=this.getWidth();this._applyManipulationCallback(t,(t=>{if(t!==this._element&&window.innerWidth>t.clientWidth+n)return;this._saveInitialAttribute(t,e);const s=window.getComputedStyle(t).getPropertyValue(e);t.style.setProperty(e,`${i(Number.parseFloat(s))}px`)}))}_saveInitialAttribute(t,e){const i=t.style.getPropertyValue(e);i&&F.setDataAttribute(t,e,i)}_resetElementAttributes(t,e){this._applyManipulationCallback(t,(t=>{const i=F.getDataAttribute(t,e);null!==i?(F.removeDataAttribute(t,e),t.style.setProperty(e,i)):t.style.removeProperty(e)}))}_applyManipulationCallback(t,e){if(o(t))e(t);else for(const i of z.find(t,this._element))e(i)}}const hn=".bs.modal",dn=`hide${hn}`,un=`hidePrevented${hn}`,fn=`hidden${hn}`,pn=`show${hn}`,mn=`shown${hn}`,gn=`resize${hn}`,_n=`click.dismiss${hn}`,bn=`mousedown.dismiss${hn}`,vn=`keydown.dismiss${hn}`,yn=`click${hn}.data-api`,wn="modal-open",An="show",En="modal-static",Tn={backdrop:!0,focus:!0,keyboard:!0},Cn={backdrop:"(boolean|string)",focus:"boolean",keyboard:"boolean"};class On extends W{constructor(t,e){super(t,e),this._dialog=z.findOne(".modal-dialog",this._element),this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._isShown=!1,this._isTransitioning=!1,this._scrollBar=new cn,this._addEventListeners()}static get Default(){return Tn}static get DefaultType(){return Cn}static get NAME(){return"modal"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||this._isTransitioning||N.trigger(this._element,pn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._isTransitioning=!0,this._scrollBar.hide(),document.body.classList.add(wn),this._adjustDialog(),this._backdrop.show((()=>this._showElement(t))))}hide(){this._isShown&&!this._isTransitioning&&(N.trigger(this._element,dn).defaultPrevented||(this._isShown=!1,this._isTransitioning=!0,this._focustrap.deactivate(),this._element.classList.remove(An),this._queueCallback((()=>this._hideModal()),this._element,this._isAnimated())))}dispose(){N.off(window,hn),N.off(this._dialog,hn),this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}handleUpdate(){this._adjustDialog()}_initializeBackDrop(){return new Ui({isVisible:Boolean(this._config.backdrop),isAnimated:this._isAnimated()})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_showElement(t){document.body.contains(this._element)||document.body.append(this._element),this._element.style.display="block",this._element.removeAttribute("aria-hidden"),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.scrollTop=0;const e=z.findOne(".modal-body",this._dialog);e&&(e.scrollTop=0),d(this._element),this._element.classList.add(An),this._queueCallback((()=>{this._config.focus&&this._focustrap.activate(),this._isTransitioning=!1,N.trigger(this._element,mn,{relatedTarget:t})}),this._dialog,this._isAnimated())}_addEventListeners(){N.on(this._element,vn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():this._triggerBackdropTransition())})),N.on(window,gn,(()=>{this._isShown&&!this._isTransitioning&&this._adjustDialog()})),N.on(this._element,bn,(t=>{N.one(this._element,_n,(e=>{this._element===t.target&&this._element===e.target&&("static"!==this._config.backdrop?this._config.backdrop&&this.hide():this._triggerBackdropTransition())}))}))}_hideModal(){this._element.style.display="none",this._element.setAttribute("aria-hidden",!0),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._isTransitioning=!1,this._backdrop.hide((()=>{document.body.classList.remove(wn),this._resetAdjustments(),this._scrollBar.reset(),N.trigger(this._element,fn)}))}_isAnimated(){return this._element.classList.contains("fade")}_triggerBackdropTransition(){if(N.trigger(this._element,un).defaultPrevented)return;const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._element.style.overflowY;"hidden"===e||this._element.classList.contains(En)||(t||(this._element.style.overflowY="hidden"),this._element.classList.add(En),this._queueCallback((()=>{this._element.classList.remove(En),this._queueCallback((()=>{this._element.style.overflowY=e}),this._dialog)}),this._dialog),this._element.focus())}_adjustDialog(){const t=this._element.scrollHeight>document.documentElement.clientHeight,e=this._scrollBar.getWidth(),i=e>0;if(i&&!t){const t=p()?"paddingLeft":"paddingRight";this._element.style[t]=`${e}px`}if(!i&&t){const t=p()?"paddingRight":"paddingLeft";this._element.style[t]=`${e}px`}}_resetAdjustments(){this._element.style.paddingLeft="",this._element.style.paddingRight=""}static jQueryInterface(t,e){return this.each((function(){const i=On.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===i[t])throw new TypeError(`No method named "${t}"`);i[t](e)}}))}}N.on(document,yn,'[data-bs-toggle="modal"]',(function(t){const e=z.getElementFromSelector(this);["A","AREA"].includes(this.tagName)&&t.preventDefault(),N.one(e,pn,(t=>{t.defaultPrevented||N.one(e,fn,(()=>{a(this)&&this.focus()}))}));const i=z.findOne(".modal.show");i&&On.getInstance(i).hide(),On.getOrCreateInstance(e).toggle(this)})),R(On),m(On);const xn=".bs.offcanvas",kn=".data-api",Ln=`load${xn}${kn}`,Sn="show",Dn="showing",$n="hiding",In=".offcanvas.show",Nn=`show${xn}`,Pn=`shown${xn}`,jn=`hide${xn}`,Mn=`hidePrevented${xn}`,Fn=`hidden${xn}`,Hn=`resize${xn}`,Wn=`click${xn}${kn}`,Bn=`keydown.dismiss${xn}`,zn={backdrop:!0,keyboard:!0,scroll:!1},Rn={backdrop:"(boolean|string)",keyboard:"boolean",scroll:"boolean"};class qn extends W{constructor(t,e){super(t,e),this._isShown=!1,this._backdrop=this._initializeBackDrop(),this._focustrap=this._initializeFocusTrap(),this._addEventListeners()}static get Default(){return zn}static get DefaultType(){return Rn}static get NAME(){return"offcanvas"}toggle(t){return this._isShown?this.hide():this.show(t)}show(t){this._isShown||N.trigger(this._element,Nn,{relatedTarget:t}).defaultPrevented||(this._isShown=!0,this._backdrop.show(),this._config.scroll||(new cn).hide(),this._element.setAttribute("aria-modal",!0),this._element.setAttribute("role","dialog"),this._element.classList.add(Dn),this._queueCallback((()=>{this._config.scroll&&!this._config.backdrop||this._focustrap.activate(),this._element.classList.add(Sn),this._element.classList.remove(Dn),N.trigger(this._element,Pn,{relatedTarget:t})}),this._element,!0))}hide(){this._isShown&&(N.trigger(this._element,jn).defaultPrevented||(this._focustrap.deactivate(),this._element.blur(),this._isShown=!1,this._element.classList.add($n),this._backdrop.hide(),this._queueCallback((()=>{this._element.classList.remove(Sn,$n),this._element.removeAttribute("aria-modal"),this._element.removeAttribute("role"),this._config.scroll||(new cn).reset(),N.trigger(this._element,Fn)}),this._element,!0)))}dispose(){this._backdrop.dispose(),this._focustrap.deactivate(),super.dispose()}_initializeBackDrop(){const t=Boolean(this._config.backdrop);return new Ui({className:"offcanvas-backdrop",isVisible:t,isAnimated:!0,rootElement:this._element.parentNode,clickCallback:t?()=>{"static"!==this._config.backdrop?this.hide():N.trigger(this._element,Mn)}:null})}_initializeFocusTrap(){return new sn({trapElement:this._element})}_addEventListeners(){N.on(this._element,Bn,(t=>{"Escape"===t.key&&(this._config.keyboard?this.hide():N.trigger(this._element,Mn))}))}static jQueryInterface(t){return this.each((function(){const e=qn.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}N.on(document,Wn,'[data-bs-toggle="offcanvas"]',(function(t){const e=z.getElementFromSelector(this);if(["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this))return;N.one(e,Fn,(()=>{a(this)&&this.focus()}));const i=z.findOne(In);i&&i!==e&&qn.getInstance(i).hide(),qn.getOrCreateInstance(e).toggle(this)})),N.on(window,Ln,(()=>{for(const t of z.find(In))qn.getOrCreateInstance(t).show()})),N.on(window,Hn,(()=>{for(const t of z.find("[aria-modal][class*=show][class*=offcanvas-]"))"fixed"!==getComputedStyle(t).position&&qn.getOrCreateInstance(t).hide()})),R(qn),m(qn);const Vn={"*":["class","dir","id","lang","role",/^aria-[\w-]*$/i],a:["target","href","title","rel"],area:[],b:[],br:[],col:[],code:[],dd:[],div:[],dl:[],dt:[],em:[],hr:[],h1:[],h2:[],h3:[],h4:[],h5:[],h6:[],i:[],img:["src","srcset","alt","title","width","height"],li:[],ol:[],p:[],pre:[],s:[],small:[],span:[],sub:[],sup:[],strong:[],u:[],ul:[]},Kn=new Set(["background","cite","href","itemtype","longdesc","poster","src","xlink:href"]),Qn=/^(?!javascript:)(?:[a-z0-9+.-]+:|[^&:/?#]*(?:[/?#]|$))/i,Xn=(t,e)=>{const i=t.nodeName.toLowerCase();return e.includes(i)?!Kn.has(i)||Boolean(Qn.test(t.nodeValue)):e.filter((t=>t instanceof RegExp)).some((t=>t.test(i)))},Yn={allowList:Vn,content:{},extraClass:"",html:!1,sanitize:!0,sanitizeFn:null,template:"
"},Un={allowList:"object",content:"object",extraClass:"(string|function)",html:"boolean",sanitize:"boolean",sanitizeFn:"(null|function)",template:"string"},Gn={entry:"(string|element|function|null)",selector:"(string|element)"};class Jn extends H{constructor(t){super(),this._config=this._getConfig(t)}static get Default(){return Yn}static get DefaultType(){return Un}static get NAME(){return"TemplateFactory"}getContent(){return Object.values(this._config.content).map((t=>this._resolvePossibleFunction(t))).filter(Boolean)}hasContent(){return this.getContent().length>0}changeContent(t){return this._checkContent(t),this._config.content={...this._config.content,...t},this}toHtml(){const t=document.createElement("div");t.innerHTML=this._maybeSanitize(this._config.template);for(const[e,i]of Object.entries(this._config.content))this._setContent(t,i,e);const e=t.children[0],i=this._resolvePossibleFunction(this._config.extraClass);return i&&e.classList.add(...i.split(" ")),e}_typeCheckConfig(t){super._typeCheckConfig(t),this._checkContent(t.content)}_checkContent(t){for(const[e,i]of Object.entries(t))super._typeCheckConfig({selector:e,entry:i},Gn)}_setContent(t,e,i){const n=z.findOne(i,t);n&&((e=this._resolvePossibleFunction(e))?o(e)?this._putElementInTemplate(r(e),n):this._config.html?n.innerHTML=this._maybeSanitize(e):n.textContent=e:n.remove())}_maybeSanitize(t){return this._config.sanitize?function(t,e,i){if(!t.length)return t;if(i&&"function"==typeof i)return i(t);const n=(new window.DOMParser).parseFromString(t,"text/html"),s=[].concat(...n.body.querySelectorAll("*"));for(const t of s){const i=t.nodeName.toLowerCase();if(!Object.keys(e).includes(i)){t.remove();continue}const n=[].concat(...t.attributes),s=[].concat(e["*"]||[],e[i]||[]);for(const e of n)Xn(e,s)||t.removeAttribute(e.nodeName)}return n.body.innerHTML}(t,this._config.allowList,this._config.sanitizeFn):t}_resolvePossibleFunction(t){return g(t,[this])}_putElementInTemplate(t,e){if(this._config.html)return e.innerHTML="",void e.append(t);e.textContent=t.textContent}}const Zn=new Set(["sanitize","allowList","sanitizeFn"]),ts="fade",es="show",is=".modal",ns="hide.bs.modal",ss="hover",os="focus",rs={AUTO:"auto",TOP:"top",RIGHT:p()?"left":"right",BOTTOM:"bottom",LEFT:p()?"right":"left"},as={allowList:Vn,animation:!0,boundary:"clippingParents",container:!1,customClass:"",delay:0,fallbackPlacements:["top","right","bottom","left"],html:!1,offset:[0,6],placement:"top",popperConfig:null,sanitize:!0,sanitizeFn:null,selector:!1,template:'',title:"",trigger:"hover focus"},ls={allowList:"object",animation:"boolean",boundary:"(string|element)",container:"(string|element|boolean)",customClass:"(string|function)",delay:"(number|object)",fallbackPlacements:"array",html:"boolean",offset:"(array|string|function)",placement:"(string|function)",popperConfig:"(null|object|function)",sanitize:"boolean",sanitizeFn:"(null|function)",selector:"(string|boolean)",template:"string",title:"(string|element|function)",trigger:"string"};class cs extends W{constructor(t,e){if(void 0===vi)throw new TypeError("Bootstrap's tooltips require Popper (https://popper.js.org)");super(t,e),this._isEnabled=!0,this._timeout=0,this._isHovered=null,this._activeTrigger={},this._popper=null,this._templateFactory=null,this._newContent=null,this.tip=null,this._setListeners(),this._config.selector||this._fixTitle()}static get Default(){return as}static get DefaultType(){return ls}static get NAME(){return"tooltip"}enable(){this._isEnabled=!0}disable(){this._isEnabled=!1}toggleEnabled(){this._isEnabled=!this._isEnabled}toggle(){this._isEnabled&&(this._activeTrigger.click=!this._activeTrigger.click,this._isShown()?this._leave():this._enter())}dispose(){clearTimeout(this._timeout),N.off(this._element.closest(is),ns,this._hideModalHandler),this._element.getAttribute("data-bs-original-title")&&this._element.setAttribute("title",this._element.getAttribute("data-bs-original-title")),this._disposePopper(),super.dispose()}show(){if("none"===this._element.style.display)throw new Error("Please use show on visible elements");if(!this._isWithContent()||!this._isEnabled)return;const t=N.trigger(this._element,this.constructor.eventName("show")),e=(c(this._element)||this._element.ownerDocument.documentElement).contains(this._element);if(t.defaultPrevented||!e)return;this._disposePopper();const i=this._getTipElement();this._element.setAttribute("aria-describedby",i.getAttribute("id"));const{container:n}=this._config;if(this._element.ownerDocument.documentElement.contains(this.tip)||(n.append(i),N.trigger(this._element,this.constructor.eventName("inserted"))),this._popper=this._createPopper(i),i.classList.add(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.on(t,"mouseover",h);this._queueCallback((()=>{N.trigger(this._element,this.constructor.eventName("shown")),!1===this._isHovered&&this._leave(),this._isHovered=!1}),this.tip,this._isAnimated())}hide(){if(this._isShown()&&!N.trigger(this._element,this.constructor.eventName("hide")).defaultPrevented){if(this._getTipElement().classList.remove(es),"ontouchstart"in document.documentElement)for(const t of[].concat(...document.body.children))N.off(t,"mouseover",h);this._activeTrigger.click=!1,this._activeTrigger[os]=!1,this._activeTrigger[ss]=!1,this._isHovered=null,this._queueCallback((()=>{this._isWithActiveTrigger()||(this._isHovered||this._disposePopper(),this._element.removeAttribute("aria-describedby"),N.trigger(this._element,this.constructor.eventName("hidden")))}),this.tip,this._isAnimated())}}update(){this._popper&&this._popper.update()}_isWithContent(){return Boolean(this._getTitle())}_getTipElement(){return this.tip||(this.tip=this._createTipElement(this._newContent||this._getContentForTemplate())),this.tip}_createTipElement(t){const e=this._getTemplateFactory(t).toHtml();if(!e)return null;e.classList.remove(ts,es),e.classList.add(`bs-${this.constructor.NAME}-auto`);const i=(t=>{do{t+=Math.floor(1e6*Math.random())}while(document.getElementById(t));return t})(this.constructor.NAME).toString();return e.setAttribute("id",i),this._isAnimated()&&e.classList.add(ts),e}setContent(t){this._newContent=t,this._isShown()&&(this._disposePopper(),this.show())}_getTemplateFactory(t){return this._templateFactory?this._templateFactory.changeContent(t):this._templateFactory=new Jn({...this._config,content:t,extraClass:this._resolvePossibleFunction(this._config.customClass)}),this._templateFactory}_getContentForTemplate(){return{".tooltip-inner":this._getTitle()}}_getTitle(){return this._resolvePossibleFunction(this._config.title)||this._element.getAttribute("data-bs-original-title")}_initializeOnDelegatedTarget(t){return this.constructor.getOrCreateInstance(t.delegateTarget,this._getDelegateConfig())}_isAnimated(){return this._config.animation||this.tip&&this.tip.classList.contains(ts)}_isShown(){return this.tip&&this.tip.classList.contains(es)}_createPopper(t){const e=g(this._config.placement,[this,t,this._element]),i=rs[e.toUpperCase()];return bi(this._element,t,this._getPopperConfig(i))}_getOffset(){const{offset:t}=this._config;return"string"==typeof t?t.split(",").map((t=>Number.parseInt(t,10))):"function"==typeof t?e=>t(e,this._element):t}_resolvePossibleFunction(t){return g(t,[this._element])}_getPopperConfig(t){const e={placement:t,modifiers:[{name:"flip",options:{fallbackPlacements:this._config.fallbackPlacements}},{name:"offset",options:{offset:this._getOffset()}},{name:"preventOverflow",options:{boundary:this._config.boundary}},{name:"arrow",options:{element:`.${this.constructor.NAME}-arrow`}},{name:"preSetPlacement",enabled:!0,phase:"beforeMain",fn:t=>{this._getTipElement().setAttribute("data-popper-placement",t.state.placement)}}]};return{...e,...g(this._config.popperConfig,[e])}}_setListeners(){const t=this._config.trigger.split(" ");for(const e of t)if("click"===e)N.on(this._element,this.constructor.eventName("click"),this._config.selector,(t=>{this._initializeOnDelegatedTarget(t).toggle()}));else if("manual"!==e){const t=e===ss?this.constructor.eventName("mouseenter"):this.constructor.eventName("focusin"),i=e===ss?this.constructor.eventName("mouseleave"):this.constructor.eventName("focusout");N.on(this._element,t,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusin"===t.type?os:ss]=!0,e._enter()})),N.on(this._element,i,this._config.selector,(t=>{const e=this._initializeOnDelegatedTarget(t);e._activeTrigger["focusout"===t.type?os:ss]=e._element.contains(t.relatedTarget),e._leave()}))}this._hideModalHandler=()=>{this._element&&this.hide()},N.on(this._element.closest(is),ns,this._hideModalHandler)}_fixTitle(){const t=this._element.getAttribute("title");t&&(this._element.getAttribute("aria-label")||this._element.textContent.trim()||this._element.setAttribute("aria-label",t),this._element.setAttribute("data-bs-original-title",t),this._element.removeAttribute("title"))}_enter(){this._isShown()||this._isHovered?this._isHovered=!0:(this._isHovered=!0,this._setTimeout((()=>{this._isHovered&&this.show()}),this._config.delay.show))}_leave(){this._isWithActiveTrigger()||(this._isHovered=!1,this._setTimeout((()=>{this._isHovered||this.hide()}),this._config.delay.hide))}_setTimeout(t,e){clearTimeout(this._timeout),this._timeout=setTimeout(t,e)}_isWithActiveTrigger(){return Object.values(this._activeTrigger).includes(!0)}_getConfig(t){const e=F.getDataAttributes(this._element);for(const t of Object.keys(e))Zn.has(t)&&delete e[t];return t={...e,..."object"==typeof t&&t?t:{}},t=this._mergeConfigObj(t),t=this._configAfterMerge(t),this._typeCheckConfig(t),t}_configAfterMerge(t){return t.container=!1===t.container?document.body:r(t.container),"number"==typeof t.delay&&(t.delay={show:t.delay,hide:t.delay}),"number"==typeof t.title&&(t.title=t.title.toString()),"number"==typeof t.content&&(t.content=t.content.toString()),t}_getDelegateConfig(){const t={};for(const[e,i]of Object.entries(this._config))this.constructor.Default[e]!==i&&(t[e]=i);return t.selector=!1,t.trigger="manual",t}_disposePopper(){this._popper&&(this._popper.destroy(),this._popper=null),this.tip&&(this.tip.remove(),this.tip=null)}static jQueryInterface(t){return this.each((function(){const e=cs.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(cs);const hs={...cs.Default,content:"",offset:[0,8],placement:"right",template:'',trigger:"click"},ds={...cs.DefaultType,content:"(null|string|element|function)"};class us extends cs{static get Default(){return hs}static get DefaultType(){return ds}static get NAME(){return"popover"}_isWithContent(){return this._getTitle()||this._getContent()}_getContentForTemplate(){return{".popover-header":this._getTitle(),".popover-body":this._getContent()}}_getContent(){return this._resolvePossibleFunction(this._config.content)}static jQueryInterface(t){return this.each((function(){const e=us.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t]()}}))}}m(us);const fs=".bs.scrollspy",ps=`activate${fs}`,ms=`click${fs}`,gs=`load${fs}.data-api`,_s="active",bs="[href]",vs=".nav-link",ys=`${vs}, .nav-item > ${vs}, .list-group-item`,ws={offset:null,rootMargin:"0px 0px -25%",smoothScroll:!1,target:null,threshold:[.1,.5,1]},As={offset:"(number|null)",rootMargin:"string",smoothScroll:"boolean",target:"element",threshold:"array"};class Es extends W{constructor(t,e){super(t,e),this._targetLinks=new Map,this._observableSections=new Map,this._rootElement="visible"===getComputedStyle(this._element).overflowY?null:this._element,this._activeTarget=null,this._observer=null,this._previousScrollData={visibleEntryTop:0,parentScrollTop:0},this.refresh()}static get Default(){return ws}static get DefaultType(){return As}static get NAME(){return"scrollspy"}refresh(){this._initializeTargetsAndObservables(),this._maybeEnableSmoothScroll(),this._observer?this._observer.disconnect():this._observer=this._getNewObserver();for(const t of this._observableSections.values())this._observer.observe(t)}dispose(){this._observer.disconnect(),super.dispose()}_configAfterMerge(t){return t.target=r(t.target)||document.body,t.rootMargin=t.offset?`${t.offset}px 0px -30%`:t.rootMargin,"string"==typeof t.threshold&&(t.threshold=t.threshold.split(",").map((t=>Number.parseFloat(t)))),t}_maybeEnableSmoothScroll(){this._config.smoothScroll&&(N.off(this._config.target,ms),N.on(this._config.target,ms,bs,(t=>{const e=this._observableSections.get(t.target.hash);if(e){t.preventDefault();const i=this._rootElement||window,n=e.offsetTop-this._element.offsetTop;if(i.scrollTo)return void i.scrollTo({top:n,behavior:"smooth"});i.scrollTop=n}})))}_getNewObserver(){const t={root:this._rootElement,threshold:this._config.threshold,rootMargin:this._config.rootMargin};return new IntersectionObserver((t=>this._observerCallback(t)),t)}_observerCallback(t){const e=t=>this._targetLinks.get(`#${t.target.id}`),i=t=>{this._previousScrollData.visibleEntryTop=t.target.offsetTop,this._process(e(t))},n=(this._rootElement||document.documentElement).scrollTop,s=n>=this._previousScrollData.parentScrollTop;this._previousScrollData.parentScrollTop=n;for(const o of t){if(!o.isIntersecting){this._activeTarget=null,this._clearActiveClass(e(o));continue}const t=o.target.offsetTop>=this._previousScrollData.visibleEntryTop;if(s&&t){if(i(o),!n)return}else s||t||i(o)}}_initializeTargetsAndObservables(){this._targetLinks=new Map,this._observableSections=new Map;const t=z.find(bs,this._config.target);for(const e of t){if(!e.hash||l(e))continue;const t=z.findOne(decodeURI(e.hash),this._element);a(t)&&(this._targetLinks.set(decodeURI(e.hash),e),this._observableSections.set(e.hash,t))}}_process(t){this._activeTarget!==t&&(this._clearActiveClass(this._config.target),this._activeTarget=t,t.classList.add(_s),this._activateParents(t),N.trigger(this._element,ps,{relatedTarget:t}))}_activateParents(t){if(t.classList.contains("dropdown-item"))z.findOne(".dropdown-toggle",t.closest(".dropdown")).classList.add(_s);else for(const e of z.parents(t,".nav, .list-group"))for(const t of z.prev(e,ys))t.classList.add(_s)}_clearActiveClass(t){t.classList.remove(_s);const e=z.find(`${bs}.${_s}`,t);for(const t of e)t.classList.remove(_s)}static jQueryInterface(t){return this.each((function(){const e=Es.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(window,gs,(()=>{for(const t of z.find('[data-bs-spy="scroll"]'))Es.getOrCreateInstance(t)})),m(Es);const Ts=".bs.tab",Cs=`hide${Ts}`,Os=`hidden${Ts}`,xs=`show${Ts}`,ks=`shown${Ts}`,Ls=`click${Ts}`,Ss=`keydown${Ts}`,Ds=`load${Ts}`,$s="ArrowLeft",Is="ArrowRight",Ns="ArrowUp",Ps="ArrowDown",js="Home",Ms="End",Fs="active",Hs="fade",Ws="show",Bs=".dropdown-toggle",zs=`:not(${Bs})`,Rs='[data-bs-toggle="tab"], [data-bs-toggle="pill"], [data-bs-toggle="list"]',qs=`.nav-link${zs}, .list-group-item${zs}, [role="tab"]${zs}, ${Rs}`,Vs=`.${Fs}[data-bs-toggle="tab"], .${Fs}[data-bs-toggle="pill"], .${Fs}[data-bs-toggle="list"]`;class Ks extends W{constructor(t){super(t),this._parent=this._element.closest('.list-group, .nav, [role="tablist"]'),this._parent&&(this._setInitialAttributes(this._parent,this._getChildren()),N.on(this._element,Ss,(t=>this._keydown(t))))}static get NAME(){return"tab"}show(){const t=this._element;if(this._elemIsActive(t))return;const e=this._getActiveElem(),i=e?N.trigger(e,Cs,{relatedTarget:t}):null;N.trigger(t,xs,{relatedTarget:e}).defaultPrevented||i&&i.defaultPrevented||(this._deactivate(e,t),this._activate(t,e))}_activate(t,e){t&&(t.classList.add(Fs),this._activate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.removeAttribute("tabindex"),t.setAttribute("aria-selected",!0),this._toggleDropDown(t,!0),N.trigger(t,ks,{relatedTarget:e})):t.classList.add(Ws)}),t,t.classList.contains(Hs)))}_deactivate(t,e){t&&(t.classList.remove(Fs),t.blur(),this._deactivate(z.getElementFromSelector(t)),this._queueCallback((()=>{"tab"===t.getAttribute("role")?(t.setAttribute("aria-selected",!1),t.setAttribute("tabindex","-1"),this._toggleDropDown(t,!1),N.trigger(t,Os,{relatedTarget:e})):t.classList.remove(Ws)}),t,t.classList.contains(Hs)))}_keydown(t){if(![$s,Is,Ns,Ps,js,Ms].includes(t.key))return;t.stopPropagation(),t.preventDefault();const e=this._getChildren().filter((t=>!l(t)));let i;if([js,Ms].includes(t.key))i=e[t.key===js?0:e.length-1];else{const n=[Is,Ps].includes(t.key);i=b(e,t.target,n,!0)}i&&(i.focus({preventScroll:!0}),Ks.getOrCreateInstance(i).show())}_getChildren(){return z.find(qs,this._parent)}_getActiveElem(){return this._getChildren().find((t=>this._elemIsActive(t)))||null}_setInitialAttributes(t,e){this._setAttributeIfNotExists(t,"role","tablist");for(const t of e)this._setInitialAttributesOnChild(t)}_setInitialAttributesOnChild(t){t=this._getInnerElement(t);const e=this._elemIsActive(t),i=this._getOuterElement(t);t.setAttribute("aria-selected",e),i!==t&&this._setAttributeIfNotExists(i,"role","presentation"),e||t.setAttribute("tabindex","-1"),this._setAttributeIfNotExists(t,"role","tab"),this._setInitialAttributesOnTargetPanel(t)}_setInitialAttributesOnTargetPanel(t){const e=z.getElementFromSelector(t);e&&(this._setAttributeIfNotExists(e,"role","tabpanel"),t.id&&this._setAttributeIfNotExists(e,"aria-labelledby",`${t.id}`))}_toggleDropDown(t,e){const i=this._getOuterElement(t);if(!i.classList.contains("dropdown"))return;const n=(t,n)=>{const s=z.findOne(t,i);s&&s.classList.toggle(n,e)};n(Bs,Fs),n(".dropdown-menu",Ws),i.setAttribute("aria-expanded",e)}_setAttributeIfNotExists(t,e,i){t.hasAttribute(e)||t.setAttribute(e,i)}_elemIsActive(t){return t.classList.contains(Fs)}_getInnerElement(t){return t.matches(qs)?t:z.findOne(qs,t)}_getOuterElement(t){return t.closest(".nav-item, .list-group-item")||t}static jQueryInterface(t){return this.each((function(){const e=Ks.getOrCreateInstance(this);if("string"==typeof t){if(void 0===e[t]||t.startsWith("_")||"constructor"===t)throw new TypeError(`No method named "${t}"`);e[t]()}}))}}N.on(document,Ls,Rs,(function(t){["A","AREA"].includes(this.tagName)&&t.preventDefault(),l(this)||Ks.getOrCreateInstance(this).show()})),N.on(window,Ds,(()=>{for(const t of z.find(Vs))Ks.getOrCreateInstance(t)})),m(Ks);const Qs=".bs.toast",Xs=`mouseover${Qs}`,Ys=`mouseout${Qs}`,Us=`focusin${Qs}`,Gs=`focusout${Qs}`,Js=`hide${Qs}`,Zs=`hidden${Qs}`,to=`show${Qs}`,eo=`shown${Qs}`,io="hide",no="show",so="showing",oo={animation:"boolean",autohide:"boolean",delay:"number"},ro={animation:!0,autohide:!0,delay:5e3};class ao extends W{constructor(t,e){super(t,e),this._timeout=null,this._hasMouseInteraction=!1,this._hasKeyboardInteraction=!1,this._setListeners()}static get Default(){return ro}static get DefaultType(){return oo}static get NAME(){return"toast"}show(){N.trigger(this._element,to).defaultPrevented||(this._clearTimeout(),this._config.animation&&this._element.classList.add("fade"),this._element.classList.remove(io),d(this._element),this._element.classList.add(no,so),this._queueCallback((()=>{this._element.classList.remove(so),N.trigger(this._element,eo),this._maybeScheduleHide()}),this._element,this._config.animation))}hide(){this.isShown()&&(N.trigger(this._element,Js).defaultPrevented||(this._element.classList.add(so),this._queueCallback((()=>{this._element.classList.add(io),this._element.classList.remove(so,no),N.trigger(this._element,Zs)}),this._element,this._config.animation)))}dispose(){this._clearTimeout(),this.isShown()&&this._element.classList.remove(no),super.dispose()}isShown(){return this._element.classList.contains(no)}_maybeScheduleHide(){this._config.autohide&&(this._hasMouseInteraction||this._hasKeyboardInteraction||(this._timeout=setTimeout((()=>{this.hide()}),this._config.delay)))}_onInteraction(t,e){switch(t.type){case"mouseover":case"mouseout":this._hasMouseInteraction=e;break;case"focusin":case"focusout":this._hasKeyboardInteraction=e}if(e)return void this._clearTimeout();const i=t.relatedTarget;this._element===i||this._element.contains(i)||this._maybeScheduleHide()}_setListeners(){N.on(this._element,Xs,(t=>this._onInteraction(t,!0))),N.on(this._element,Ys,(t=>this._onInteraction(t,!1))),N.on(this._element,Us,(t=>this._onInteraction(t,!0))),N.on(this._element,Gs,(t=>this._onInteraction(t,!1)))}_clearTimeout(){clearTimeout(this._timeout),this._timeout=null}static jQueryInterface(t){return this.each((function(){const e=ao.getOrCreateInstance(this,t);if("string"==typeof t){if(void 0===e[t])throw new TypeError(`No method named "${t}"`);e[t](this)}}))}}return R(ao),m(ao),{Alert:Q,Button:Y,Carousel:xt,Collapse:Bt,Dropdown:qi,Modal:On,Offcanvas:qn,Popover:us,ScrollSpy:Es,Tab:Ks,Toast:ao,Tooltip:cs}})); 7 | //# sourceMappingURL=bootstrap.bundle.min.js.map --------------------------------------------------------------------------------