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 |
18 |
19 |
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
--------------------------------------------------------------------------------