move to streamlit
This commit is contained in:
commit
a295417287
11
.gitignore
vendored
Normal file
11
.gitignore
vendored
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
__pycache__/
|
||||||
|
.vscode/
|
||||||
|
.idea/
|
||||||
|
.cursor/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
9
.gitlab-ci.yml
Normal file
9
.gitlab-ci.yml
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
include:
|
||||||
|
- project: 'cicd/ci'
|
||||||
|
file: 'streamlit-app.yml'
|
||||||
|
variables:
|
||||||
|
GIT_STRATEGY: 'clone'
|
||||||
|
APP_VERSION: '1.0.0'
|
||||||
|
APP_PORT: 8501
|
||||||
|
APP_SHARED: 1
|
||||||
|
BASE_IMAGE: 'streamlit-app-base:2.1.0'
|
||||||
47
README.md
Normal file
47
README.md
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
[](https://github.com/psf/black) [](https://pycqa.github.io/isort/) [](https://github.com/pylint-dev/pylint)
|
||||||
|
__________________________________________________________________________________________________________
|
||||||
|
# sfm, v 0.0.1
|
||||||
|
__sfm__
|
||||||
|
|
||||||
|
| | Исполнитель | Заказчик |
|
||||||
|
|-----------|:----------------------------------------:|:----------------------------------------------:|
|
||||||
|
| Сотрудник | raykov-mse | raykov-mse |
|
||||||
|
| Отдел | aurora | aurora |
|
||||||
|
| Контакты | raykovmse@rshb.ru | raykovmse@rshb.ru |
|
||||||
|
-----
|
||||||
|
### Структура проекта:
|
||||||
|

|
||||||
|
### Рабочий процесс:
|
||||||
|
1. Клонируем репозиторий
|
||||||
|
```commandline
|
||||||
|
git@<gitlab_url>:<group_name>/<project_name>.git
|
||||||
|
```
|
||||||
|
2. Создаем виртуальное окружение
|
||||||
|
```commandline
|
||||||
|
python -m venv venv
|
||||||
|
source venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
Если что-то устанавливаем, не забываем обновлять список зависимостей
|
||||||
|
```commandline
|
||||||
|
pip freeze -l > requirements.txt
|
||||||
|
```
|
||||||
|
3. Создаем feature ветку
|
||||||
|
```commandline
|
||||||
|
git checkout -b "feature-<task_number>-<short_task_description>"
|
||||||
|
```
|
||||||
|
4. Пишем свой код. Коммитим изменения
|
||||||
|
```commandline
|
||||||
|
git add .
|
||||||
|
git commit -m "<commit_message>"
|
||||||
|
```
|
||||||
|
Как писать хорошие коммит сообщения:
|
||||||
|
https://www.conventionalcommits.org/ru/v1.0.0/
|
||||||
|
|
||||||
|
5. Пушим изменения в Gitlab
|
||||||
|
```commandline
|
||||||
|
git push origin
|
||||||
|
```
|
||||||
|
6. Создаем Merge Request в master
|
||||||
|
7. Проходим код-ревью с коллегами
|
||||||
|
8. Мерджите
|
||||||
2
app/__init__.py
Normal file
2
app/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""Пакет приложения SFM Streamlit."""
|
||||||
|
|
||||||
2
app/auth/__init__.py
Normal file
2
app/auth/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
"""Вспомогательные модули аутентификации SFM Streamlit."""
|
||||||
|
|
||||||
34
app/auth/mock_provider.py
Normal file
34
app/auth/mock_provider.py
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
|
||||||
|
def protect_application(render_exit: bool = True) -> None:
|
||||||
|
_ = render_exit
|
||||||
|
|
||||||
|
|
||||||
|
def get_jwt_token() -> dict[str, str] | None:
|
||||||
|
email = st.session_state.get("local_user_email")
|
||||||
|
username = st.session_state.get("local_username")
|
||||||
|
full_name = st.session_state.get("local_full_name")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
cols = st.columns([1, 2, 1])
|
||||||
|
with cols[1]:
|
||||||
|
with st.container(border=True):
|
||||||
|
st.info("Локальный режим: введите профиль пользователя")
|
||||||
|
email = st.text_input("Email", key="local_user_email")
|
||||||
|
username = st.text_input("Username", key="local_username")
|
||||||
|
full_name = st.text_input("Full name", key="local_full_name")
|
||||||
|
|
||||||
|
if not email:
|
||||||
|
st.stop()
|
||||||
|
|
||||||
|
st.session_state["local_mock_profile"] = {
|
||||||
|
"email": email,
|
||||||
|
"preferred_username": username or email.split("@")[0],
|
||||||
|
"name": full_name or username or email,
|
||||||
|
"realm_access": {"roles": ["local_dev"]},
|
||||||
|
}
|
||||||
|
return {"id_token": "local-dev-token"}
|
||||||
|
|
||||||
66
app/auth/providers.py
Normal file
66
app/auth/providers.py
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib.util import find_spec
|
||||||
|
from typing import Any, Protocol
|
||||||
|
|
||||||
|
from streamlit_oauth import OAuth2Component
|
||||||
|
|
||||||
|
from app.config import get_location, get_setting
|
||||||
|
|
||||||
|
from .mock_provider import get_jwt_token as get_jwt_token_local
|
||||||
|
from .mock_provider import protect_application as protect_application_local
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProvider(Protocol):
|
||||||
|
def get_token_data(self) -> dict[str, Any] | None: ...
|
||||||
|
|
||||||
|
|
||||||
|
class LocalMockProvider:
|
||||||
|
def get_token_data(self) -> dict[str, Any] | None:
|
||||||
|
protect_application_local(render_exit=False)
|
||||||
|
return get_jwt_token_local()
|
||||||
|
|
||||||
|
|
||||||
|
class RaisaProvider:
|
||||||
|
def get_token_data(self) -> dict[str, Any] | None:
|
||||||
|
from raisa_streamlit_oauth import ( # type: ignore[reportMissingImports]
|
||||||
|
get_jwt_token as get_jwt_token_raisa,
|
||||||
|
)
|
||||||
|
from raisa_streamlit_oauth import ( # type: ignore[reportMissingImports]
|
||||||
|
protect_application as protect_application_raisa,
|
||||||
|
)
|
||||||
|
|
||||||
|
protect_application_raisa(render_exit=False)
|
||||||
|
return get_jwt_token_raisa()
|
||||||
|
|
||||||
|
|
||||||
|
class OAuthFallbackProvider:
|
||||||
|
def get_token_data(self) -> dict[str, Any] | None:
|
||||||
|
oauth2 = OAuth2Component(
|
||||||
|
client_id=get_setting("KEYCLOAK_CLIENT_ID"),
|
||||||
|
client_secret=get_setting("KEYCLOAK_CLIENT_SECRET"),
|
||||||
|
authorize_endpoint=get_setting("KEYCLOAK_AUTHORIZE_URL"),
|
||||||
|
token_endpoint=get_setting("KEYCLOAK_TOKEN_URL"),
|
||||||
|
refresh_token_endpoint=get_setting("KEYCLOAK_TOKEN_URL"),
|
||||||
|
revoke_token_endpoint=get_setting("KEYCLOAK_LOGOUT_URL"),
|
||||||
|
)
|
||||||
|
result = oauth2.authorize_button(
|
||||||
|
name="Войти через Keycloak",
|
||||||
|
redirect_uri=get_setting("KEYCLOAK_REDIRECT_URI"),
|
||||||
|
scope=get_setting("KEYCLOAK_SCOPE"),
|
||||||
|
key="keycloak-login",
|
||||||
|
use_container_width=True,
|
||||||
|
pkce="S256",
|
||||||
|
)
|
||||||
|
if result and "token" in result:
|
||||||
|
return result["token"]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def build_auth_provider() -> AuthProvider:
|
||||||
|
if get_location() == "local":
|
||||||
|
return LocalMockProvider()
|
||||||
|
if find_spec("raisa_streamlit_oauth") is not None:
|
||||||
|
return RaisaProvider()
|
||||||
|
return OAuthFallbackProvider()
|
||||||
|
|
||||||
50
app/auth/service.py
Normal file
50
app/auth/service.py
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
import jwt
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.config import get_location, get_setting
|
||||||
|
|
||||||
|
from .providers import build_auth_provider
|
||||||
|
|
||||||
|
|
||||||
|
def clear_session() -> None:
|
||||||
|
for key in list(st.session_state.keys()):
|
||||||
|
del st.session_state[key]
|
||||||
|
|
||||||
|
|
||||||
|
class AuthService:
|
||||||
|
def run(self) -> bool:
|
||||||
|
if "user_profile" in st.session_state:
|
||||||
|
return True
|
||||||
|
|
||||||
|
token_data = build_auth_provider().get_token_data()
|
||||||
|
if not token_data:
|
||||||
|
return False
|
||||||
|
|
||||||
|
id_token = token_data.get("id_token")
|
||||||
|
if not id_token:
|
||||||
|
st.error("Не удалось получить id_token из ответа авторизации.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
profile = self._decode_id_token(id_token)
|
||||||
|
st.session_state["token_data"] = token_data
|
||||||
|
st.session_state["user_profile"] = profile
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _decode_id_token(self, id_token: str) -> dict[str, Any]:
|
||||||
|
if get_location() == "local":
|
||||||
|
return st.session_state.get("local_mock_profile", {"email": "local@local"})
|
||||||
|
|
||||||
|
jwks_client = jwt.PyJWKClient(get_setting("KEYCLOAK_JWKS_URL"))
|
||||||
|
signing_key = jwks_client.get_signing_key_from_jwt(id_token)
|
||||||
|
return jwt.decode(
|
||||||
|
id_token,
|
||||||
|
signing_key.key,
|
||||||
|
algorithms=[get_setting("KEYCLOAK_JWT_ALGORITHM")],
|
||||||
|
audience=get_setting("KEYCLOAK_CLIENT_ID"),
|
||||||
|
options={"verify_exp": True},
|
||||||
|
)
|
||||||
|
|
||||||
32
app/config.py
Normal file
32
app/config.py
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
SETTINGS_PREFIX = "OPENBAO__SETTINGS__"
|
||||||
|
REQUIRED_KEYCLOAK_SETTINGS = (
|
||||||
|
"KEYCLOAK_CLIENT_ID",
|
||||||
|
"KEYCLOAK_CLIENT_SECRET",
|
||||||
|
"KEYCLOAK_AUTHORIZE_URL",
|
||||||
|
"KEYCLOAK_TOKEN_URL",
|
||||||
|
"KEYCLOAK_LOGOUT_URL",
|
||||||
|
"KEYCLOAK_JWKS_URL",
|
||||||
|
"KEYCLOAK_REDIRECT_URI",
|
||||||
|
"KEYCLOAK_SCOPE",
|
||||||
|
"KEYCLOAK_JWT_ALGORITHM",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def get_setting(name: str) -> str:
|
||||||
|
prefixed = os.getenv(f"{SETTINGS_PREFIX}{name}", "").strip()
|
||||||
|
if prefixed:
|
||||||
|
return prefixed
|
||||||
|
return os.getenv(name, "").strip()
|
||||||
|
|
||||||
|
|
||||||
|
def get_missing_settings() -> list[str]:
|
||||||
|
return [name for name in REQUIRED_KEYCLOAK_SETTINGS if not get_setting(name)]
|
||||||
|
|
||||||
|
|
||||||
|
def get_location() -> str:
|
||||||
|
return get_setting("LOCATION").lower()
|
||||||
|
|
||||||
31
app/ui.py
Normal file
31
app/ui.py
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.config import SETTINGS_PREFIX, get_missing_settings
|
||||||
|
|
||||||
|
|
||||||
|
def render_env_check() -> None:
|
||||||
|
with st.expander("Проверка env окружения"):
|
||||||
|
if st.button("Проверить OPENBAO__SETTINGS", use_container_width=True):
|
||||||
|
missing = get_missing_settings()
|
||||||
|
if missing:
|
||||||
|
st.error("Не заданы обязательные переменные:")
|
||||||
|
for name in missing:
|
||||||
|
st.code(f"{SETTINGS_PREFIX}{name} (резерв: {name})", language="text")
|
||||||
|
else:
|
||||||
|
st.success("Все обязательные переменные заданы.")
|
||||||
|
|
||||||
|
|
||||||
|
def render_profile(profile: dict) -> None:
|
||||||
|
st.success("Аутентификация через Keycloak Raisa выполнена.")
|
||||||
|
st.subheader("Профиль пользователя")
|
||||||
|
st.json(
|
||||||
|
{
|
||||||
|
"email": profile.get("email"),
|
||||||
|
"preferred_username": profile.get("preferred_username"),
|
||||||
|
"name": profile.get("name"),
|
||||||
|
"realm_roles": profile.get("realm_access", {}).get("roles", []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
23
main.py
Normal file
23
main.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import streamlit as st
|
||||||
|
|
||||||
|
from app.auth.service import AuthService, clear_session
|
||||||
|
from app.ui import render_env_check, render_profile
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
st.set_page_config(page_title="SFM Streamlit", page_icon=":lock:", layout="wide")
|
||||||
|
st.title("SFM Streamlit: Keycloak-only auth")
|
||||||
|
render_env_check()
|
||||||
|
|
||||||
|
if not AuthService().run():
|
||||||
|
return
|
||||||
|
|
||||||
|
render_profile(st.session_state["user_profile"])
|
||||||
|
|
||||||
|
if st.button("Выйти", type="secondary"):
|
||||||
|
clear_session()
|
||||||
|
st.rerun()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
raisa_config.yml
Normal file
11
raisa_config.yml
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Конфигурация ресурсов приложения
|
||||||
|
# Доступные профили: Малый (0.25CPU/1GB), Средний (0.5CPU/2GB), Большой (1CPU/4GB), custom
|
||||||
|
# CPU Cores указывается, только с шагом 0.25
|
||||||
|
# RAM GB указывается, только с шагом 1
|
||||||
|
profile:
|
||||||
|
# Последние данные по наименованию профиля: Малый
|
||||||
|
cpu: 0.25 # значение Cores
|
||||||
|
ram: 1.0 # значение GB
|
||||||
|
resourcePolicy:
|
||||||
|
allowCpuOverbooking: false
|
||||||
|
allowRamOverbooking: false
|
||||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
openpyxl==3.1.2
|
||||||
|
streamlit-oauth==0.1.8
|
||||||
|
pyjwt[crypto]==2.8.0
|
||||||
|
python-decouple==3.8
|
||||||
|
black[all]==24.4.2
|
||||||
|
flake8==7.0.0
|
||||||
|
isort==5.13.2
|
||||||
1
roles.json
Normal file
1
roles.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
1
users.json
Normal file
1
users.json
Normal file
@ -0,0 +1 @@
|
|||||||
|
{}
|
||||||
Loading…
x
Reference in New Issue
Block a user