fers web
This commit is contained in:
3
Dockerfile.web
Normal file
3
Dockerfile.web
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
FROM nginx:alpine
|
||||||
|
COPY . /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile.api
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "8000:8000"
|
||||||
environment:
|
environment:
|
||||||
@@ -19,3 +21,13 @@ services:
|
|||||||
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
- REDIS_PASSWORD=CNXpuhMdxXHo7ZK8bhtXDvgXVZcjRn
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
|
web:
|
||||||
|
build:
|
||||||
|
context: ./web
|
||||||
|
dockerfile: ../Dockerfile.web
|
||||||
|
ports:
|
||||||
|
- "3000:80"
|
||||||
|
depends_on:
|
||||||
|
- api
|
||||||
|
volumes:
|
||||||
|
- ./web:/usr/share/nginx/html:ro
|
||||||
|
|||||||
13
main.py
13
main.py
@@ -2,7 +2,8 @@ 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, 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
|
import uvicorn
|
||||||
|
|
||||||
# Читаем версию из pyproject.toml
|
# Читаем версию из pyproject.toml
|
||||||
@@ -20,6 +21,14 @@ except Exception as e:
|
|||||||
APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
|
APP_PATH = os.getenv("APP_PATH", "/home/user/api-copp")
|
||||||
|
|
||||||
app = FastAPI(title="api", version=VERSION)
|
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(base.router)
|
||||||
app.include_router(init_data_base.router)
|
app.include_router(init_data_base.router)
|
||||||
app.include_router(groups_crud.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(response_crud.router)
|
||||||
app.include_router(poll_crud.router)
|
app.include_router(poll_crud.router)
|
||||||
app.include_router(holland_crud.router)
|
app.include_router(holland_crud.router)
|
||||||
app.include_router(ui.router)
|
|
||||||
|
|
||||||
|
|
||||||
# Доступна переменная APP_PATH для использования в приложении
|
# Доступна переменная APP_PATH для использования в приложении
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from typing import List
|
|||||||
from bd import Settings
|
from bd import Settings
|
||||||
from sqlalchemy import create_engine
|
from sqlalchemy import create_engine
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
router = APIRouter(tags=["db"])
|
router = APIRouter(tags=["db"])
|
||||||
|
|
||||||
@@ -67,3 +68,40 @@ async def create_tables():
|
|||||||
return {"status": "ok", "detail": f"Created {len(unique)} metadata groups"}
|
return {"status": "ok", "detail": f"Created {len(unique)} metadata groups"}
|
||||||
except SQLAlchemyError as e:
|
except SQLAlchemyError as e:
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
|
|||||||
@@ -95,3 +95,15 @@ def get_response(response_id: str):
|
|||||||
if not r:
|
if not r:
|
||||||
raise HTTPException(status_code=404, detail="Response not found")
|
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]}
|
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]
|
||||||
|
|||||||
111
route/ui.py
111
route/ui.py
@@ -1,111 +0,0 @@
|
|||||||
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>
|
|
||||||
"""
|
|
||||||
@@ -13,8 +13,9 @@ router = APIRouter(tags=["users"], prefix="/users")
|
|||||||
|
|
||||||
|
|
||||||
class UserCreate(BaseModel):
|
class UserCreate(BaseModel):
|
||||||
first_name: str
|
first_name: Optional[str] = None
|
||||||
last_name: str
|
last_name: Optional[str] = None
|
||||||
|
name: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
class UserUpdate(BaseModel):
|
class UserUpdate(BaseModel):
|
||||||
@@ -28,11 +29,21 @@ def get_session():
|
|||||||
return sessionmaker(bind=engine, future=True)
|
return sessionmaker(bind=engine, future=True)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/", status_code=201)
|
@router.post("", status_code=201)
|
||||||
def create_user(payload: UserCreate):
|
def create_user(payload: UserCreate):
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
with Session() as 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.add(user)
|
||||||
session.commit()
|
session.commit()
|
||||||
session.refresh(user)
|
session.refresh(user)
|
||||||
@@ -76,7 +87,7 @@ def delete_user(user_id: str):
|
|||||||
return {}
|
return {}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/")
|
@router.get("")
|
||||||
def list_users():
|
def list_users():
|
||||||
Session = get_session()
|
Session = get_session()
|
||||||
with Session() as session:
|
with Session() as session:
|
||||||
|
|||||||
80
web/app.js
Normal file
80
web/app.js
Normal file
@@ -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(`<h2>Соглашение</h2><p>Нажмите продолжить, чтобы зарегистрироваться и пройти тест.</p><button id=agree>Продолжить</button>`);
|
||||||
|
document.getElementById('agree').onclick = ()=> showRegister();
|
||||||
|
}
|
||||||
|
|
||||||
|
function showRegister(){
|
||||||
|
render(`<h2>Регистрация</h2>
|
||||||
|
<form id=reg>
|
||||||
|
<label>Имя: <input name=firstname required></label><br>
|
||||||
|
<label>Фамилия: <input name=lastname required></label><br>
|
||||||
|
<button type=submit>Создать пользователя</button>
|
||||||
|
</form>
|
||||||
|
<div id=result></div>`);
|
||||||
|
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(`<h2>Доступные тесты</h2><ul id=polls>${polls.map(p=>`<li><a href=#poll-${p.id}>${p.title||p.name||p.id}</a></li>`).join('')}</ul><div id=actions><button id=refresh>Обновить</button></div>`);
|
||||||
|
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('<p>Не удалось загрузить тест</p>'); return }
|
||||||
|
const poll = await res.json();
|
||||||
|
// simple rendering: list questions and choices (if present)
|
||||||
|
const html = [`<h2>${poll.title||poll.name||'Тест'}</h2>`];
|
||||||
|
poll.questions = poll.questions || [];
|
||||||
|
html.push(`<form id=resp>`);
|
||||||
|
poll.questions.forEach(q=>{
|
||||||
|
html.push(`<div><strong>${q.text||q.prompt||q.id}</strong>`);
|
||||||
|
const choices = q.choices || [];
|
||||||
|
if(choices.length){
|
||||||
|
choices.forEach(c=> html.push(`<div><label><input type=radio name="q_${q.id}" value="${c.id}" required> ${c.text||c.label||c.id}</label></div>`));
|
||||||
|
} else {
|
||||||
|
html.push(`<div><input name="q_${q.id}" placeholder="Ваш ответ"></div>`);
|
||||||
|
}
|
||||||
|
html.push('</div>');
|
||||||
|
});
|
||||||
|
html.push('<button type=submit>Отправить ответы</button></form>');
|
||||||
|
html.push('<div id=msg></div>');
|
||||||
|
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();
|
||||||
13
web/index.html
Normal file
13
web/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||||
|
<title>API-Client UI</title>
|
||||||
|
<style>body{font-family:Arial,Helvetica,sans-serif;max-width:720px;margin:24px auto;padding:0 12px}button{margin-top:8px}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Reference in New Issue
Block a user