diff --git a/.gitignore b/.gitignore index b6e4761..9fe17bc 100644 --- a/.gitignore +++ b/.gitignore @@ -126,4 +126,4 @@ venv.bak/ dmypy.json # Pyre type checker -.pyre/ +.pyre/ \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cbec1e2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,11 @@ +FROM python:3.10 +WORKDIR /app + +COPY ./requirements.txt /app/ +RUN pip install --no-cache-dir -r /app/requirements.txt + +ADD gunicorn_conf.py alembic.ini /app/ +ADD migrations /app/migrations +ADD services-backend /app/services-backend + +CMD [ "gunicorn", "-k", "uvicorn.workers.UvicornWorker", "-c", "/app/gunicorn_conf.py", "services-backend.routes.base:app" ] diff --git a/LICENSE b/LICENSE index 92e7e9c..5494ac6 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -BSD 2-Clause License +BSD 3-Clause License Copyright (c) 2022, Профком студентов физфака МГУ All rights reserved. @@ -13,6 +13,10 @@ modification, are permitted provided that the following conditions are met: this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE @@ -22,4 +26,4 @@ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE -OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d16211d --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +run: + source ./venv/bin/activate && uvicorn --reload --log-level debug services_backend.routes.base:app + +db: + docker run -d -p 5432:5432 -e POSTGRES_HOST_AUTH_METHOD=trust --name db-services-backend postgres:15 + +migrate: + alembic upgrade head diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e7d1c2 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# services-api + +Бэкэдн сервисов приложения Твой ФФ для профкома ФФ МГУ + +## Запуск + +1) Перейдите в папку проекта + +2) Создайте виртуальное окружение командой: +```console +foo@bar:~$ python3 -m venv ./venv/ +``` + +3) Установите библиотеки +```console +foo@bar:~$ pip install -m requirements.txt +``` +4) Запускайте приложение! +```console +foo@bar:~$ python -m services-backend +``` + +## ENV-file description + +DB_DSN= + +--- diff --git a/alembic.ini b/alembic.ini new file mode 100644 index 0000000..8921c49 --- /dev/null +++ b/alembic.ini @@ -0,0 +1,99 @@ +# A generic, single database configuration. + +[alembic] +# path to migration scripts +script_location = migrations + +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# sys.path path, will be prepended to sys.path if present. +# defaults to the current working directory. +prepend_sys_path = . + +# timezone to use when rendering the date within the migration file +# as well as the filename. +# If specified, requires the python-dateutil library that can be +# installed by adding `alembic[tz]` to the pip requirements +# string value is passed to dateutil.tz.gettz() +# leave blank for localtime +# timezone = + +# max length of characters to apply to the +# "slug" field +# truncate_slug_length = 40 + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + +# set to 'true' to allow .pyc and .pyo files without +# a source .py file to be detected as revisions in the +# versions/ directory +# sourceless = false + +# version location specification; This defaults +# to migrations/versions. When using multiple version +# directories, initial revisions must be specified with --version-path. +# The path separator used here should be the separator specified by "version_path_separator" below. +# version_locations = %(here)s/bar:%(here)s/bat:migrations/versions + +# version path separator; As mentioned above, this is the character used to split +# version_locations. The default within new alembic.ini files is "os", which uses os.pathsep. +# If this key is omitted entirely, it falls back to the legacy behavior of splitting on spaces and/or commas. +# Valid values for version_path_separator are: +# +# version_path_separator = : +# version_path_separator = ; +# version_path_separator = space +version_path_separator = os # Use os.pathsep. Default configuration used for new projects. + +# the output encoding used when revision files +# are written from script.py.mako +# output_encoding = utf-8 + +[post_write_hooks] +# post_write_hooks defines scripts or Python functions that are run +# on newly generated revision scripts. See the documentation for further +# detail and examples + +# format using "black" - use the console_scripts runner, against the "black" entrypoint +# hooks = black +# black.type = console_scripts +# black.entrypoint = black +# black.options = -l 79 REVISION_SCRIPT_FILENAME + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..d45b706 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +version: "1.0" + +services: + db: + image: postgres:13.3 + ports: + - "127.0.0.1:5432:5432" + environment: + - POSTGRES_PASSWORD=123 + + api: + build: . + env_file: .env. + command: bash -c "alembic upgrade head && uvicorn services_backend.__main__:app --reload" + volumes: + - .:/app + ports: + - "127.0.0.1:8000:8000" + depends_on: + - db \ No newline at end of file diff --git a/flake8.conf b/flake8.conf new file mode 100644 index 0000000..547208a --- /dev/null +++ b/flake8.conf @@ -0,0 +1,35 @@ +[flake8] +select = + E, W, # pep8 errors and warnings + F, # pyflakes + C9, # McCabe + N8, # Naming Conventions + #B, S, # bandit + #C, # commas + #D, # docstrings + #P, # string-format + #Q, # quotes + +ignore = + E122, # continuation line missing indentation or outdented + E123, # closing bracket does not match indentation of opening bracket's line + E127, # continuation line over-indented for visual indent + E131, # continuation line unaligned for hanging + E203, # whitespace before ':' + E225, # missing whitespace around operator + E226, # missing whitespace around arithmetic operator + E24, # multiple spaces after ',' or tab after ',' + E275, # missing whitespace after keyword + E305, # expected 2 blank lines after end of function or class + E306, # expected 1 blank line before a nested definition + E402, # module level import not at top of file + E722, # do not use bare except, specify exception instead + E731, # do not assign a lambda expression, use a def + E741, # do not use variables named 'l', 'O', or 'I' + + F722, # syntax error in forward annotation + + W503, # line break before binary operator + W504, # line break after binary operator + +max-line-length = 120 \ No newline at end of file diff --git a/gunicorn_conf.py b/gunicorn_conf.py new file mode 100644 index 0000000..98e1e8d --- /dev/null +++ b/gunicorn_conf.py @@ -0,0 +1,64 @@ +import json +import multiprocessing +import os + +workers_per_core_str = os.getenv("WORKERS_PER_CORE", "1") +max_workers_str = os.getenv("MAX_WORKERS") +use_max_workers = None +if max_workers_str: + use_max_workers = int(max_workers_str) +web_concurrency_str = os.getenv("WEB_CONCURRENCY", None) + +host = os.getenv("HOST", "0.0.0.0") +port = os.getenv("PORT", "80") +bind_env = os.getenv("BIND", None) +use_loglevel = os.getenv("LOG_LEVEL", "info") +if bind_env: + use_bind = bind_env +else: + use_bind = f"{host}:{port}" + +cores = multiprocessing.cpu_count() +workers_per_core = float(workers_per_core_str) +default_web_concurrency = workers_per_core * cores +if web_concurrency_str: + web_concurrency = int(web_concurrency_str) + assert web_concurrency > 0 +else: + web_concurrency = max(int(default_web_concurrency), 2) + if use_max_workers: + web_concurrency = min(web_concurrency, use_max_workers) +accesslog_var = os.getenv("ACCESS_LOG", "-") +use_accesslog = accesslog_var or None +errorlog_var = os.getenv("ERROR_LOG", "-") +use_errorlog = errorlog_var or None +graceful_timeout_str = os.getenv("GRACEFUL_TIMEOUT", "120") +timeout_str = os.getenv("TIMEOUT", "120") +keepalive_str = os.getenv("KEEP_ALIVE", "5") + +loglevel = use_loglevel +workers = web_concurrency +bind = use_bind +errorlog = use_errorlog +worker_tmp_dir = "/dev/shm" +accesslog = use_accesslog +graceful_timeout = int(graceful_timeout_str) +timeout = int(timeout_str) +keepalive = int(keepalive_str) + + +log_data = { + "loglevel": loglevel, + "workers": workers, + "bind": bind, + "graceful_timeout": graceful_timeout, + "timeout": timeout, + "keepalive": keepalive, + "errorlog": errorlog, + "accesslog": accesslog, + "workers_per_core": workers_per_core, + "use_max_workers": use_max_workers, + "host": host, + "port": port, +} +print(json.dumps(log_data)) diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..98e4f9c --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Generic single-database configuration. \ No newline at end of file diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..a9746a9 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,71 @@ +from logging.config import fileConfig + +from sqlalchemy import engine_from_config +from sqlalchemy import pool + +from alembic import context +from services_backend.models.base import Base +from services_backend.settings import get_settings + + +config = context.config +settings = get_settings() + + +if config.config_file_name is not None: + fileConfig(config.config_file_name) + + +target_metadata = Base.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + configuration = config.get_section(config.config_ini_section) + configuration['sqlalchemy.url'] = settings.DB_DSN + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/.gitkeep b/migrations/versions/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/migrations/versions/670f4caac7dd_init.py b/migrations/versions/670f4caac7dd_init.py new file mode 100644 index 0000000..3f30c54 --- /dev/null +++ b/migrations/versions/670f4caac7dd_init.py @@ -0,0 +1,29 @@ +from alembic import op +import sqlalchemy as sa + +revision = '670f4caac7dd' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table('category', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('type', sa.String(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_table('button', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('name', sa.String(), nullable=True), + sa.Column('category_id', sa.Integer(), nullable=True), + sa.Column('icon', sa.String(), nullable=True), + sa.ForeignKeyConstraint(['category_id'], ['category.id'], ), + sa.PrimaryKeyConstraint('id') + ) + + +def downgrade(): + op.drop_table('button') + op.drop_table('category') diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2368eb9 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,22 @@ +[tool.black] +line-length = 120 +target-version = ['py310'] +skip-string-normalization = true + +[tool.isort] +line_length = 120 +multi_line_output = 3 +lines_after_imports = 2 +include_trailing_comma = true + +[tool.pytest.ini_options] +minversion = "7.0" +python_files = "*.py" +testpaths = [ + "tests" +] +pythonpath = [ + "." +] +log_cli=true +log_level=0 diff --git a/requirements.dev.txt b/requirements.dev.txt new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..dfdfe90 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +fastapi +fastapi-sqlalchemy +psycopg2-binary +pydantic[dotenv] +uvicorn +alembic +SQLAlchemy +gunicorn diff --git a/services_backend/__init__.py b/services_backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services_backend/__main__.py b/services_backend/__main__.py new file mode 100644 index 0000000..3e28e7a --- /dev/null +++ b/services_backend/__main__.py @@ -0,0 +1,6 @@ +from .routes.base import app +import uvicorn + + +if __name__ == '__main__': + uvicorn.run(app) \ No newline at end of file diff --git a/services_backend/exceptions.py b/services_backend/exceptions.py new file mode 100644 index 0000000..e69de29 diff --git a/services_backend/models/__init__.py b/services_backend/models/__init__.py new file mode 100644 index 0000000..eac587f --- /dev/null +++ b/services_backend/models/__init__.py @@ -0,0 +1,3 @@ +from .database import Button, Category + +__all__ = ["Button", "Category"] \ No newline at end of file diff --git a/services_backend/models/base.py b/services_backend/models/base.py new file mode 100644 index 0000000..2279e14 --- /dev/null +++ b/services_backend/models/base.py @@ -0,0 +1,22 @@ +import re +from sqlalchemy.ext.declarative import as_declarative, declared_attr + + +@as_declarative() +class Base: + """Base class for all database entities""" + + @classmethod + @declared_attr + def __tablename__(cls) -> str: + """Generate database table name automatically. + Convert CamelCase class name to snake_case db table name. + """ + return re.sub(r"(? str: + attrs = [] + for c in self.__table__.columns: + attrs.append(f"{c.name}={getattr(self, c.name)}") + return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) + diff --git a/services_backend/models/database.py b/services_backend/models/database.py new file mode 100644 index 0000000..c275dc2 --- /dev/null +++ b/services_backend/models/database.py @@ -0,0 +1,18 @@ +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship +from .base import Base + + +class Category(Base): + id = Column(Integer, primary_key=True) + name = Column(String) + type = Column(String) + button = relationship("Button", back_populates="category", foreign_keys="Button.category_id") + + +class Button(Base): + id = Column(Integer, primary_key=True) + name = Column(String) + category_id = Column(Integer, ForeignKey(Category.id)) + category = relationship("Category", back_populates="button", foreign_keys=[category_id]) + icon = Column(String) diff --git a/services_backend/routes/__init__.py b/services_backend/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services_backend/routes/base.py b/services_backend/routes/base.py new file mode 100644 index 0000000..657ef1b --- /dev/null +++ b/services_backend/routes/base.py @@ -0,0 +1,29 @@ +from fastapi import FastAPI + +from services_backend.settings import Settings +from .button import button +from .category import category +from fastapi.middleware.cors import CORSMiddleware +from fastapi_sqlalchemy import DBSessionMiddleware + +settings = Settings() +app = FastAPI() + + +app.add_middleware( + DBSessionMiddleware, + db_url=settings.DB_DSN, + session_args={"autocommit": True}, + engine_args={"pool_pre_ping": True} +) + +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ALLOW_ORIGINS, + allow_credentials=settings.CORS_ALLOW_CREDENTIALS, + allow_methods=settings.CORS_ALLOW_METHODS, + allow_headers=settings.CORS_ALLOW_HEADERS, +) + +app.include_router(button, prefix='/button', tags=["category"]) +app.include_router(category, prefix='/category', tags=["button"]) \ No newline at end of file diff --git a/services_backend/routes/button.py b/services_backend/routes/button.py new file mode 100644 index 0000000..bb6d54f --- /dev/null +++ b/services_backend/routes/button.py @@ -0,0 +1,52 @@ +from fastapi import HTTPException, APIRouter +from fastapi_sqlalchemy import db + +from .models.button import ButtonCreate, ButtonUpdate, ButtonGet +from ..models.database import Button, Category + +button = APIRouter() + + +@button.post("/", response_model=ButtonCreate) +def create_button(button_inp: ButtonCreate): + category = db.session.query(Category).filter(Category.id == button_inp.category_id).one_or_none() + if not category: + raise HTTPException(status_code=404, detail="Category does not exist") + button = Button(**button_inp.dict()) + db.session.add(button) + db.session.flush() + return button + + +@button.get("/", response_model=list[ButtonGet]) +def get_buttons(offset: int = 0, limit: int = 100): + return db.session.query(Button).offset(offset).limit(limit).all() + + +@button.get("/{button_id}", response_model=ButtonGet) +def get_button(button_id: int): + button = db.session.query(Button).filter(Button.id == button_id).one_or_none() + if not button: + raise HTTPException(status_code=404, detail="Button does not exist") + return button + + +@button.delete("/{button_id}", response_model=None) +def remove_button(button_id: int): + button = db.session.query(Button).filter(Button.id == button_id).one_or_none() + if not button: + raise HTTPException(status_code=404, detail="Button does not exist") + db.session.delete(button) + db.session.flush() + + +@button.patch("/{button_id}", response_model=ButtonGet) +def update_button(button_inp: ButtonUpdate, button_id: int): + button = db.session.query(Button).filter(Button.id == button_id).one_or_none() + if not button: + raise HTTPException(status_code=404, detail="Button does not exist") + button.category_id = button_inp.category_id or button.category_id + button.icon = button_inp.icon or button.icon + button.name = button_inp.name or button.name + db.session.flush() + return button diff --git a/services_backend/routes/category.py b/services_backend/routes/category.py new file mode 100644 index 0000000..d538082 --- /dev/null +++ b/services_backend/routes/category.py @@ -0,0 +1,51 @@ +from fastapi import HTTPException, APIRouter +from fastapi_sqlalchemy import db + +from .models.category import CategoryCreate, CategoryUpdate, CategoryGet +from ..models.database import Category, Button + +category = APIRouter() + +@category.post("/", response_model=CategoryCreate) +def create_category(category_inp: CategoryCreate): + category = Category(**category_inp.dict()) + db.session.add(category) + db.session.flush() + return category + + +@category.get("/", response_model=list[CategoryGet]) +def get_categories(offset: int = 0, limit: int = 100): + return db.session.query(Category).offset(offset).limit(limit).all() + + +@category.get("/{category_id}", response_model=CategoryGet) +def get_category(category_id: int): + category = db.session.query(Category).filter(Category.id == category_id).one_or_none() + if not category: + raise HTTPException(status_code=404, detail="Category does not exist") + return category + + +@category.delete("/{category_id}", response_model=None) +def remove_category(category_id: int): + category = db.session.query(Category).filter(Category.id == category_id).one_or_none() + if category is None: + raise HTTPException(status_code=404, detail="Category does not exist") + delete = db.session.query(Category).filter(Category.id == category_id).one_or_none() + for button in db.session.query(Button).filter(Button.category_id == category_id).all(): + db.session.delete(button) + db.session.flush() + db.session.delete(delete) + db.session.flush() + + +@category.patch("/{category_id}", response_model=CategoryUpdate) +def update_category(category_inp: CategoryUpdate, category_id: int): + category = db.session.query(Category).filter(Category.id == category_id).one_or_none() + if category is None: + raise HTTPException(status_code=404, detail="Category does not exist") + category.type = category_inp.type or category.type + category.name = category_inp.name or category.name + db.session.flush() + return category diff --git a/services_backend/routes/models/__init__.py b/services_backend/routes/models/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/services_backend/routes/models/base.py b/services_backend/routes/models/base.py new file mode 100644 index 0000000..05d0cdb --- /dev/null +++ b/services_backend/routes/models/base.py @@ -0,0 +1,12 @@ +from pydantic import BaseModel + + +class Base(BaseModel): + def __repr__(self) -> str: + attrs = [] + for k, v in self.__class__.schema().items(): + attrs.append(f"{k}={v}") + return "{}({})".format(self.__class__.__name__, ', '.join(attrs)) + + class Config: + orm_mode = True \ No newline at end of file diff --git a/services_backend/routes/models/button.py b/services_backend/routes/models/button.py new file mode 100644 index 0000000..d608542 --- /dev/null +++ b/services_backend/routes/models/button.py @@ -0,0 +1,20 @@ +from .base import Base + + +class ButtonCreate(Base): + category_id: int + icon: str | None + name: str | None + + +class ButtonUpdate(Base): + category_id: int | None + icon: str | None + name: str | None + + +class ButtonGet(Base): + id: int + category_id: int + icon: str | None + name: str | None diff --git a/services_backend/routes/models/category.py b/services_backend/routes/models/category.py new file mode 100644 index 0000000..a148d98 --- /dev/null +++ b/services_backend/routes/models/category.py @@ -0,0 +1,18 @@ +from .base import Base + + +class CategoryCreate(Base): + type: str | None + name: str | None + + +class CategoryUpdate(Base): + type: str | None + name: str | None + + +class CategoryGet(Base): + id: int + type: str | None + name: str | None + diff --git a/services_backend/settings.py b/services_backend/settings.py new file mode 100644 index 0000000..ef3a2eb --- /dev/null +++ b/services_backend/settings.py @@ -0,0 +1,24 @@ +from pydantic import BaseSettings, PostgresDsn +from functools import lru_cache + + +class Settings(BaseSettings): + """Application settings""" + DB_DSN: PostgresDsn + + CORS_ALLOW_ORIGINS: list[str] = ['*'] + CORS_ALLOW_CREDENTIALS: bool = True + CORS_ALLOW_METHODS: list[str] = ['*'] + CORS_ALLOW_HEADERS: list[str] = ['*'] + + class Config: + """Pydantic BaseSettings config""" + + case_sensitive = True + env_file = ".env" + + +@lru_cache +def get_settings() -> Settings: + settings = Settings() + return settings diff --git a/services_backend/utils/__init__.py b/services_backend/utils/__init__.py new file mode 100644 index 0000000..e69de29