mt5-manager/main.py
175 lines
6.2 KiB
Python
'''Веб-приложение менеджера терминалов.'''
__version__ = '0.5.1'
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path as FilePath
from fastapi import FastAPI, HTTPException, Request
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.exceptions import RequestValidationError
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel, Field, ValidationError
from starlette.concurrency import run_in_threadpool
from config import ConfigStore
from terminal_manager import PROJECT_DIR, TerminalManager
class AccountInput(BaseModel):
name: str = ''
login: int | str = ''
server: str = ''
password: str = ''
clear_account: bool = False
class CreateInput(AccountInput):
folder: str = ''
class SettingsInput(BaseModel):
mt5_folder: str = Field(min_length=1)
terminal_template: str = Field(min_length=1)
mt5_installation: str = ''
service_ex5: str = ''
startup_timeout_sec: int = Field(default=180, ge=10, le=900)
disk_warning_mb: int = Field(default=1024, ge=0)
@asynccontextmanager
async def lifespan(app: FastAPI):
manager = TerminalManager(ConfigStore())
app.state.manager = manager
try:
yield
finally:
await run_in_threadpool(manager.close)
app = FastAPI(lifespan=lifespan)
app.mount('/static', StaticFiles(directory=PROJECT_DIR / 'static'), name='static')
templates = Jinja2Templates(directory=PROJECT_DIR / 'templates')
@app.exception_handler(RequestValidationError)
async def invalid_request(request: Request, error: RequestValidationError):
# Стандартный ответ Pydantic может содержать исходный объект запроса,
# включая пароль. Здесь возвращаем только нейтральное описание ошибки.
return JSONResponse(status_code=422, content={'detail': 'Проверьте поля запроса и типы значений.'})
def submit(request, action, folder, values=None):
try:
return request.app.state.manager.submit(action, folder, values)
except ValueError as error:
raise HTTPException(400, str(error)) from None
@app.get('/', response_class=HTMLResponse)
def index(request: Request):
return templates.TemplateResponse(request, 'index.html',
{'instances': request.app.state.manager.snapshot()})
@app.get('/settings', response_class=HTMLResponse)
def settings_page(request: Request):
return templates.TemplateResponse(request, 'settings.html')
@app.post('/instances/{name}')
def info_instance(request: Request, name: str):
return request.app.state.manager.info(name)
@app.post('/start/{name}', status_code=202)
def start_instance(request: Request, name: str):
return submit(request, 'start', name)
@app.post('/stop/{name}', status_code=202)
def stop_instance(request: Request, name: str):
return submit(request, 'stop', name)
@app.post('/create/{name}', status_code=202)
def create_instance(request: Request, name: str, values: AccountInput | None = None):
return submit(request, 'create', name, (values or AccountInput()).model_dump())
@app.post('/api/terminals', status_code=202)
def create_terminal(request: Request, values: CreateInput):
folder = values.folder or request.app.state.manager.suggest_name()
return submit(request, 'create', folder, values.model_dump(exclude={'folder'}))
@app.put('/api/terminals/{name}/account', status_code=202)
def save_account(request: Request, name: str, values: AccountInput):
return submit(request, 'account', name, values.model_dump())
@app.get('/api/jobs/{job_id}')
def job_status(request: Request, job_id: str):
try:
return request.app.state.manager.job(job_id)
except KeyError:
raise HTTPException(404, 'Операция не найдена.') from None
@app.get('/api/settings')
def get_settings(request: Request):
return request.app.state.manager.settings()
@app.get('/api/project')
def project_status(request: Request):
manager = request.app.state.manager
return manager.updates.status(manager.store.data)
@app.post('/api/project/{action}', status_code=202)
def project_operation(request: Request, action: str):
actions = {'check': 'check_updates', 'update': 'update_project', 'deploy': 'deploy_service'}
if action not in actions:
raise HTTPException(404, 'Неизвестная операция проекта.')
return submit(request, actions[action], '')
@app.put('/api/settings')
def save_settings(request: Request, values: SettingsInput):
try:
request.app.state.manager.save_settings(values.model_dump())
except ValidationError:
raise HTTPException(400, 'Проверьте параметры конфигурации.') from None
except (ValueError, OSError) as error:
raise HTTPException(400, str(error)) from None
return {'saved': True}
@app.put('/api/files/{kind}')
async def upload_file(request: Request, kind: str):
manager = request.app.state.manager
if kind not in ('terminal', 'editor', 'servers', 'service'):
raise HTTPException(404, 'Неизвестный тип файла.')
limit = (16 if kind == 'servers' else 256) * 1024 * 1024
size = 0
temporary = None
try:
# Потоковая загрузка не требует держать EXE целиком в памяти.
with tempfile.NamedTemporaryFile(delete=False) as out:
temporary = FilePath(out.name)
async for chunk in request.stream():
size += len(chunk)
if size > limit:
raise HTTPException(413, 'Превышен допустимый размер файла.')
out.write(chunk)
if not size:
raise HTTPException(400, 'Получен пустой файл.')
await run_in_threadpool(manager.install_upload, kind, temporary)
except (ValueError, OSError) as error:
raise HTTPException(400, str(error)) from None
finally:
if temporary:
temporary.unlink(missing_ok=True)
return {'uploaded': True, 'size': size}