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 """ + + +
+ +