This commit is contained in:
2026-04-10 00:01:42 +03:00
parent 1b8c03d6c3
commit f01e3b9040
4 changed files with 161 additions and 95 deletions

View File

@@ -12,10 +12,10 @@ from sqlalchemy.orm import Session
from backend.db import get_db from backend.db import get_db
from backend.models import User from backend.models import User
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto") pwd_context = CryptContext(schemes=["argon2", "bcrypt"], deprecated="auto")
bearer = HTTPBearer(auto_error=False) bearer = HTTPBearer(auto_error=False)
AUTH_VERSION = "argon2-2026-04-07" AUTH_VERSION = "argon2-bcrypt-compat-2026-04-07"
def _env(name: str, default: str) -> str: def _env(name: str, default: str) -> str:
@@ -48,6 +48,10 @@ def verify_password(password: str, password_hash: str) -> bool:
) from e ) from e
def password_needs_update(password_hash: str) -> bool:
return pwd_context.needs_update(password_hash)
def create_access_token(*, user_id: str) -> str: def create_access_token(*, user_id: str) -> str:
now = datetime.now(timezone.utc) now = datetime.now(timezone.utc)
payload = { payload = {

View File

@@ -27,6 +27,7 @@ from backend.auth import ( # noqa: E402
create_access_token, create_access_token,
get_current_user, get_current_user,
hash_password, hash_password,
password_needs_update,
verify_password, verify_password,
) )
from backend.db import engine, get_db # noqa: E402 from backend.db import engine, get_db # noqa: E402
@@ -130,6 +131,11 @@ def login(req: LoginRequest, db: Session = Depends(get_db)):
if not user.is_active: if not user.is_active:
raise HTTPException(status_code=403, detail="User is inactive") raise HTTPException(status_code=403, detail="User is inactive")
if password_needs_update(user.password_hash):
user.password_hash = hash_password(req.password)
db.add(user)
db.commit()
token = create_access_token(user_id=str(user.id)) token = create_access_token(user_id=str(user.id))
return {"access_token": token, "token_type": "bearer"} return {"access_token": token, "token_type": "bearer"}
except HTTPException: except HTTPException:

View File

@@ -6,5 +6,7 @@ sqlalchemy
psycopg[binary] psycopg[binary]
passlib[argon2] passlib[argon2]
argon2-cffi argon2-cffi
passlib[bcrypt]
bcrypt
python-jose[cryptography] python-jose[cryptography]
pydantic[email] pydantic[email]

View File

@@ -4,10 +4,21 @@ from datetime import datetime, timezone
import requests import requests
import streamlit as st import streamlit as st
st.set_page_config(page_title="DevOps AI Assistant", page_icon="🚀") # В духе https://github.com/streamlit/demo-ai-assistant — крупный герой, заголовок в ряд с действием
st.set_page_config(
page_title="DevOps AI Assistant",
page_icon="",
layout="wide",
initial_sidebar_state="expanded",
)
st.title("DevOps AI Assistant") _HERO_HTML = """
st.markdown("Портал с данными и инструментами для АС/ФП.") <style>
.portal-hero-wrap { margin: 0 0 0.25rem 0; }
.portal-hero { font-size: clamp(2.5rem, 6vw, 3.5rem); line-height: 1; margin: 0; font-weight: 400; }
</style>
<div class="portal-hero-wrap"><div class="portal-hero">❉</div></div>
"""
def _env(name: str, default: str) -> str: def _env(name: str, default: str) -> str:
@@ -24,14 +35,36 @@ def _normalize_base_url(value: str) -> str:
v = v[: -len("/generate")] v = v[: -len("/generate")]
return v.rstrip("/") return v.rstrip("/")
def _title_bar(*, show_logout: bool):
st.html(_HERO_HTML)
row = st.container(horizontal=True, vertical_alignment="bottom")
with row:
st.title("DevOps AI Assistant", anchor=False)
if show_logout:
def _logout():
st.session_state.token = None
st.rerun()
st.button(
"Выйти",
icon=":material/logout:",
on_click=_logout,
help="Выйти из портала",
)
with st.sidebar: with st.sidebar:
st.header("Настройки") st.markdown("#### Настройки")
backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url)) backend_base_url = _normalize_base_url(st.text_input("Backend URL", default_backend_base_url))
model = st.selectbox( model = st.selectbox(
"LLM Модель", "LLM модель",
["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"], ["deepseek-r1:1.5b", "qwen2.5-coder:1.5b", "llama3.1:8b", "Arena Model"],
) )
platform = st.radio("Платформа", ["PV DOT CI", "PV DOT CD", "DPM CI", "DPM CD"]) platform = st.radio("Платформа", ["PV DOT CI", "PV DOT CD", "DPM CI", "DPM CD"])
st.divider()
st.caption("Портал АС/ФП · конфигурации в разделе «Инструменты»")
if "history" not in st.session_state: if "history" not in st.session_state:
st.session_state.history = [] st.session_state.history = []
@@ -67,6 +100,7 @@ def _auth_headers():
return {} return {}
return {"Authorization": f"Bearer {st.session_state.token}"} return {"Authorization": f"Bearer {st.session_state.token}"}
def _detail_or_text(resp: requests.Response) -> str: def _detail_or_text(resp: requests.Response) -> str:
try: try:
payload = resp.json() payload = resp.json()
@@ -78,15 +112,18 @@ def _detail_or_text(resp: requests.Response) -> str:
def _show_account(): def _show_account():
st.subheader("Аутентификация") _title_bar(show_logout=bool(st.session_state.token))
st.caption("Войдите или создайте учётную запись, чтобы открыть разделы портала.")
c1, c2 = st.columns(2) # Два блока с рамкой — ближе к «карточному» виду демо
c1, c2 = st.columns(2, gap="large")
with c1: with c1:
with st.container(border=True):
st.markdown("**Регистрация**") st.markdown("**Регистрация**")
reg_email = st.text_input("Email", key="reg_email") reg_email = st.text_input("Email", key="reg_email")
reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name") reg_full_name = st.text_input("Имя (опционально)", key="reg_full_name")
reg_password = st.text_input("Пароль", type="password", key="reg_password") reg_password = st.text_input("Пароль", type="password", key="reg_password")
if st.button("Создать аккаунт", key="btn_register"): if st.button("Создать аккаунт", key="btn_register", use_container_width=True):
try: try:
r = requests.post( r = requests.post(
f"{backend_base_url}/auth/register", f"{backend_base_url}/auth/register",
@@ -101,12 +138,11 @@ def _show_account():
st.error(f"Ошибка подключения к бекенду: {e}") st.error(f"Ошибка подключения к бекенду: {e}")
with c2: with c2:
with st.container(border=True):
st.markdown("**Вход**") st.markdown("**Вход**")
login_email = st.text_input("Email ", key="login_email") login_email = st.text_input("Email ", key="login_email")
login_password = st.text_input("Пароль ", type="password", key="login_password") login_password = st.text_input("Пароль ", type="password", key="login_password")
login_col_a, login_col_b = st.columns([1, 1]) if st.button("Войти", type="primary", key="btn_login", use_container_width=True):
with login_col_a:
if st.button("Войти", type="primary", key="btn_login"):
try: try:
r = requests.post( r = requests.post(
f"{backend_base_url}/auth/login", f"{backend_base_url}/auth/login",
@@ -121,10 +157,6 @@ def _show_account():
st.rerun() st.rerun()
except Exception as e: except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}") st.error(f"Ошибка подключения к бекенду: {e}")
with login_col_b:
if st.button("Выйти", disabled=not bool(st.session_state.token), key="btn_logout"):
st.session_state.token = None
st.rerun()
st.divider() st.divider()
if st.session_state.token: if st.session_state.token:
@@ -135,16 +167,20 @@ def _show_account():
st.warning("Сессия истекла или токен неверный. Войдите заново.") st.warning("Сессия истекла или токен неверный. Войдите заново.")
st.rerun() st.rerun()
else: else:
with st.expander("Профиль", expanded=False):
st.json(r.json()) st.json(r.json())
except Exception as e: except Exception as e:
st.error(f"Ошибка подключения к бекенду: {e}") st.error(f"Ошибка подключения к бекенду: {e}")
# Auth wall: пока пользователь не вошёл — показываем только экран входа/регистрации. # Auth wall
if not st.session_state.token: if not st.session_state.token:
_show_account() _show_account()
st.stop() st.stop()
# После входа — тот же заголовок + вкладки
_title_bar(show_logout=True)
st.caption("Портал с данными и инструментами для АС/ФП.")
tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs( tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
[ [
@@ -158,29 +194,37 @@ tab_info, tab_autonomous, tab_infra, tab_tools, tab_news = st.tabs(
with tab_info: with tab_info:
st.subheader("Сведения об АС/ФП") st.subheader("Сведения об АС/ФП")
st.caption("Черновик формы. Дальше можно привязать к Postgres и хранить как сущность.") st.caption("Черновик формы. Дальше можно привязать к Postgres.")
with st.container(border=True):
st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"]) st.session_state.asfp_info["name"] = st.text_input("Название", st.session_state.asfp_info["name"])
st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"]) st.session_state.asfp_info["owner"] = st.text_input("Владелец", st.session_state.asfp_info["owner"])
st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"]) st.session_state.asfp_info["contacts"] = st.text_input("Контакты", st.session_state.asfp_info["contacts"])
st.session_state.asfp_info["description"] = st.text_area( st.session_state.asfp_info["description"] = st.text_area(
"Описание", st.session_state.asfp_info["description"], height=120 "Описание", st.session_state.asfp_info["description"], height=120
) )
st.divider() with st.expander("JSON", expanded=False):
st.json(st.session_state.asfp_info) st.json(st.session_state.asfp_info)
with tab_autonomous: with tab_autonomous:
st.subheader("Автономные внедрения") st.subheader("Автономные внедрения")
st.info("Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты.") with st.container(border=True):
st.info(
"Сюда можно добавить список внедрений, статус, даты, ответственных и ссылки на релизы/тикеты."
)
with tab_infra: with tab_infra:
st.subheader("Инфраструктура АС/ФП") st.subheader("Инфраструктура АС/ФП")
st.info("Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости, секреты и т.д.") with st.container(border=True):
st.info(
"Сюда можно добавить окружения, кластера, сервисы, ресурсы, сетевые зависимости и т.д."
)
with tab_tools: with tab_tools:
st.subheader("Инструменты") st.subheader("Инструменты")
st.caption(f"Backend URL: {backend_base_url}") st.caption(f"Backend: `{backend_base_url}`")
st.markdown("### Pipeline generator") with st.container(border=True):
st.markdown("#### Pipeline generator")
user_input = st.text_area( user_input = st.text_area(
"Опишите пайплайн (например: 'Python app with pytest and docker build')", "Опишите пайплайн (например: 'Python app with pytest and docker build')",
height=150, height=150,
@@ -189,9 +233,9 @@ with tab_tools:
col_a, col_b = st.columns([1, 3]) col_a, col_b = st.columns([1, 3])
with col_a: with col_a:
generate_clicked = st.button("Сгенерировать конфигурацию", type="primary") generate_clicked = st.button("Сгенерировать", type="primary", use_container_width=True)
with col_b: with col_b:
st.caption("Подсказка: чем конкретнее требования, тем валиднее YAML.") st.caption("Чем конкретнее требования, тем стабильнее YAML.")
if generate_clicked: if generate_clicked:
if user_input.strip(): if user_input.strip():
@@ -233,4 +277,14 @@ with tab_tools:
with tab_news: with tab_news:
st.subheader("Новости") st.subheader("Новости")
st.info("Сюда можно вывести новости/изменения: релизы, инциденты, регламентные работы, обновления инструментов.") with st.container(border=True):
st.info(
"Релизы, инциденты, регламентные работы, обновления инструментов — сюда можно вывести ленту позже."
)
st.divider()
st.button(
"&nbsp;:small[:gray[:material/balance: О портале]]",
type="tertiary",
help="DevOps AI Assistant — внутренний портал.",
)