mt5-manager/main.py
175 lines
6.2 KiB
Python
'''Веб-приложение менеджера терминалов.'''
__version__ = '0.5.1'
Yuriy Bykov
2025-10-14 16:04:37 +03:00
import tempfile
from contextlib import asynccontextmanager
from pathlib import Path as FilePath
from fastapi import FastAPI, HTTPException, Request
Yuriy Bykov
2025-10-07 21:48:20 +03:00
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)
Yuriy Bykov
2025-10-04 15:10:53 +03:00
Yuriy Bykov
2025-10-14 14:59:40 +03:00
app = FastAPI(lifespan=lifespan)
app.mount('/static', StaticFiles(directory=PROJECT_DIR / 'static'), name='static')
templates = Jinja2Templates(directory=PROJECT_DIR / 'templates')
Yuriy Bykov
2025-10-10 10:16:59 +03:00
@app.exception_handler(RequestValidationError)
async def invalid_request(request: Request, error: RequestValidationError):
# Стандартный ответ Pydantic может содержать исходный объект запроса,
# включая пароль. Здесь возвращаем только нейтральное описание ошибки.
return JSONResponse(status_code=422, content={'detail': 'Проверьте поля запроса и типы значений.'})
Yuriy Bykov
2025-10-07 21:48:20 +03:00
Yuriy Bykov
2025-10-14 14:59:40 +03:00
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()})
Yuriy Bykov
2025-10-07 21:48:20 +03:00
Yuriy Bykov
2025-10-14 14:59:40 +03:00
@app.get('/settings', response_class=HTMLResponse)
def settings_page(request: Request):
return templates.TemplateResponse(request, 'settings.html')
Yuriy Bykov
2025-10-16 20:41:52 +03:00
@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}