From a1dd1a32795857441c4462b2a5f5668d601d417b Mon Sep 17 00:00:00 2001 From: jze9 Date: Sun, 1 Mar 2026 21:40:09 +0500 Subject: [PATCH] fers web --- Dockerfile => Dockerfile.api | 0 Dockerfile.web | 3 + docker-compose.yml | 14 ++++- main.py | 13 +++- route/init_data_base.py | 38 ++++++++++++ route/response_crud.py | 12 ++++ route/ui.py | 111 ----------------------------------- route/users_crud.py | 21 +++++-- web/app.js | 80 +++++++++++++++++++++++++ web/index.html | 13 ++++ 10 files changed, 186 insertions(+), 119 deletions(-) rename Dockerfile => Dockerfile.api (100%) create mode 100644 Dockerfile.web delete mode 100644 route/ui.py create mode 100644 web/app.js create mode 100644 web/index.html diff --git a/Dockerfile b/Dockerfile.api similarity index 100% rename from Dockerfile rename to Dockerfile.api diff --git a/Dockerfile.web b/Dockerfile.web new file mode 100644 index 0000000..1e6fb46 --- /dev/null +++ b/Dockerfile.web @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY . /usr/share/nginx/html +EXPOSE 80 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml index a3959a7..f454995 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,6 +1,8 @@ services: api: - build: . + build: + context: . + dockerfile: Dockerfile.api ports: - "8000:8000" environment: @@ -19,3 +21,13 @@ services: - REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn volumes: - ./data:/app/data + web: + build: + context: ./web + dockerfile: ../Dockerfile.web + ports: + - "3000:80" + depends_on: + - api + volumes: + - ./web:/usr/share/nginx/html:ro diff --git a/main.py b/main.py index ccc41f0..4d74a51 100644 --- a/main.py +++ b/main.py @@ -2,7 +2,8 @@ import os import tomllib from pathlib import Path from fastapi import FastAPI -from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud, ui +from fastapi.middleware.cors import CORSMiddleware +from route import base, holland_crud, init_data_base, groups_crud, organizations_crud, users_crud, question_crud, choice_crud, response_crud, poll_crud import uvicorn # Читаем версию из pyproject.toml @@ -20,6 +21,14 @@ except Exception as e: APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp") app = FastAPI(title="api", version=VERSION) +# Разрешаем CORS для фронтенда (dev). При необходимости сузить список origins. +app.add_middleware( + CORSMiddleware, + allow_origins=["http://localhost:3000", "http://127.0.0.1:3000"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) app.include_router(base.router) app.include_router(init_data_base.router) app.include_router(groups_crud.router) @@ -30,7 +39,7 @@ 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 для использования в приложении diff --git a/route/init_data_base.py b/route/init_data_base.py index 12b9732..d387e82 100644 --- a/route/init_data_base.py +++ b/route/init_data_base.py @@ -7,6 +7,7 @@ from typing import List from bd import Settings from sqlalchemy import create_engine from sqlalchemy.exc import SQLAlchemyError +from pydantic import BaseModel router = APIRouter(tags=["db"]) @@ -67,3 +68,40 @@ async def create_tables(): return {"status": "ok", "detail": f"Created {len(unique)} metadata groups"} except SQLAlchemyError as e: raise HTTPException(status_code=500, detail=str(e)) + + +class ClearDBIn(BaseModel): + confirm: bool + + +@router.post("/db/clear") +async def clear_tables(payload: ClearDBIn): + """Полная очистка всех таблиц, описанных в `bd.tables`. + + Требуется явное подтверждение: POST с телом {"confirm": true}. + """ + if not payload.confirm: + raise HTTPException(status_code=400, detail="Confirmation required") + + 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 + 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.drop_all(bind=engine) + return {"status": "ok", "detail": f"Dropped {len(unique)} metadata groups"} + except SQLAlchemyError as e: + raise HTTPException(status_code=500, detail=str(e)) diff --git a/route/response_crud.py b/route/response_crud.py index 46b5387..57f7e92 100644 --- a/route/response_crud.py +++ b/route/response_crud.py @@ -95,3 +95,15 @@ def get_response(response_id: str): 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]} + + +@router.get("/users/{user_id}/responses") +def list_responses_by_user(user_id: str): + Session = get_session() + try: + uid = uuid.UUID(user_id) + except Exception: + raise HTTPException(status_code=400, detail="Invalid user UUID") + with Session() as session: + rows = session.query(Response).filter(Response.user_id == uid).all() + return [{"id": str(r.id), "poll_id": str(r.poll_id), "submitted_at": r.submitted_at.isoformat()} for r in rows] diff --git a/route/ui.py b/route/ui.py deleted file mode 100644 index 5ce96b2..0000000 --- a/route/ui.py +++ /dev/null @@ -1,111 +0,0 @@ -from fastapi import APIRouter, Request - -router = APIRouter() - - -@router.get("/ui/create-user") -def create_user_page(request: Request): - return """ - - - - - Create user & Take test - - -

Create user

-
-
-
- -
- -

Or create Holland test

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

Take test

-
- - - - -""" diff --git a/route/users_crud.py b/route/users_crud.py index 0919de1..3134f3c 100644 --- a/route/users_crud.py +++ b/route/users_crud.py @@ -13,8 +13,9 @@ router = APIRouter(tags=["users"], prefix="/users") class UserCreate(BaseModel): - first_name: str - last_name: str + first_name: Optional[str] = None + last_name: Optional[str] = None + name: Optional[str] = None class UserUpdate(BaseModel): @@ -28,11 +29,21 @@ def get_session(): return sessionmaker(bind=engine, future=True) -@router.post("/", status_code=201) +@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) + # Support payload with 'name' (single field) or first_name+last_name + first = payload.first_name + last = payload.last_name + if not (first or last) and payload.name: + parts = payload.name.strip().split() + if parts: + first = parts[0] + last = " ".join(parts[1:]) if len(parts) > 1 else "" + if not first and not last: + raise HTTPException(status_code=422, detail="Missing name or first_name/last_name") + user = User(first_name=first or "", last_name=last or "") session.add(user) session.commit() session.refresh(user) @@ -76,7 +87,7 @@ def delete_user(user_id: str): return {} -@router.get("/") +@router.get("") def list_users(): Session = get_session() with Session() as session: diff --git a/web/app.js b/web/app.js new file mode 100644 index 0000000..180a667 --- /dev/null +++ b/web/app.js @@ -0,0 +1,80 @@ +const API_BASE = `${location.protocol}//${location.hostname}:8000`; + +const app = document.getElementById('app'); + +function render(html){ app.innerHTML = html } + +function showAgreement(){ + render(`

Соглашение

Нажмите продолжить, чтобы зарегистрироваться и пройти тест.

`); + document.getElementById('agree').onclick = ()=> showRegister(); +} + +function showRegister(){ + render(`

Регистрация

+
+
+
+ +
+
`); + document.getElementById('reg').onsubmit = async (e)=>{ + e.preventDefault(); + const fd = new FormData(e.target); + const first = (fd.get('firstname') || '').trim(); + const last = (fd.get('lastname') || '').trim(); + const data = {first_name: first, last_name: last}; + const res = await fetch(`${API_BASE}/users/`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(data)}); + if(!res.ok){ document.getElementById('result').innerText = 'Ошибка создания'; return } + const user = await res.json(); + localStorage.setItem('user_id', user.id); + showPollList(); + } +} + +async function showPollList(){ + const res = await fetch(`${API_BASE}/polls`); + const polls = res.ok ? await res.json() : []; + render(`

Доступные тесты

`); + document.getElementById('refresh').onclick = showPollList; + document.querySelectorAll('#polls a').forEach(a=>a.onclick = (ev)=>{ ev.preventDefault(); const id = a.getAttribute('href').slice(6); showPoll(id); }); +} + +async function showPoll(id){ + const res = await fetch(`${API_BASE}/polls/${id}`); + if(!res.ok){ render('

Не удалось загрузить тест

'); return } + const poll = await res.json(); + // simple rendering: list questions and choices (if present) + const html = [`

${poll.title||poll.name||'Тест'}

`]; + poll.questions = poll.questions || []; + html.push(`
`); + poll.questions.forEach(q=>{ + html.push(`
${q.text||q.prompt||q.id}`); + const choices = q.choices || []; + if(choices.length){ + choices.forEach(c=> html.push(`
`)); + } else { + html.push(`
`); + } + html.push('
'); + }); + html.push('
'); + html.push('
'); + render(html.join('')); + document.getElementById('resp').onsubmit = async (e)=>{ + e.preventDefault(); + const form = new FormData(e.target); + const answers = []; + for(const [k,v] of form.entries()){ + if(!k.startsWith('q_')) continue; + const qid = k.slice(2); + answers.push({question_id: qid, choice_id: v}); + } + const payload = {user_id: localStorage.getItem('user_id'), answers}; + const r = await fetch(`${API_BASE}/polls/${id}/responses`, {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}); + if(!r.ok){ document.getElementById('msg').innerText = 'Ошибка отправки'; return } + document.getElementById('msg').innerText = 'Ответы отправлены'; + } +} + +// start +showAgreement(); \ No newline at end of file diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..760ca3b --- /dev/null +++ b/web/index.html @@ -0,0 +1,13 @@ + + + + + + API-Client UI + + + +
+ + + \ No newline at end of file