'''Веб-приложение менеджера терминалов.''' __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}