diff --git a/db/config.py b/bd/__init__.py similarity index 67% rename from db/config.py rename to bd/__init__.py index 4d5ddef..3ffc02b 100644 --- a/db/config.py +++ b/bd/__init__.py @@ -1,13 +1,15 @@ +# Database configuration package from pydantic.generics import GenericModel import re import os +import urllib.parse class Settings(GenericModel): DB_HOST: str = os.getenv("DB_HOST", "postgres") DB_PORT: int = int(os.getenv("DB_PORT", "5432")) DB_USER: str = os.getenv("DB_USER", "postgres") DB_PASS: str = os.getenv("DB_PASS", "") - DB_NAME: str = os.getenv("DB_NAME", "syte") + DB_NAME: str = os.getenv("DB_NAME", "profi") @classmethod def validate_db_port(cls, v): @@ -23,8 +25,10 @@ class Settings(GenericModel): @property def DATABASE_URL_asyncpg(self) -> str: - return f"postgresql+asyncpg://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + pwd = urllib.parse.quote_plus(str(self.DB_PASS)) + return f"postgresql+asyncpg://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" @property def DATABASE_URL_syncpg(self) -> str: - return f"postgresql+psycopg2://{self.DB_USER}:{self.DB_PASS}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" + pwd = urllib.parse.quote_plus(str(self.DB_PASS)) + return f"postgresql+pg8000://{self.DB_USER}:{pwd}@{self.DB_HOST}:{self.DB_PORT}/{self.DB_NAME}" diff --git a/bd/tables/choice.py b/bd/tables/choice.py new file mode 100644 index 0000000..001cf0c --- /dev/null +++ b/bd/tables/choice.py @@ -0,0 +1,16 @@ +from sqlalchemy import Column, String, Integer, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from .simple_base import Base +import uuid + + +class Choice(Base): + __tablename__ = "choices" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id", ondelete="CASCADE"), nullable=False) + text = Column(String(500), nullable=False) + position = Column(Integer, nullable=True) + + +__all__ = ["Base", "Choice"] diff --git a/bd/tables/group.py b/bd/tables/group.py new file mode 100644 index 0000000..8c2c509 --- /dev/null +++ b/bd/tables/group.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, String, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from .simple_base import Base +import uuid +from sqlalchemy.orm import relationship + +class Group(Base): + __tablename__ = "group" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name_group = Column(String(150), nullable=False) + user_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False) + + user = relationship("User", backref="groups", passive_deletes=True) + +__all__ = ["Base", "Group"] + diff --git a/bd/tables/organization.py b/bd/tables/organization.py new file mode 100644 index 0000000..c18445b --- /dev/null +++ b/bd/tables/organization.py @@ -0,0 +1,17 @@ +from sqlalchemy import Column, String, ForeignKey +from sqlalchemy.dialects.postgresql import UUID +from .simple_base import Base +import uuid +from sqlalchemy.orm import relationship + +class Organization(Base): + __tablename__ = "organization" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + name_organization = Column(String(150), nullable=False) + group_id = Column(UUID(as_uuid=True), ForeignKey("group.id", ondelete="CASCADE"), nullable=False) + + group = relationship("Group", backref="organizations", passive_deletes=True) + +__all__ = ["Base", "Organization"] + diff --git a/bd/tables/poll.py b/bd/tables/poll.py new file mode 100644 index 0000000..e7358da --- /dev/null +++ b/bd/tables/poll.py @@ -0,0 +1,21 @@ +from sqlalchemy import Column, String, Boolean, DateTime, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from .simple_base import Base +import uuid + + +class Poll(Base): + __tablename__ = "polls" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + title = Column(String(255), nullable=False) + description = Column(Text, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + questions = relationship("Question", backref="poll", cascade="all, delete-orphan", passive_deletes=True) + + +__all__ = ["Base", "Poll"] diff --git a/bd/tables/question.py b/bd/tables/question.py new file mode 100644 index 0000000..e11d8eb --- /dev/null +++ b/bd/tables/question.py @@ -0,0 +1,20 @@ +from sqlalchemy import Column, Text, Integer, ForeignKey, String +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import relationship +from .simple_base import Base +import uuid + + +class Question(Base): + __tablename__ = "questions" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id", ondelete="CASCADE"), nullable=False) + text = Column(Text, nullable=False) + position = Column(Integer, nullable=True) + type = Column(String(32), nullable=False, default="single") # single|multi|open + + choices = relationship("Choice", backref="question", cascade="all, delete-orphan", passive_deletes=True) + + +__all__ = ["Base", "Question"] diff --git a/bd/tables/response.py b/bd/tables/response.py new file mode 100644 index 0000000..41f7c86 --- /dev/null +++ b/bd/tables/response.py @@ -0,0 +1,30 @@ +from sqlalchemy import Column, DateTime, ForeignKey, Text +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from .simple_base import Base +import uuid + + +class Response(Base): + __tablename__ = "responses" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + poll_id = Column(UUID(as_uuid=True), ForeignKey("polls.id"), nullable=False) + user_id = Column(UUID(as_uuid=True), nullable=True) + submitted_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False) + + answers = relationship("Answer", backref="response", cascade="all, delete-orphan", passive_deletes=True) + + +class Answer(Base): + __tablename__ = "answers" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + response_id = Column(UUID(as_uuid=True), ForeignKey("responses.id", ondelete="CASCADE"), nullable=False) + question_id = Column(UUID(as_uuid=True), ForeignKey("questions.id"), nullable=False) + choice_id = Column(UUID(as_uuid=True), nullable=True) + text = Column(Text, nullable=True) + + +__all__ = ["Base", "Response", "Answer"] diff --git a/bd/tables/simple_base.py b/bd/tables/simple_base.py new file mode 100644 index 0000000..95ebc0c --- /dev/null +++ b/bd/tables/simple_base.py @@ -0,0 +1,3 @@ +from sqlalchemy.orm import declarative_base + +Base = declarative_base() \ No newline at end of file diff --git a/bd/tables/users.py b/bd/tables/users.py new file mode 100644 index 0000000..e2e1fdb --- /dev/null +++ b/bd/tables/users.py @@ -0,0 +1,14 @@ +from sqlalchemy import Column, String +from sqlalchemy.dialects.postgresql import UUID +from .simple_base import Base +import uuid + +class User(Base): + __tablename__ = "users" + + id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + first_name = Column(String(150), nullable=False) + last_name = Column(String(150), nullable=False) + +__all__ = ["Base", "User"] + diff --git a/docker-compose.yml b/docker-compose.yml index f114713..a3959a7 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,5 +1,3 @@ -version: '3.8' - services: api: build: . @@ -12,7 +10,12 @@ services: - DB_HOST=192.168.1.11 - DB_PORT=5432 - DB_USER=admin1 - - DB_PASS=_NuUXNv*P+#;4v7 - - DB_NAME=syte + - DB_PASS=Y6%r35RpckeP^F + - DB_NAME=profi + # Redis connection (external) + - REDIS_HOST=192.168.1.19 + - REDIS_PORT=6379 + - REDIS_DB=0 + - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn volumes: - ./data:/app/data diff --git a/main.py b/main.py index 1084a7e..ccc41f0 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,7 @@ import os import tomllib from pathlib import Path from fastapi import FastAPI -from route import base +from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, ui import uvicorn # Читаем версию из pyproject.toml @@ -21,6 +21,17 @@ APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp") app = FastAPI(title="api", version=VERSION) app.include_router(base.router) +app.include_router(init_data_base.router) +app.include_router(groups_crud.router) +app.include_router(organizations_crud.router) +app.include_router(users_crud.router) +app.include_router(question_crud.router) +app.include_router(choice_crud.router) +app.include_router(response_crud.router) +app.include_router(poll_crud.router) +app.include_router(holland_crud.router) +app.include_router(ui.router) + # Доступна переменная APP_PATH для использования в приложении print(f"Application path: {APP_PATH}") diff --git a/pyproject.toml b/pyproject.toml index 7ded047..7f98fd1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,9 @@ dependencies = [ "uvicorn[standard]>=0.40.0", "pydantic>=2.12.5", "python-dotenv>=1.2.1", + "redis>=7.1.0", + "sqlalchemy>=2.0.47", + "psycopg>=3.3.3", ] [project.optional-dependencies] diff --git a/redis_db/__init__.py b/redis_db/__init__.py new file mode 100644 index 0000000..e515b55 --- /dev/null +++ b/redis_db/__init__.py @@ -0,0 +1,62 @@ +from pydantic.generics import GenericModel +import os +import redis + + +class RedisSettings(GenericModel): + REDIS_HOST: str = os.getenv("REDIS_HOST", "192.168.1.19") + REDIS_PORT: int = int(os.getenv("REDIS_PORT", 6379)) + REDIS_DB: int = int(os.getenv("REDIS_DB", 0)) + REDIS_PASSWORD: str = os.getenv("REDIS_PASSWORD") or None + + @property + def REDIS_URL(self) -> str: + """Получить Redis URL для подключения""" + if self.REDIS_PASSWORD: + return f"redis://:{self.REDIS_PASSWORD}@{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + else: + return f"redis://{self.REDIS_HOST}:{self.REDIS_PORT}/{self.REDIS_DB}" + + @property + def connection_kwargs(self) -> dict: + """Получить параметры подключения для redis.Redis()""" + return { + "host": self.REDIS_HOST, + "port": self.REDIS_PORT, + "db": self.REDIS_DB, + "password": self.REDIS_PASSWORD, + "decode_responses": True, + } + + def get_client(self) -> redis.Redis: + """Создать и вернуть Redis клиент""" + return redis.Redis(**self.connection_kwargs) + + def test_connection(self) -> bool: + """Протестировать подключение к Redis""" + try: + client = self.get_client() + client.ping() + print(f"✓ Успешно подключено к Redis на {self.REDIS_HOST}:{self.REDIS_PORT}") + return True + except redis.ConnectionError as e: + print(f"✗ Ошибка подключения к Redis: {e}") + return False + + +# Глобальная конфигурация +_redis_settings = RedisSettings() + + +def get_redis_settings() -> RedisSettings: + """Получить конфигурацию Redis""" + return _redis_settings + + +def get_redis_client() -> redis.Redis: + """Получить готовый клиент Redis""" + return _redis_settings.get_client() + + +__all__ = ["RedisSettings", "get_redis_settings", "get_redis_client"] + diff --git a/requirements.txt b/requirements.txt index caa7d08..4cd1ed1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,7 +8,10 @@ idna==3.11 pydantic==2.12.5 pydantic_core==2.41.5 python-dotenv==1.2.1 +redis==5.0.0 starlette==0.50.0 typing-inspection==0.4.2 typing_extensions==4.15.0 uvicorn==0.40.0 +sqlalchemy==2.0.32 +pg8000==1.29.0 \ No newline at end of file diff --git a/route/__init__.py b/route/__init__.py new file mode 100644 index 0000000..d212dab --- /dev/null +++ b/route/__init__.py @@ -0,0 +1 @@ +# Routes package diff --git a/route/base.py b/route/base.py index f9ce7ab..31e75cf 100644 --- a/route/base.py +++ b/route/base.py @@ -1,6 +1,10 @@ import tomllib from pathlib import Path -from fastapi import APIRouter +from fastapi import APIRouter, HTTPException +import socket + +from bd import Settings +from redis_db import get_redis_client # Создаем базовый router для FastAPI router = APIRouter(tags=["base"]) @@ -25,4 +29,36 @@ async def health(): @router.get("/version") async def version(): - return {"version": get_version()} \ No newline at end of file + return {"version": get_version()} + + +@router.get("/health/db") +async def health_db(): + """Проверка доступности БД по TCP (host:port)""" + settings = Settings() + host = settings.DB_HOST + port = settings.DB_PORT + try: + # Пробуем открыть TCP-соединение к хосту:порту + with socket.create_connection((host, port), timeout=3): + return {"status": "ok", "db": "reachable", "host": host, "port": port} + except Exception as e: + raise HTTPException(status_code=503, detail=f"db unreachable: {e}") + + +@router.get("/health/redis") +async def health_redis(): + """Проверка подключения к Redis через ping()""" + client = get_redis_client() + try: + if client.ping(): + # Получаем параметры соединения для удобства в ответе + conn_kwargs = getattr(client, "connection_pool", None) + info = {} + if conn_kwargs: + info = conn_kwargs.connection_kwargs if hasattr(conn_kwargs, 'connection_kwargs') else {} + return {"status": "ok", "redis": "reachable", "info": info} + else: + raise HTTPException(status_code=503, detail="redis ping failed") + except Exception as e: + raise HTTPException(status_code=503, detail=f"redis unreachable: {e}") \ No newline at end of file diff --git a/route/choice_crud.py b/route/choice_crud.py new file mode 100644 index 0000000..c3332d7 --- /dev/null +++ b/route/choice_crud.py @@ -0,0 +1,114 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.choice import Choice +from bd.tables.question import Question + +router = APIRouter(tags=["choices"], prefix="/choices") + + +class ChoiceCreate(BaseModel): + question_id: str + text: str + position: Optional[int] + + +class ChoiceUpdate(BaseModel): + text: Optional[str] + position: Optional[int] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_choice(payload: ChoiceCreate): + Session = get_session() + try: + qid = uuid.UUID(payload.question_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid question UUID") + with Session() as session: + question = session.get(Question, qid) + if not question: + raise HTTPException(status_code=404, detail="Question not found") + ch = Choice(question_id=qid, text=payload.text, position=payload.position) + session.add(ch) + session.commit() + session.refresh(ch) + return {"id": str(ch.id)} + + +@router.put("/{choice_id}") +def update_choice(choice_id: str, payload: ChoiceUpdate): + Session = get_session() + try: + cid = uuid.UUID(choice_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + ch = session.get(Choice, cid) + if not ch: + raise HTTPException(status_code=404, detail="Choice not found") + if payload.text is not None: + ch.text = payload.text + if payload.position is not None: + ch.position = payload.position + session.add(ch) + session.commit() + session.refresh(ch) + return {"id": str(ch.id)} + + +@router.delete("/{choice_id}", status_code=204) +def delete_choice(choice_id: str): + Session = get_session() + try: + cid = uuid.UUID(choice_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + ch = session.get(Choice, cid) + if not ch: + raise HTTPException(status_code=404, detail="Choice not found") + session.delete(ch) + session.commit() + return {} + + +@router.get("/") +def list_choices(question_id: Optional[str] = None): + Session = get_session() + with Session() as session: + q = session.query(Choice) + if question_id: + try: + qid = uuid.UUID(question_id) + q = q.filter(Choice.question_id == qid) + except Exception: + raise HTTPException(status_code=400, detail="Invalid question_id UUID") + rows = q.all() + return [{"id": str(r.id), "text": r.text, "question_id": str(r.question_id)} for r in rows] + + +@router.get("/{choice_id}") +def get_choice(choice_id: str): + Session = get_session() + try: + cid = uuid.UUID(choice_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + ch = session.get(Choice, cid) + if not ch: + raise HTTPException(status_code=404, detail="Choice not found") + return {"id": str(ch.id), "text": ch.text, "question_id": str(ch.question_id)} diff --git a/route/groups_crud.py b/route/groups_crud.py new file mode 100644 index 0000000..3255f24 --- /dev/null +++ b/route/groups_crud.py @@ -0,0 +1,113 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.group import Group +from bd.tables.users import User + +router = APIRouter(tags=["groups"], prefix="/groups") + + +class GroupCreate(BaseModel): + name_group: str + user_id: str + + +class GroupUpdate(BaseModel): + name_group: Optional[str] + user_id: Optional[str] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_group(payload: GroupCreate): + Session = get_session() + try: + uid = uuid.UUID(payload.user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid user_id UUID") + with Session() as session: + user = session.get(User, uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + grp = Group(name_group=payload.name_group, user_id=uid) + session.add(grp) + session.commit() + session.refresh(grp) + return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} + + +@router.get("/") +def list_groups(): + Session = get_session() + with Session() as session: + rows = session.query(Group).all() + return [{"id": str(g.id), "name_group": g.name_group, "user_id": str(g.user_id)} for g in rows] + + +@router.get("/{group_id}") +def get_group(group_id: str): + Session = get_session() + try: + gid = uuid.UUID(group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + grp = session.get(Group, gid) + if not grp: + raise HTTPException(status_code=404, detail="Group not found") + return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} + + +@router.put("/{group_id}") +def update_group(group_id: str, payload: GroupUpdate): + Session = get_session() + try: + gid = uuid.UUID(group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + grp = session.get(Group, gid) + if not grp: + raise HTTPException(status_code=404, detail="Group not found") + if payload.name_group is not None: + grp.name_group = payload.name_group + if payload.user_id is not None: + try: + new_uid = uuid.UUID(payload.user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid user_id UUID") + user = session.get(User, new_uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + grp.user_id = new_uid + session.add(grp) + session.commit() + session.refresh(grp) + return {"id": str(grp.id), "name_group": grp.name_group, "user_id": str(grp.user_id)} + + +@router.delete("/{group_id}", status_code=204) +def delete_group(group_id: str): + Session = get_session() + try: + gid = uuid.UUID(group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + grp = session.get(Group, gid) + if not grp: + raise HTTPException(status_code=404, detail="Group not found") + session.delete(grp) + session.commit() + return {} diff --git a/route/holland_crud.py b/route/holland_crud.py new file mode 100644 index 0000000..e32fd20 --- /dev/null +++ b/route/holland_crud.py @@ -0,0 +1,85 @@ +from fastapi import APIRouter, HTTPException +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.poll import Poll +from bd.tables.question import Question +from bd.tables.choice import Choice + +router = APIRouter(tags=["polls"], prefix="/polls") + + +PAIRS = [ + ("инженер-техник", "инженер-контролер"), + ("вязальщик", "санитарный врач"), + ("повар", "наборщик"), + ("фотограф", "зав. магазином"), + ("чертежник", "дизайнер"), + ("философ", "психиатр"), + ("ученый-химик", "бухгалтер"), + ("редактор научного журнала", "адвокат"), + ("лингвист", "переводчик художественной литературы"), + ("педиатр", "статистик"), + ("организатор воспитательной работы", "председатель профсоюза"), + ("спортивный врач", "фельетонист"), + ("нотариус", "снабженец"), + ("перфоратор", "карикатурист"), + ("политический деятель", "писатель"), + ("садовник", "метеоролог"), + ("водитель", "медсестра"), + ("инженер-электрик", "секретарь-машинистка"), + ("маляр", "художник по металлу"), + ("биолог", "главный врач"), + ("телеоператор", "режиссер"), + ("гидролог", "ревизор"), + ("зоолог", "зоотехник"), + ("математик", "архитектор"), + ("работник ИДН", "счетовод"), + ("учитель", "милиционер"), + ("воспитатель", "художник по керамике"), + ("экономист", "заведующий отделом"), + ("корректор", "критик"), + ("завхоз", "директор"), + ("радиоинженер", "специалист по ядерной физике"), + ("водопроводчик", "наборщик"), + ("агроном", "председатель сельхозкооператива"), + ("закройщик-модельер", "декоратор"), + ("археолог", "эксперт"), + ("работник музея", "консультант"), + ("ученый", "актер"), + ("логопед", "стенографист"), + ("врач", "дипломат"), + ("главный бухгалтер", "директор"), + ("поэт", "психолог"), + ("архивариус", "скульптор"), +] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/holland", status_code=201) +def create_holland_poll(author_id: Optional[str] = None): + Session = get_session() + with Session() as session: + poll = Poll(title="Тест Д. Голланда — определение типа личности", + description="Тест Д. Голланда: выберите из каждой пары предпочитаемую профессию.") + # polls are anonymous; ignore author_id + session.add(poll) + for idx, (a, b) in enumerate(PAIRS, start=1): + q = Question(text=f"Вариант {idx}", position=idx, type="single", poll=poll) + session.add(q) + ch1 = Choice(question=q, text=f"а) {a}", position=1) + ch2 = Choice(question=q, text=f"б) {b}", position=2) + session.add(ch1) + session.add(ch2) + session.commit() + session.refresh(poll) + return {"id": str(poll.id), "questions": len(poll.questions)} diff --git a/route/init_data_base.py b/route/init_data_base.py new file mode 100644 index 0000000..12b9732 --- /dev/null +++ b/route/init_data_base.py @@ -0,0 +1,69 @@ +from fastapi import APIRouter, HTTPException +import pkgutil +import importlib +from pathlib import Path +from typing import List + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.exc import SQLAlchemyError + +router = APIRouter(tags=["db"]) + + +def _iter_table_modules() -> List[str]: + try: + import bd.tables as tables_pkg + pkg_paths = getattr(tables_pkg, "__path__", None) + if not pkg_paths: + pkg_paths = [str(Path(__file__).resolve().parent.parent / "bd" / "tables")] + except Exception: + pkg_paths = [str(Path(__file__).resolve().parent.parent / "bd" / "tables")] + + names = [] + for finder, name, ispkg in pkgutil.iter_modules(pkg_paths): + names.append(name) + return names + + +def collect_metadatas(): + metadatas = [] + for mod_name in _iter_table_modules(): + try: + module = importlib.import_module(f"bd.tables.{mod_name}") + except Exception: + continue + Base = getattr(module, "Base", None) + if Base is not None and hasattr(Base, "metadata"): + metadatas.append(Base.metadata) + return metadatas + + +@router.post("/db/create-tables") +async def create_tables(): + """Создаёт все таблицы, описанные в модулях `db.tables`. + + Endpoint вызывается по нажатию кнопки в UI (POST). + """ + settings = Settings() + db_url = settings.DATABASE_URL_syncpg + engine = create_engine(db_url, future=True) + + metadatas = collect_metadatas() + if not metadatas: + raise HTTPException(status_code=400, detail="No table metadata found in db.tables") + + # deduplicate metadata objects (multiple modules may expose the same Base.metadata) + unique = [] + seen = set() + for md in metadatas: + if id(md) not in seen: + seen.add(id(md)) + unique.append(md) + + try: + for md in unique: + md.create_all(bind=engine) + return {"status": "ok", "detail": f"Created {len(unique)} metadata groups"} + except SQLAlchemyError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/route/organizations_crud.py b/route/organizations_crud.py new file mode 100644 index 0000000..ded5493 --- /dev/null +++ b/route/organizations_crud.py @@ -0,0 +1,113 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.organization import Organization +from bd.tables.group import Group + +router = APIRouter(tags=["organizations"], prefix="/organizations") + + +class OrganizationCreate(BaseModel): + name_organization: str + group_id: str + + +class OrganizationUpdate(BaseModel): + name_organization: Optional[str] + group_id: Optional[str] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_organization(payload: OrganizationCreate): + Session = get_session() + try: + gid = uuid.UUID(payload.group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid group_id UUID") + with Session() as session: + grp = session.get(Group, gid) + if not grp: + raise HTTPException(status_code=404, detail="Group not found") + org = Organization(name_organization=payload.name_organization, group_id=gid) + session.add(org) + session.commit() + session.refresh(org) + return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} + + +@router.get("/") +def list_organizations(): + Session = get_session() + with Session() as session: + rows = session.query(Organization).all() + return [{"id": str(o.id), "name_organization": o.name_organization, "group_id": str(o.group_id)} for o in rows] + + +@router.get("/{org_id}") +def get_organization(org_id: str): + Session = get_session() + try: + oid = uuid.UUID(org_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + org = session.get(Organization, oid) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} + + +@router.put("/{org_id}") +def update_organization(org_id: str, payload: OrganizationUpdate): + Session = get_session() + try: + oid = uuid.UUID(org_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + org = session.get(Organization, oid) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + if payload.name_organization is not None: + org.name_organization = payload.name_organization + if payload.group_id is not None: + try: + new_gid = uuid.UUID(payload.group_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid group_id UUID") + grp = session.get(Group, new_gid) + if not grp: + raise HTTPException(status_code=404, detail="Group not found") + org.group_id = new_gid + session.add(org) + session.commit() + session.refresh(org) + return {"id": str(org.id), "name_organization": org.name_organization, "group_id": str(org.group_id)} + + +@router.delete("/{org_id}", status_code=204) +def delete_organization(org_id: str): + Session = get_session() + try: + oid = uuid.UUID(org_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + org = session.get(Organization, oid) + if not org: + raise HTTPException(status_code=404, detail="Organization not found") + session.delete(org) + session.commit() + return {} diff --git a/route/poll_crud.py b/route/poll_crud.py new file mode 100644 index 0000000..189007d --- /dev/null +++ b/route/poll_crud.py @@ -0,0 +1,95 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.poll import Poll +from bd.tables.question import Question +from bd.tables.choice import Choice + +router = APIRouter(tags=["polls"], prefix="/polls") + + +class ChoiceIn(BaseModel): + text: str + position: Optional[int] + + +class QuestionIn(BaseModel): + text: str + type: Optional[str] = "single" + position: Optional[int] + choices: Optional[List[ChoiceIn]] + + +class PollIn(BaseModel): + title: str + description: Optional[str] + questions: Optional[List[QuestionIn]] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_poll(payload: PollIn): + Session = get_session() + with Session() as session: + poll = Poll(title=payload.title, description=payload.description) + # polls are anonymous; no author_id stored + session.add(poll) + # questions and choices + if payload.questions: + for q in payload.questions: + question = Question(text=q.text, type=q.type or "single", position=q.position, poll=poll) + session.add(question) + if q.choices: + for idx, c in enumerate(q.choices): + choice = Choice(text=c.text, position=c.position if c.position is not None else idx, question=question) + session.add(choice) + session.commit() + session.refresh(poll) + return {"id": str(poll.id)} + + +@router.get("/{poll_id}") +def get_poll(poll_id: str): + Session = get_session() + try: + pid = uuid.UUID(poll_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + poll = session.get(Poll, pid) + if not poll: + raise HTTPException(status_code=404, detail="Poll not found") + data = { + "id": str(poll.id), + "title": poll.title, + "description": poll.description, + "questions": [ + { + "id": str(q.id), + "text": q.text, + "type": q.type, + "choices": [{"id": str(c.id), "text": c.text} for c in q.choices] + } + for q in poll.questions + ], + } + return data + + +@router.get("/") +def list_polls(): + Session = get_session() + with Session() as session: + rows = session.query(Poll).all() + return [{"id": str(p.id), "title": p.title, "description": p.description} for p in rows] diff --git a/route/question_crud.py b/route/question_crud.py new file mode 100644 index 0000000..f545e63 --- /dev/null +++ b/route/question_crud.py @@ -0,0 +1,118 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.question import Question +from bd.tables.poll import Poll + +router = APIRouter(tags=["questions"], prefix="/questions") + + +class QuestionCreate(BaseModel): + poll_id: str + text: str + type: Optional[str] = "single" + position: Optional[int] + + +class QuestionUpdate(BaseModel): + text: Optional[str] + type: Optional[str] + position: Optional[int] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_question(payload: QuestionCreate): + Session = get_session() + try: + pid = uuid.UUID(payload.poll_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid poll UUID") + with Session() as session: + poll = session.get(Poll, pid) + if not poll: + raise HTTPException(status_code=404, detail="Poll not found") + q = Question(poll_id=pid, text=payload.text, type=payload.type or "single", position=payload.position) + session.add(q) + session.commit() + session.refresh(q) + return {"id": str(q.id)} + + +@router.put("/{question_id}") +def update_question(question_id: str, payload: QuestionUpdate): + Session = get_session() + try: + qid = uuid.UUID(question_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + q = session.get(Question, qid) + if not q: + raise HTTPException(status_code=404, detail="Question not found") + if payload.text is not None: + q.text = payload.text + if payload.type is not None: + q.type = payload.type + if payload.position is not None: + q.position = payload.position + session.add(q) + session.commit() + session.refresh(q) + return {"id": str(q.id)} + + +@router.delete("/{question_id}", status_code=204) +def delete_question(question_id: str): + Session = get_session() + try: + qid = uuid.UUID(question_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + q = session.get(Question, qid) + if not q: + raise HTTPException(status_code=404, detail="Question not found") + session.delete(q) + session.commit() + return {} + + +@router.get("/") +def list_questions(poll_id: Optional[str] = None): + Session = get_session() + with Session() as session: + q = session.query(Question) + if poll_id: + try: + pid = uuid.UUID(poll_id) + q = q.filter(Question.poll_id == pid) + except Exception: + raise HTTPException(status_code=400, detail="Invalid poll_id UUID") + rows = q.all() + return [{"id": str(r.id), "text": r.text, "poll_id": str(r.poll_id)} for r in rows] + + +@router.get("/{question_id}") +def get_question(question_id: str): + Session = get_session() + try: + qid = uuid.UUID(question_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + q = session.get(Question, qid) + if not q: + raise HTTPException(status_code=404, detail="Question not found") + return {"id": str(q.id), "text": q.text, "poll_id": str(q.poll_id)} diff --git a/route/response_crud.py b/route/response_crud.py new file mode 100644 index 0000000..46b5387 --- /dev/null +++ b/route/response_crud.py @@ -0,0 +1,97 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import List, Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.poll import Poll +from bd.tables.question import Question +from bd.tables.response import Response, Answer + +router = APIRouter(tags=["responses"], prefix="/polls") + + +class AnswerIn(BaseModel): + question_id: str + choice_id: Optional[str] + text: Optional[str] + + +class ResponseIn(BaseModel): + user_id: Optional[str] + answers: List[AnswerIn] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/{poll_id}/responses", status_code=201) +def submit_response(poll_id: str, payload: ResponseIn): + Session = get_session() + try: + pid = uuid.UUID(poll_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid poll UUID") + with Session() as session: + poll = session.get(Poll, pid) + if not poll: + raise HTTPException(status_code=404, detail="Poll not found") + resp = Response(poll_id=pid) + if payload.user_id: + try: + resp.user_id = uuid.UUID(payload.user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid user_id UUID") + session.add(resp) + # validate answers + for a in payload.answers: + try: + qid = uuid.UUID(a.question_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid question_id UUID") + question = session.get(Question, qid) + if not question or question.poll_id != pid: + raise HTTPException(status_code=400, detail="Question does not belong to poll") + choice_uuid = None + if a.choice_id: + try: + choice_uuid = uuid.UUID(a.choice_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid choice_id UUID") + answer = Answer(response=resp, question_id=qid, choice_id=choice_uuid, text=a.text) + session.add(answer) + session.commit() + session.refresh(resp) + return {"id": str(resp.id)} + + +@router.get("/{poll_id}/responses") +def list_responses(poll_id: str): + Session = get_session() + try: + pid = uuid.UUID(poll_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid poll UUID") + with Session() as session: + rows = session.query(Response).filter(Response.poll_id == pid).all() + return [{"id": str(r.id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat()} for r in rows] + + +@router.get("/responses/{response_id}") +def get_response(response_id: str): + Session = get_session() + try: + rid = uuid.UUID(response_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + r = session.get(Response, rid) + if not r: + raise HTTPException(status_code=404, detail="Response not found") + return {"id": str(r.id), "poll_id": str(r.poll_id), "user_id": str(r.user_id) if r.user_id else None, "submitted_at": r.submitted_at.isoformat(), "answers": [{"id": str(a.id), "question_id": str(a.question_id), "choice_id": str(a.choice_id) if a.choice_id else None, "text": a.text} for a in r.answers]} diff --git a/route/ui.py b/route/ui.py new file mode 100644 index 0000000..5ce96b2 --- /dev/null +++ b/route/ui.py @@ -0,0 +1,111 @@ +from fastapi import APIRouter, Request + +router = APIRouter() + + +@router.get("/ui/create-user") +def create_user_page(request: Request): + return """ + + + + + Create user & Take test + + +

Create user

+
+
+
+ +
+ +

Or create Holland test

+ + + + + +""" + + +@router.get("/ui/take-test") +def take_test_page(request: Request, poll_id: str = None, user_id: str = None): + return f""" + + +Take test + +

Take test

+
+ + + + +""" diff --git a/route/users_crud.py b/route/users_crud.py new file mode 100644 index 0000000..0919de1 --- /dev/null +++ b/route/users_crud.py @@ -0,0 +1,98 @@ +from fastapi import APIRouter, HTTPException +from pydantic import BaseModel +from typing import Optional +import uuid + +from bd import Settings +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from bd.tables.users import User + +router = APIRouter(tags=["users"], prefix="/users") + + +class UserCreate(BaseModel): + first_name: str + last_name: str + + +class UserUpdate(BaseModel): + first_name: Optional[str] + last_name: Optional[str] + + +def get_session(): + settings = Settings() + engine = create_engine(settings.DATABASE_URL_syncpg, future=True) + return sessionmaker(bind=engine, future=True) + + +@router.post("/", status_code=201) +def create_user(payload: UserCreate): + Session = get_session() + with Session() as session: + user = User(first_name=payload.first_name, last_name=payload.last_name) + session.add(user) + session.commit() + session.refresh(user) + return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} + + +@router.put("/{user_id}") +def update_user(user_id: str, payload: UserUpdate): + Session = get_session() + try: + uid = uuid.UUID(user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + user = session.get(User, uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + if payload.first_name is not None: + user.first_name = payload.first_name + if payload.last_name is not None: + user.last_name = payload.last_name + session.add(user) + session.commit() + session.refresh(user) + return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} + + +@router.delete("/{user_id}", status_code=204) +def delete_user(user_id: str): + Session = get_session() + try: + uid = uuid.UUID(user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + user = session.get(User, uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + session.delete(user) + session.commit() + return {} + + +@router.get("/") +def list_users(): + Session = get_session() + with Session() as session: + rows = session.query(User).all() + return [{"id": str(u.id), "first_name": u.first_name, "last_name": u.last_name} for u in rows] + + +@router.get("/{user_id}") +def get_user(user_id: str): + Session = get_session() + try: + uid = uuid.UUID(user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid UUID") + with Session() as session: + user = session.get(User, uid) + if not user: + raise HTTPException(status_code=404, detail="User not found") + return {"id": str(user.id), "first_name": user.first_name, "last_name": user.last_name} diff --git a/uv.lock b/uv.lock index 3589a5c..8eb3b5c 100644 --- a/uv.lock +++ b/uv.lock @@ -34,12 +34,15 @@ wheels = [ [[package]] name = "api-copp" -version = "0.1.0" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "fastapi" }, + { name = "psycopg" }, { name = "pydantic" }, { name = "python-dotenv" }, + { name = "redis" }, + { name = "sqlalchemy" }, { name = "uvicorn", extra = ["standard"] }, ] @@ -55,11 +58,14 @@ dev = [ requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "fastapi", specifier = ">=0.128.0" }, + { name = "psycopg", specifier = ">=3.3.3" }, { name = "pydantic", specifier = ">=2.12.5" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "python-dotenv", specifier = ">=1.2.1" }, + { name = "redis", specifier = ">=7.1.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.1.0" }, + { name = "sqlalchemy", specifier = ">=2.0.47" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" }, ] provides-extras = ["dev"] @@ -122,6 +128,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/05/5cbb59154b093548acd0f4c7c474a118eda06da25aa75c616b72d8fcd92a/fastapi-0.128.0-py3-none-any.whl", hash = "sha256:aebd93f9716ee3b4f4fcfe13ffb7cf308d99c9f3ab5622d8877441072561582d", size = 103094, upload-time = "2025-12-27T15:21:12.154Z" }, ] +[[package]] +name = "greenlet" +version = "3.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/51/1664f6b78fc6ebbd98019a1fd730e83fa78f2db7058f72b1463d3612b8db/greenlet-3.3.2.tar.gz", hash = "sha256:2eaf067fc6d886931c7962e8c6bede15d2f01965560f3359b27c80bde2d151f2", size = 188267, upload-time = "2026-02-20T20:54:15.531Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/ae/8bffcbd373b57a5992cd077cbe8858fff39110480a9d50697091faea6f39/greenlet-3.3.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8d1658d7291f9859beed69a776c10822a0a799bc4bfe1bd4272bb60e62507dab", size = 279650, upload-time = "2026-02-20T20:18:00.783Z" }, + { url = "https://files.pythonhosted.org/packages/d1/c0/45f93f348fa49abf32ac8439938726c480bd96b2a3c6f4d949ec0124b69f/greenlet-3.3.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18cb1b7337bca281915b3c5d5ae19f4e76d35e1df80f4ad3c1a7be91fadf1082", size = 650295, upload-time = "2026-02-20T20:47:34.036Z" }, + { url = "https://files.pythonhosted.org/packages/b3/de/dd7589b3f2b8372069ab3e4763ea5329940fc7ad9dcd3e272a37516d7c9b/greenlet-3.3.2-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c2e47408e8ce1c6f1ceea0dffcdf6ebb85cc09e55c7af407c99f1112016e45e9", size = 662163, upload-time = "2026-02-20T20:56:01.295Z" }, + { url = "https://files.pythonhosted.org/packages/cd/ac/85804f74f1ccea31ba518dcc8ee6f14c79f73fe36fa1beba38930806df09/greenlet-3.3.2-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e3cb43ce200f59483eb82949bf1835a99cf43d7571e900d7c8d5c62cdf25d2f9", size = 675371, upload-time = "2026-02-20T21:02:49.664Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d8/09bfa816572a4d83bccd6750df1926f79158b1c36c5f73786e26dbe4ee38/greenlet-3.3.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:63d10328839d1973e5ba35e98cccbca71b232b14051fd957b6f8b6e8e80d0506", size = 664160, upload-time = "2026-02-20T20:21:04.015Z" }, + { url = "https://files.pythonhosted.org/packages/48/cf/56832f0c8255d27f6c35d41b5ec91168d74ec721d85f01a12131eec6b93c/greenlet-3.3.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e4ab3cfb02993c8cc248ea73d7dae6cec0253e9afa311c9b37e603ca9fad2ce", size = 1619181, upload-time = "2026-02-20T20:49:36.052Z" }, + { url = "https://files.pythonhosted.org/packages/0a/23/b90b60a4aabb4cec0796e55f25ffbfb579a907c3898cd2905c8918acaa16/greenlet-3.3.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:94ad81f0fd3c0c0681a018a976e5c2bd2ca2d9d94895f23e7bb1af4e8af4e2d5", size = 1687713, upload-time = "2026-02-20T20:21:11.684Z" }, + { url = "https://files.pythonhosted.org/packages/f3/ca/2101ca3d9223a1dc125140dbc063644dca76df6ff356531eb27bc267b446/greenlet-3.3.2-cp314-cp314-win_amd64.whl", hash = "sha256:8c4dd0f3997cf2512f7601563cc90dfb8957c0cff1e3a1b23991d4ea1776c492", size = 232034, upload-time = "2026-02-20T20:20:08.186Z" }, + { url = "https://files.pythonhosted.org/packages/f6/4a/ecf894e962a59dea60f04877eea0fd5724618da89f1867b28ee8b91e811f/greenlet-3.3.2-cp314-cp314-win_arm64.whl", hash = "sha256:cd6f9e2bbd46321ba3bbb4c8a15794d32960e3b0ae2cc4d49a1a53d314805d71", size = 231437, upload-time = "2026-02-20T20:18:59.722Z" }, + { url = "https://files.pythonhosted.org/packages/98/6d/8f2ef704e614bcf58ed43cfb8d87afa1c285e98194ab2cfad351bf04f81e/greenlet-3.3.2-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:e26e72bec7ab387ac80caa7496e0f908ff954f31065b0ffc1f8ecb1338b11b54", size = 286617, upload-time = "2026-02-20T20:19:29.856Z" }, + { url = "https://files.pythonhosted.org/packages/5e/0d/93894161d307c6ea237a43988f27eba0947b360b99ac5239ad3fe09f0b47/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b466dff7a4ffda6ca975979bab80bdadde979e29fc947ac3be4451428d8b0e4", size = 655189, upload-time = "2026-02-20T20:47:35.742Z" }, + { url = "https://files.pythonhosted.org/packages/f5/2c/d2d506ebd8abcb57386ec4f7ba20f4030cbe56eae541bc6fd6ef399c0b41/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b8bddc5b73c9720bea487b3bffdb1840fe4e3656fba3bd40aa1489e9f37877ff", size = 658225, upload-time = "2026-02-20T20:56:02.527Z" }, + { url = "https://files.pythonhosted.org/packages/d1/67/8197b7e7e602150938049d8e7f30de1660cfb87e4c8ee349b42b67bdb2e1/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:59b3e2c40f6706b05a9cd299c836c6aa2378cabe25d021acd80f13abf81181cf", size = 666581, upload-time = "2026-02-20T21:02:51.526Z" }, + { url = "https://files.pythonhosted.org/packages/8e/30/3a09155fbf728673a1dea713572d2d31159f824a37c22da82127056c44e4/greenlet-3.3.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b26b0f4428b871a751968285a1ac9648944cea09807177ac639b030bddebcea4", size = 657907, upload-time = "2026-02-20T20:21:05.259Z" }, + { url = "https://files.pythonhosted.org/packages/f3/fd/d05a4b7acd0154ed758797f0a43b4c0962a843bedfe980115e842c5b2d08/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1fb39a11ee2e4d94be9a76671482be9398560955c9e568550de0224e41104727", size = 1618857, upload-time = "2026-02-20T20:49:37.309Z" }, + { url = "https://files.pythonhosted.org/packages/6f/e1/50ee92a5db521de8f35075b5eff060dd43d39ebd46c2181a2042f7070385/greenlet-3.3.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:20154044d9085151bc309e7689d6f7ba10027f8f5a8c0676ad398b951913d89e", size = 1680010, upload-time = "2026-02-20T20:21:13.427Z" }, + { url = "https://files.pythonhosted.org/packages/29/4b/45d90626aef8e65336bed690106d1382f7a43665e2249017e9527df8823b/greenlet-3.3.2-cp314-cp314t-win_amd64.whl", hash = "sha256:c04c5e06ec3e022cbfe2cd4a846e1d4e50087444f875ff6d2c2ad8445495cf1a", size = 237086, upload-time = "2026-02-20T20:20:45.786Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -209,6 +240,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + [[package]] name = "pydantic" version = "2.12.5" @@ -354,6 +397,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "redis" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/43/c8/983d5c6579a411d8a99bc5823cc5712768859b5ce2c8afe1a65b37832c81/redis-7.1.0.tar.gz", hash = "sha256:b1cc3cfa5a2cb9c2ab3ba700864fb0ad75617b41f01352ce5779dabf6d5f9c3c", size = 4796669, upload-time = "2025-11-19T15:54:39.961Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/89/f0/8956f8a86b20d7bb9d6ac0187cf4cd54d8065bc9a1a09eb8011d4d326596/redis-7.1.0-py3-none-any.whl", hash = "sha256:23c52b208f92b56103e17c5d06bdc1a6c2c0b3106583985a76a18f83b265de2b", size = 354159, upload-time = "2025-11-19T15:54:38.064Z" }, +] + [[package]] name = "ruff" version = "0.14.14" @@ -380,6 +432,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9e/6a/40fee331a52339926a92e17ae748827270b288a35ef4a15c9c8f2ec54715/ruff-0.14.14-py3-none-win_arm64.whl", hash = "sha256:56e6981a98b13a32236a72a8da421d7839221fa308b223b9283312312e5ac76c", size = 10920448, upload-time = "2026-01-22T22:30:15.417Z" }, ] +[[package]] +name = "sqlalchemy" +version = "2.0.47" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cd/4b/1e00561093fe2cd8eef09d406da003c8a118ff02d6548498c1ae677d68d9/sqlalchemy-2.0.47.tar.gz", hash = "sha256:e3e7feb57b267fe897e492b9721ae46d5c7de6f9e8dee58aacf105dc4e154f3d", size = 9886323, upload-time = "2026-02-24T16:34:27.947Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/30/98243209aae58ed80e090ea988d5182244ca7ab3ff59e6d850c3dfc7651e/sqlalchemy-2.0.47-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b03010a5a5dfe71676bc83f2473ebe082478e32d77e6f082c8fe15a31c3b42a6", size = 2154355, upload-time = "2026-02-24T17:05:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/ab/62/12ca6ea92055fe486d6558a2a4efe93e194ff597463849c01f88e5adb99d/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f8e3371aa9024520883a415a09cc20c33cfd3eeccf9e0f4f4c367f940b9cbd44", size = 3274486, upload-time = "2026-02-24T17:18:13.659Z" }, + { url = "https://files.pythonhosted.org/packages/97/88/7dfbdeaa8d42b1584e65d6cc713e9d33b6fa563e0d546d5cb87e545bb0e5/sqlalchemy-2.0.47-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c9449f747e50d518c6e1b40cc379e48bfc796453c47b15e627ea901c201e48a6", size = 3279481, upload-time = "2026-02-24T17:27:26.491Z" }, + { url = "https://files.pythonhosted.org/packages/d0/b7/75e1c1970616a9dd64a8a6fd788248da2ddaf81c95f4875f2a1e8aee4128/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:21410f60d5cac1d6bfe360e05bd91b179be4fa0aa6eea6be46054971d277608f", size = 3224269, upload-time = "2026-02-24T17:18:15.078Z" }, + { url = "https://files.pythonhosted.org/packages/31/ac/eec1a13b891df9a8bc203334caf6e6aac60b02f61b018ef3b4124b8c4120/sqlalchemy-2.0.47-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:819841dd5bb4324c284c09e2874cf96fe6338bfb57a64548d9b81a4e39c9871f", size = 3246262, upload-time = "2026-02-24T17:27:27.986Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b0/661b0245b06421058610da39f8ceb34abcc90b49f90f256380968d761dbe/sqlalchemy-2.0.47-cp314-cp314-win32.whl", hash = "sha256:e255ee44821a7ef45649c43064cf94e74f81f61b4df70547304b97a351e9b7db", size = 2116528, upload-time = "2026-02-24T17:22:59.363Z" }, + { url = "https://files.pythonhosted.org/packages/aa/ef/1035a90d899e61810791c052004958be622a2cf3eb3df71c3fe20778c5d0/sqlalchemy-2.0.47-cp314-cp314-win_amd64.whl", hash = "sha256:209467ff73ea1518fe1a5aaed9ba75bb9e33b2666e2553af9ccd13387bf192cb", size = 2142181, upload-time = "2026-02-24T17:23:01.001Z" }, + { url = "https://files.pythonhosted.org/packages/76/bb/17a1dd09cbba91258218ceb582225f14b5364d2683f9f5a274f72f2d764f/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e78fd9186946afaa287f8a1fe147ead06e5d566b08c0afcb601226e9c7322a64", size = 3563477, upload-time = "2026-02-24T17:12:18.46Z" }, + { url = "https://files.pythonhosted.org/packages/66/8f/1a03d24c40cc321ef2f2231f05420d140bb06a84f7047eaa7eaa21d230ba/sqlalchemy-2.0.47-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5740e2f31b5987ed9619d6912ae5b750c03637f2078850da3002934c9532f172", size = 3528568, upload-time = "2026-02-24T17:28:03.732Z" }, + { url = "https://files.pythonhosted.org/packages/fd/53/d56a213055d6b038a5384f0db5ece7343334aca230ff3f0fa1561106f22c/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb9ac00d03de93acb210e8ec7243fefe3e012515bf5fd2f0898c8dff38bc77a4", size = 3472284, upload-time = "2026-02-24T17:12:20.319Z" }, + { url = "https://files.pythonhosted.org/packages/ff/19/c235d81b9cfdd6130bf63143b7bade0dc4afa46c4b634d5d6b2a96bea233/sqlalchemy-2.0.47-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c72a0b9eb2672d70d112cb149fbaf172d466bc691014c496aaac594f1988e706", size = 3478410, upload-time = "2026-02-24T17:28:05.892Z" }, + { url = "https://files.pythonhosted.org/packages/0e/db/cafdeca5ecdaa3bb0811ba5449501da677ce0d83be8d05c5822da72d2e86/sqlalchemy-2.0.47-cp314-cp314t-win32.whl", hash = "sha256:c200db1128d72a71dc3c31c24b42eb9fd85b2b3e5a3c9ba1e751c11ac31250ff", size = 2147164, upload-time = "2026-02-24T17:14:40.783Z" }, + { url = "https://files.pythonhosted.org/packages/fc/5e/ff41a010e9e0f76418b02ad352060a4341bb15f0af66cedc924ab376c7c6/sqlalchemy-2.0.47-cp314-cp314t-win_amd64.whl", hash = "sha256:669837759b84e575407355dcff912835892058aea9b80bd1cb76d6a151cf37f7", size = 2182154, upload-time = "2026-02-24T17:14:43.205Z" }, + { url = "https://files.pythonhosted.org/packages/15/9f/7c378406b592fcf1fc157248607b495a40e3202ba4a6f1372a2ba6447717/sqlalchemy-2.0.47-py3-none-any.whl", hash = "sha256:e2647043599297a1ef10e720cf310846b7f31b6c841fee093d2b09d81215eb93", size = 1940159, upload-time = "2026-02-24T17:15:07.158Z" }, +] + [[package]] name = "starlette" version = "0.50.0" @@ -413,6 +491,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2025.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/c202b344c5ca7daf398f3b8a477eeb205cf3b6f32e7ec3a6bac0629ca975/tzdata-2025.3.tar.gz", hash = "sha256:de39c2ca5dc7b0344f2eba86f49d614019d29f060fc4ebc8a417896a620b56a7", size = 196772, upload-time = "2025-12-13T17:45:35.667Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c7/b0/003792df09decd6849a5e39c28b513c06e84436a54440380862b5aeff25d/tzdata-2025.3-py2.py3-none-any.whl", hash = "sha256:06a47e5700f3081aab02b2e513160914ff0694bce9947d6b76ebd6bf57cfc5d1", size = 348521, upload-time = "2025-12-13T17:45:33.889Z" }, +] + [[package]] name = "uvicorn" version = "0.40.0"