fix data base

This commit is contained in:
2026-02-27 15:37:15 +05:00
parent 335db59c9c
commit d37c255994
27 changed files with 1372 additions and 11 deletions

View File

@@ -1,13 +1,15 @@
# Database configuration package
from pydantic.generics import GenericModel from pydantic.generics import GenericModel
import re import re
import os import os
import urllib.parse
class Settings(GenericModel): class Settings(GenericModel):
DB_HOST: str = os.getenv("DB_HOST", "postgres") DB_HOST: str = os.getenv("DB_HOST", "postgres")
DB_PORT: int = int(os.getenv("DB_PORT", "5432")) DB_PORT: int = int(os.getenv("DB_PORT", "5432"))
DB_USER: str = os.getenv("DB_USER", "postgres") DB_USER: str = os.getenv("DB_USER", "postgres")
DB_PASS: str = os.getenv("DB_PASS", "") DB_PASS: str = os.getenv("DB_PASS", "")
DB_NAME: str = os.getenv("DB_NAME", "syte") DB_NAME: str = os.getenv("DB_NAME", "profi")
@classmethod @classmethod
def validate_db_port(cls, v): def validate_db_port(cls, v):
@@ -23,8 +25,10 @@ class Settings(GenericModel):
@property @property
def DATABASE_URL_asyncpg(self) -> str: 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 @property
def DATABASE_URL_syncpg(self) -> str: 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}"

16
bd/tables/choice.py Normal file
View File

@@ -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"]

17
bd/tables/group.py Normal file
View File

@@ -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"]

17
bd/tables/organization.py Normal file
View File

@@ -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"]

21
bd/tables/poll.py Normal file
View File

@@ -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"]

20
bd/tables/question.py Normal file
View File

@@ -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"]

30
bd/tables/response.py Normal file
View File

@@ -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"]

3
bd/tables/simple_base.py Normal file
View File

@@ -0,0 +1,3 @@
from sqlalchemy.orm import declarative_base
Base = declarative_base()

14
bd/tables/users.py Normal file
View File

@@ -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"]

View File

@@ -1,5 +1,3 @@
version: '3.8'
services: services:
api: api:
build: . build: .
@@ -12,7 +10,12 @@ services:
- DB_HOST=192.168.1.11 - DB_HOST=192.168.1.11
- DB_PORT=5432 - DB_PORT=5432
- DB_USER=admin1 - DB_USER=admin1
- DB_PASS=_NuUXNv*P+#;4v7 - DB_PASS=Y6%r35RpckeP^F
- DB_NAME=syte - DB_NAME=profi
# Redis connection (external)
- REDIS_HOST=192.168.1.19
- REDIS_PORT=6379
- REDIS_DB=0
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
volumes: volumes:
- ./data:/app/data - ./data:/app/data

13
main.py
View File

@@ -2,7 +2,7 @@ import os
import tomllib import tomllib
from pathlib import Path from pathlib import Path
from fastapi import FastAPI 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 import uvicorn
# Читаем версию из pyproject.toml # Читаем версию из pyproject.toml
@@ -21,6 +21,17 @@ APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
app = FastAPI(title="api", version=VERSION) app = FastAPI(title="api", version=VERSION)
app.include_router(base.router) 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 для использования в приложении # Доступна переменная APP_PATH для использования в приложении
print(f"Application path: {APP_PATH}") print(f"Application path: {APP_PATH}")

View File

@@ -16,6 +16,9 @@ dependencies = [
"uvicorn[standard]>=0.40.0", "uvicorn[standard]>=0.40.0",
"pydantic>=2.12.5", "pydantic>=2.12.5",
"python-dotenv>=1.2.1", "python-dotenv>=1.2.1",
"redis>=7.1.0",
"sqlalchemy>=2.0.47",
"psycopg>=3.3.3",
] ]
[project.optional-dependencies] [project.optional-dependencies]

62
redis_db/__init__.py Normal file
View File

@@ -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"]

View File

@@ -8,7 +8,10 @@ idna==3.11
pydantic==2.12.5 pydantic==2.12.5
pydantic_core==2.41.5 pydantic_core==2.41.5
python-dotenv==1.2.1 python-dotenv==1.2.1
redis==5.0.0
starlette==0.50.0 starlette==0.50.0
typing-inspection==0.4.2 typing-inspection==0.4.2
typing_extensions==4.15.0 typing_extensions==4.15.0
uvicorn==0.40.0 uvicorn==0.40.0
sqlalchemy==2.0.32
pg8000==1.29.0

1
route/__init__.py Normal file
View File

@@ -0,0 +1 @@
# Routes package

View File

@@ -1,6 +1,10 @@
import tomllib import tomllib
from pathlib import Path 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 для FastAPI
router = APIRouter(tags=["base"]) router = APIRouter(tags=["base"])
@@ -26,3 +30,35 @@ async def health():
@router.get("/version") @router.get("/version")
async def version(): async def version():
return {"version": get_version()} 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}")

114
route/choice_crud.py Normal file
View File

@@ -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)}

113
route/groups_crud.py Normal file
View File

@@ -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 {}

85
route/holland_crud.py Normal file
View File

@@ -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)}

69
route/init_data_base.py Normal file
View File

@@ -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))

113
route/organizations_crud.py Normal file
View File

@@ -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 {}

95
route/poll_crud.py Normal file
View File

@@ -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]

118
route/question_crud.py Normal file
View File

@@ -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)}

97
route/response_crud.py Normal file
View File

@@ -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]}

111
route/ui.py Normal file
View File

@@ -0,0 +1,111 @@
from fastapi import APIRouter, Request
router = APIRouter()
@router.get("/ui/create-user")
def create_user_page(request: Request):
return """
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Create user & Take test</title>
</head>
<body>
<h1>Create user</h1>
<form id="userForm">
<label>First name: <input name="first_name" required></label><br>
<label>Last name: <input name="last_name" required></label><br>
<button type="submit">Create user</button>
</form>
<h2>Or create Holland test</h2>
<button id="createHolland">Create Holland test</button>
<script>
document.getElementById('userForm').addEventListener('submit', async (e) => {
e.preventDefault();
const fd = new FormData(e.target);
const data = { first_name: fd.get('first_name'), last_name: fd.get('last_name') };
const res = await fetch('/users/', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(data) });
if (!res.ok) { alert('Create user failed'); return; }
const js = await res.json();
const uid = js.id;
alert('User created: ' + uid);
// allow user to input poll id manually
const poll = prompt('Enter poll id to take (or leave empty to create Holland test)');
if (poll) {
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(poll) + '&user_id=' + encodeURIComponent(uid);
} else {
// create Holland test then redirect
const r2 = await fetch('/polls/holland', { method: 'POST' });
const p = await r2.json();
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(p.id) + '&user_id=' + encodeURIComponent(uid);
}
});
document.getElementById('createHolland').addEventListener('click', async () => {
const r = await fetch('/polls/holland', { method: 'POST' });
if (!r.ok) { alert('Create failed'); return; }
const p = await r.json();
alert('Created poll ' + p.id);
location.href = '/ui/take-test?poll_id=' + encodeURIComponent(p.id);
});
</script>
</body>
</html>
"""
@router.get("/ui/take-test")
def take_test_page(request: Request, poll_id: str = None, user_id: str = None):
return f"""
<!doctype html>
<html>
<head><meta charset="utf-8"><title>Take test</title></head>
<body>
<h1>Take test</h1>
<div id="poll"></div>
<button id="submitBtn">Submit</button>
<script>
const pollId = '{poll_id or ''}';
const userId = '{user_id or ''}';
async function load() {{
if (!pollId) {{ document.getElementById('poll').innerText = 'No poll_id provided'; return; }}
const r = await fetch('/polls/' + encodeURIComponent(pollId));
if (!r.ok) {{ document.getElementById('poll').innerText = 'Failed to load poll'; return; }}
const p = await r.json();
const container = document.getElementById('poll');
const h = document.createElement('h2'); h.innerText = p.title; container.appendChild(h);
p.questions.forEach(q => {{
const div = document.createElement('div');
div.innerHTML = '<p>' + q.text + '</p>';
q.choices.forEach(c => {{
const id = 'q_' + q.id + '_c_' + c.id;
const input = `<label><input type="radio" name="${{q.id}}" value="${{c.id}}"> ${{c.text}}</label><br>`;
div.insertAdjacentHTML('beforeend', input);
}});
container.appendChild(div);
}});
}}
load();
document.getElementById('submitBtn').addEventListener('click', async () => {{
const answers = [];
const groups = new Set();
document.querySelectorAll('input[type=radio]').forEach(r => groups.add(r.name));
groups.forEach(name => {{
const checked = document.querySelector('input[name="' + name + '"]:checked');
if (checked) answers.push({{question_id: name, choice_id: checked.value}});
}});
const payload = {{ user_id: userId || null, answers }};
const res = await fetch('/polls/' + encodeURIComponent(pollId) + '/responses', {{ method: 'POST', headers: {{'Content-Type':'application/json'}}, body: JSON.stringify(payload) }});
if (!res.ok) {{ alert('Submit failed'); return; }}
const js = await res.json();
alert('Response saved: ' + js.id);
}});
</script>
</body>
</html>
"""

98
route/users_crud.py Normal file
View File

@@ -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}

89
uv.lock generated
View File

@@ -34,12 +34,15 @@ wheels = [
[[package]] [[package]]
name = "api-copp" name = "api-copp"
version = "0.1.0" version = "0.3.0"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastapi" }, { name = "fastapi" },
{ name = "psycopg" },
{ name = "pydantic" }, { name = "pydantic" },
{ name = "python-dotenv" }, { name = "python-dotenv" },
{ name = "redis" },
{ name = "sqlalchemy" },
{ name = "uvicorn", extra = ["standard"] }, { name = "uvicorn", extra = ["standard"] },
] ]
@@ -55,11 +58,14 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" },
{ name = "fastapi", specifier = ">=0.128.0" }, { name = "fastapi", specifier = ">=0.128.0" },
{ name = "psycopg", specifier = ">=3.3.3" },
{ name = "pydantic", specifier = ">=2.12.5" }, { name = "pydantic", specifier = ">=2.12.5" },
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" },
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" }, { name = "python-dotenv", specifier = ">=1.2.1" },
{ name = "redis", specifier = ">=7.1.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.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" }, { name = "uvicorn", extras = ["standard"], specifier = ">=0.40.0" },
] ]
provides-extras = ["dev"] 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" }, { 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]] [[package]]
name = "h11" name = "h11"
version = "0.16.0" 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" }, { 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]] [[package]]
name = "pydantic" name = "pydantic"
version = "2.12.5" 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" }, { 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]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.14" 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" }, { 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]] [[package]]
name = "starlette" name = "starlette"
version = "0.50.0" 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" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.40.0" version = "0.40.0"