import ipaddress import re import serverus from Telegram import api as bot_api from Telegram.interfaces import Interface, command, register, state, text from .. import fields, platforms, services, tasks, uploads from ..models import AppVersion, Credentials, DockerApp, Platform, Point, Server from . import texts # состояния диалога DRAFT = 'draft' DRAFT_SERVER = 'draft.server' DRAFT_MASK = 'draft.mask' DRAFT_DOMAIN = 'draft.domain' DRAFT_PROXY = 'draft.proxy' SELECT = 'select' POINT = 'point' POINT_RELEASE = 'point.release' POINT_REINSTALL = 'point.reinstall' POINT_DOMAIN_EDIT = 'point.domain' POINT_PROXY_EDIT = 'point.proxy' POINT_KEYS = 'point.keys' POINT_KEY_ADD = 'point.keys.add' POINT_KEY_REVOKE = 'point.keys.revoke' DOCKER_ADD = 'docker.add' DOCKER_SELECT = 'docker.select' PLATFORM = 'platform' PLATFORM_RELEASE = 'platform.release' PLATFORM_REINSTALL = 'platform.reinstall' APP_NEW = 'platform.app.new' APP = 'app' APP_UPLOAD = 'app.upload' APP_CONFIRM = 'app.confirm' APP_REMOVE = 'app.remove' SERVICE = 'app.service' DRAFT_CREDENTIALS = {DRAFT_SERVER: 'server', DRAFT_MASK: 'mask'} DOMAIN_RE = re.compile(r'^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$') HTML = 'HTML' def parse_credentials(value: str) -> Credentials | None: """'ip:password' -> Credentials. Пароль может содержать двоеточия.""" address, _, password = value.strip().partition(':') try: address = str(ipaddress.ip_address(address)) except ValueError: return None return Credentials(address, password) if password else None def is_domain(value: str) -> bool: return bool(DOMAIN_RE.match(value)) def is_proxy_url(value: str) -> bool: try: serverus.parse_proxy(value) except ValueError: return False return True @register class MyPointInterface(Interface): title = 'MyPoint VPN' # главное меню @command('start') @text(texts.MAIN_MENU, texts.CANCEL) def main_menu(self, ctx): ctx.reset() points = list(Point.objects.owned_by(ctx.user)) platform_list = list(Platform.objects.owned_by(ctx.user)) lines = [texts.points_list(points)] if points else [] lines += [texts.platforms_list(platform_list)] if platform_list else [] ctx.reply('\n'.join(lines) or texts.GREETING, keyboard=self.main_keyboard(ctx), parse_mode=HTML) def main_keyboard(self, ctx): return texts.main_keyboard( Point.objects.owned_by(ctx.user).exists(), Platform.objects.owned_by(ctx.user).exists(), ) @text(texts.FAQ) def faq(self, ctx): ctx.reply(texts.FAQ_TEXT, parse_mode=HTML) @text(texts.HELP) def help(self, ctx): ctx.reply(texts.HELP_TEXT, parse_mode=HTML) def fallback(self, ctx): ctx.reply(texts.FALLBACK) # добавление сервера: черновик живет в данных чата до "установить" @text(texts.ADD_SERVER) def add_server(self, ctx): ctx.reset() ctx.data['draft'] = {} self.show_draft(ctx) def show_draft(self, ctx): ctx.state = DRAFT ctx.reply(texts.draft_card(ctx.data['draft']), keyboard=texts.DRAFT_KEYBOARD, parse_mode=HTML) @text(texts.SERVER, state=DRAFT) def ask_server(self, ctx): self.ask(ctx, DRAFT_SERVER, texts.CREDENTIALS_PROMPT) @text(texts.MASK, state=DRAFT) def ask_mask(self, ctx): self.ask(ctx, DRAFT_MASK, texts.CREDENTIALS_PROMPT, [[texts.CANCEL, texts.DELETE]]) @text(texts.DELETE, state=DRAFT_MASK) def delete_mask(self, ctx): ctx.data['draft'].pop('mask', None) self.show_draft(ctx) @text(texts.DOMAIN, state=DRAFT) def ask_domain(self, ctx): self.ask(ctx, DRAFT_DOMAIN, texts.DOMAIN_PROMPT, [[texts.CANCEL, texts.DELETE]]) @text(texts.PROXY, state=DRAFT) def ask_proxy(self, ctx): self.ask(ctx, DRAFT_PROXY, texts.PROXY_PROMPT, [[texts.CF_WARP, texts.CANCEL, texts.DELETE]]) def ask(self, ctx, new_state: str, prompt: str, keyboard=texts.CANCEL_KEYBOARD): ctx.state = new_state ctx.reply(prompt, keyboard=keyboard, parse_mode=HTML) @text(texts.CANCEL, state=DRAFT_SERVER) @text(texts.CANCEL, state=DRAFT_MASK) @text(texts.CANCEL, state=DRAFT_DOMAIN) @text(texts.CANCEL, state=DRAFT_PROXY) def back_to_draft(self, ctx): self.show_draft(ctx) @state(DRAFT_SERVER, DRAFT_MASK) def set_credentials(self, ctx): # в сообщении пароль root, в чате ему не место ctx.delete_message() credentials = parse_credentials(ctx.text) if credentials is None: return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML) draft = ctx.data['draft'] key = DRAFT_CREDENTIALS[ctx.state] other = draft.get('mask' if key == 'server' else 'server', {}) if other.get('address') == credentials.address: return ctx.reply(texts.ADDRESS_DUPLICATE) if Server.objects.is_address_taken(credentials.address): return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address)) draft[key] = {'address': credentials.address, 'password': credentials.password} self.show_draft(ctx) @state(DRAFT_DOMAIN) def set_domain(self, ctx): domain = ctx.text.strip().lower().rstrip('.') if not is_domain(domain): return ctx.reply(texts.DOMAIN_INVALID) ctx.data['draft']['domain'] = domain self.show_draft(ctx) @text(texts.DELETE, state=DRAFT_DOMAIN) def delete_domain(self, ctx): ctx.data['draft']['domain'] = '' self.show_draft(ctx) @state(DRAFT_PROXY) def set_proxy(self, ctx): # в прокси может быть пароль ctx.delete_message() proxy = ctx.text.strip() if not is_proxy_url(proxy): return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML) ctx.data['draft']['proxy'] = proxy self.show_draft(ctx) @text(texts.CF_WARP, state=DRAFT_PROXY) def set_cf_warp(self, ctx): ctx.data['draft']['proxy'] = Point.CF_WARP self.show_draft(ctx) @text(texts.DELETE, state=DRAFT_PROXY) def delete_proxy(self, ctx): ctx.data['draft']['proxy'] = '' self.show_draft(ctx) @text(texts.INSTALL, state=DRAFT) def install(self, ctx): draft = ctx.data['draft'] if not draft.get('server'): return ctx.reply(texts.DRAFT_INCOMPLETE) if draft.get('mask') and not draft.get('domain'): return ctx.reply(texts.MASK_WITHOUT_DOMAIN) point = Point.objects.create_point( ctx.user, panel=Credentials(**draft['server']), mask=Credentials(**draft['mask']) if draft.get('mask') else None, domain=draft.get('domain', ''), outbound_proxy=draft.get('proxy', ''), ) tasks.start(tasks.install_point, point, Point.Status.INSTALLING, ctx.chat) ctx.reset() ctx.reply(texts.INSTALL_STARTED, keyboard=self.main_keyboard(ctx)) # управление сервером @text(texts.MANAGE) def ask_point(self, ctx): ctx.reset() points = Point.objects.owned_by(ctx.user) self.ask(ctx, SELECT, f'{texts.SELECT_PROMPT}\n{texts.points_list(points)}') @state(SELECT) def select_point(self, ctx): point = Point.objects.owned_by(ctx.user).find_by_name(ctx.text.strip()) if point is None: return ctx.reply(texts.POINT_NOT_FOUND) self.show_point(ctx, point) @text(texts.SERVER_MENU) def server_menu(self, ctx): """Кнопка из уведомления: последняя открытая точка или последняя созданная.""" point = self.current_point(ctx) or Point.objects.owned_by(ctx.user).find_last() if point is None: return self.main_menu(ctx) self.show_point(ctx, point) def current_point(self, ctx) -> Point | None: point_id = ctx.data.get('point') return Point.objects.owned_by(ctx.user).find_by_pk(point_id) if point_id else None def show_point(self, ctx, point: Point): ctx.reset() ctx.state = POINT ctx.data['point'] = point.pk ctx.reply(texts.point_card(point), keyboard=texts.POINT_KEYBOARD, parse_mode=HTML) def idle_point(self, ctx) -> Point | None: """Текущая точка, если с ней можно работать. Иначе отвечает пользователю и возвращает None.""" point = self.current_point(ctx) if point is None: self.main_menu(ctx) elif point.is_busy: ctx.reply(texts.POINT_BUSY.format(status=point.get_status_display())) return None return point @text(texts.RELEASE, state=POINT) def ask_release(self, ctx): if self.idle_point(ctx): self.ask(ctx, POINT_RELEASE, texts.RELEASE_CONFIRM, [[texts.YES, texts.NO]]) @text(texts.YES, state=POINT_RELEASE) def release(self, ctx): point = self.idle_point(ctx) if point is None: return tasks.start(tasks.release_point, point, Point.Status.RELEASING, ctx.chat) ctx.reset() ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx)) @text(texts.REINSTALL, state=POINT) def ask_reinstall(self, ctx): if self.idle_point(ctx): self.ask(ctx, POINT_REINSTALL, texts.REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]]) @state(POINT_REINSTALL) def reinstall_with_password(self, ctx): ctx.delete_message() credentials = parse_credentials(ctx.text) if credentials is None: return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML) point = self.idle_point(ctx) if point is None: return if point.servers.find_by_address(credentials.address) is None: return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address)) self.start_reinstall(ctx, point, credentials.address, credentials.password) @text(texts.USE_OLD, state=POINT_REINSTALL) def reinstall_with_old_passwords(self, ctx): point = self.idle_point(ctx) if point: self.start_reinstall(ctx, point) def start_reinstall(self, ctx, point: Point, address: str | None = None, password: str | None = None): tasks.start(tasks.reinstall_point, point, Point.Status.INSTALLING, ctx.chat, address, password) ctx.reset() ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx)) @text(texts.NO, state=POINT_RELEASE) @text(texts.CANCEL, state=POINT_RELEASE) @text(texts.CANCEL, state=POINT_REINSTALL) @text(texts.CANCEL, state=POINT_DOMAIN_EDIT) @text(texts.CANCEL, state=POINT_PROXY_EDIT) def back_to_point(self, ctx): point = self.current_point(ctx) if point is None: return self.main_menu(ctx) self.show_point(ctx, point) # домен и прокси готовой точки: применяются в фоне, панель тем временем работает с прежними настройками @text(texts.POINT_DOMAIN, state=POINT) def ask_point_domain(self, ctx): point = self.ready_point(ctx) if point: keyboard = texts.CANCEL_KEYBOARD if point.mask else [[texts.CANCEL, texts.DELETE]] self.ask(ctx, POINT_DOMAIN_EDIT, texts.domain_prompt(point), keyboard) @state(POINT_DOMAIN_EDIT) def change_domain(self, ctx): domain = ctx.text.strip().lower().rstrip('.') if not is_domain(domain): return ctx.reply(texts.DOMAIN_INVALID) point = self.ready_point(ctx) if point: self.start_change(ctx, point, tasks.set_domain, domain) @text(texts.DELETE, state=POINT_DOMAIN_EDIT) def delete_point_domain(self, ctx): point = self.ready_point(ctx) if point is None: return if point.mask: return ctx.reply(texts.DOMAIN_REQUIRED_BY_MASK) if not point.domain: return self.show_point(ctx, point) self.start_change(ctx, point, tasks.set_domain, '') @text(texts.POINT_PROXY, state=POINT) def ask_point_proxy(self, ctx): point = self.ready_point(ctx) if point: self.ask(ctx, POINT_PROXY_EDIT, texts.proxy_prompt(point), [[texts.CF_WARP, texts.CANCEL, texts.DELETE]]) @state(POINT_PROXY_EDIT) def change_proxy(self, ctx): # в прокси может быть пароль ctx.delete_message() proxy = ctx.text.strip() if not is_proxy_url(proxy): return ctx.reply(texts.PROXY_INVALID, parse_mode=HTML) self.start_proxy_change(ctx, proxy) @text(texts.CF_WARP, state=POINT_PROXY_EDIT) def change_proxy_to_warp(self, ctx): self.start_proxy_change(ctx, Point.CF_WARP) @text(texts.DELETE, state=POINT_PROXY_EDIT) def delete_point_proxy(self, ctx): self.start_proxy_change(ctx, '') def start_proxy_change(self, ctx, proxy: str): point = self.ready_point(ctx) if point: self.start_change(ctx, point, tasks.set_outbound_proxy, fields.encrypt(proxy) if proxy else '') def start_change(self, ctx, point: Point, actor, value: str): tasks.start(actor, point, Point.Status.UPDATING, ctx.chat, value) ctx.reset() ctx.reply(texts.CHANGE_STARTED, keyboard=self.main_keyboard(ctx)) # ключи def ready_point(self, ctx) -> Point | None: """Текущая точка, если на ее панели можно выпускать ключи. Иначе отвечает пользователю и возвращает None.""" point = self.idle_point(ctx) if point is not None and (point.status != Point.Status.READY or not point.panel_inbound_id): ctx.reply(texts.POINT_NOT_READY) return None return point @text(texts.CLIENTS, state=POINT) @text(texts.CLIENTS, state=POINT_KEYS) @text(texts.CANCEL, state=POINT_KEY_ADD) @text(texts.CANCEL, state=POINT_KEY_REVOKE) def show_keys(self, ctx): point = self.ready_point(ctx) if point is None: return ctx.state = POINT_KEYS keys = [(key, services.key_traffic(key)) for key in point.keys.all()] ctx.reply(texts.keys_list(point, keys), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML) @state(POINT_KEYS) def show_key(self, ctx): point = self.ready_point(ctx) if point is None: return key = point.keys.find_by_name(ctx.text.strip().lower()) if key is None: return ctx.reply(texts.KEY_NOT_FOUND) ctx.reply(texts.key_card(key, services.key_traffic(key)), parse_mode=HTML) @text(texts.ADD_KEY, state=POINT_KEYS) def ask_key_name(self, ctx): if self.ready_point(ctx): self.ask(ctx, POINT_KEY_ADD, texts.KEY_NAME_PROMPT) @state(POINT_KEY_ADD) def add_key(self, ctx): point = self.ready_point(ctx) if point is None: return try: key = services.issue_key(point, ctx.text.strip().lower()) except ValueError as error: return ctx.reply(str(error)) except serverus.XuiApiError as error: return ctx.reply(texts.PANEL_ERROR.format(error=error)) ctx.state = POINT_KEYS ctx.reply(texts.key_card(key, None), keyboard=texts.KEYS_KEYBOARD, parse_mode=HTML) @text(texts.REVOKE_KEY, state=POINT_KEYS) def ask_revoke_key(self, ctx): if self.ready_point(ctx): self.ask(ctx, POINT_KEY_REVOKE, texts.KEY_REVOKE_PROMPT) @state(POINT_KEY_REVOKE) def revoke_key(self, ctx): point = self.ready_point(ctx) if point is None: return key = point.keys.find_by_name(ctx.text.strip().lower()) if key is None: return ctx.reply(texts.KEY_NOT_FOUND) try: services.revoke_key(key) except serverus.XuiApiError as error: return ctx.reply(texts.PANEL_ERROR.format(error=error)) ctx.reply(texts.KEY_REVOKED.format(name=key.name)) self.show_keys(ctx) # docker-серверы @text(texts.ADD_DOCKER) def ask_docker_server(self, ctx): ctx.reset() self.ask(ctx, DOCKER_ADD, texts.DOCKER_CREDENTIALS_PROMPT) @text(texts.CANCEL, state=DOCKER_ADD) @text(texts.CANCEL, state=DOCKER_SELECT) def cancel_docker(self, ctx): self.main_menu(ctx) @state(DOCKER_ADD) def add_docker_server(self, ctx): # в сообщении пароль root, в чате ему не место ctx.delete_message() credentials = parse_credentials(ctx.text) if credentials is None: return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML) if Server.objects.is_address_taken(credentials.address): return ctx.reply(texts.ADDRESS_TAKEN.format(address=credentials.address)) platform = Platform.objects.create_platform(ctx.user, credentials) tasks.start(tasks.install_platform, platform, Platform.Status.INSTALLING, ctx.chat) ctx.reset() ctx.reply(texts.DOCKER_INSTALL_STARTED, keyboard=self.main_keyboard(ctx)) @text(texts.DOCKER_SERVERS) def ask_platform(self, ctx): ctx.reset() platform_list = Platform.objects.owned_by(ctx.user) self.ask(ctx, DOCKER_SELECT, f'{texts.PLATFORM_SELECT_PROMPT}\n{texts.platforms_list(platform_list)}') @state(DOCKER_SELECT) def select_platform(self, ctx): platform = Platform.objects.owned_by(ctx.user).find_by_name(ctx.text.strip()) if platform is None: return ctx.reply(texts.PLATFORM_NOT_FOUND) self.show_platform(ctx, platform) @text(texts.PLATFORM_MENU) def platform_menu(self, ctx): """Кнопка из уведомления: последний открытый docker-сервер или последний созданный.""" platform = self.current_platform(ctx) or Platform.objects.owned_by(ctx.user).find_last() if platform is None: return self.main_menu(ctx) self.show_platform(ctx, platform) def current_platform(self, ctx) -> Platform | None: platform_id = ctx.data.get('platform') return Platform.objects.owned_by(ctx.user).find_by_pk(platform_id) if platform_id else None def show_platform(self, ctx, platform: Platform): ctx.reset() ctx.state = PLATFORM ctx.data['platform'] = platform.pk ctx.reply(texts.platform_card(platform), keyboard=texts.PLATFORM_KEYBOARD, parse_mode=HTML) def idle_platform(self, ctx, ready: bool = True) -> Platform | None: """Текущий docker-сервер, если с ним можно работать. Иначе отвечает пользователю и возвращает None. ready - сервер должен быть установлен.""" platform = self.current_platform(ctx) if platform is None: self.main_menu(ctx) elif platform.is_busy: ctx.reply(texts.POINT_BUSY.format(status=platform.get_status_display())) return None elif ready and platform.status != Platform.Status.READY: ctx.reply(texts.PLATFORM_NOT_READY) return None return platform @text(texts.CHECK, state=PLATFORM) def check_platform(self, ctx): platform = self.idle_platform(ctx) if platform: tasks.refresh_platform.send(platform.pk, ctx.chat.pk) ctx.reply(texts.CHECK_STARTED) @text(texts.RELEASE, state=PLATFORM) def ask_platform_release(self, ctx): if self.idle_platform(ctx, ready=False): self.ask(ctx, PLATFORM_RELEASE, texts.PLATFORM_RELEASE_CONFIRM, [[texts.YES, texts.NO]]) @text(texts.YES, state=PLATFORM_RELEASE) def release_platform(self, ctx): platform = self.idle_platform(ctx, ready=False) if platform is None: return tasks.start(tasks.release_platform, platform, Platform.Status.RELEASING, ctx.chat) ctx.reset() ctx.reply(texts.RELEASE_STARTED, keyboard=self.main_keyboard(ctx)) @text(texts.REINSTALL, state=PLATFORM) def ask_platform_reinstall(self, ctx): if self.idle_platform(ctx, ready=False): self.ask(ctx, PLATFORM_REINSTALL, texts.PLATFORM_REINSTALL_PROMPT, [[texts.CANCEL, texts.USE_OLD]]) @state(PLATFORM_REINSTALL) def reinstall_platform_with_password(self, ctx): ctx.delete_message() credentials = parse_credentials(ctx.text) if credentials is None: return ctx.reply(texts.CREDENTIALS_INVALID, parse_mode=HTML) platform = self.idle_platform(ctx, ready=False) if platform is None: return if platform.server.address != credentials.address: return ctx.reply(texts.REINSTALL_UNKNOWN_ADDRESS.format(address=credentials.address)) self.start_platform_reinstall(ctx, platform, fields.encrypt(credentials.password)) @text(texts.USE_OLD, state=PLATFORM_REINSTALL) def reinstall_platform_with_old_password(self, ctx): platform = self.idle_platform(ctx, ready=False) if platform: self.start_platform_reinstall(ctx, platform, '') def start_platform_reinstall(self, ctx, platform: Platform, encrypted_password: str): tasks.start(tasks.reinstall_platform, platform, Platform.Status.INSTALLING, ctx.chat, encrypted_password) ctx.reset() ctx.reply(texts.REINSTALL_STARTED, keyboard=self.main_keyboard(ctx)) @text(texts.NO, state=PLATFORM_RELEASE) @text(texts.CANCEL, state=PLATFORM_RELEASE) @text(texts.CANCEL, state=PLATFORM_REINSTALL) @text(texts.CANCEL, state=APP_NEW) def back_to_platform(self, ctx): platform = self.current_platform(ctx) if platform is None: return self.main_menu(ctx) self.show_platform(ctx, platform) # приложения @state(PLATFORM) def select_app(self, ctx): platform = self.current_platform(ctx) if platform is None: return self.main_menu(ctx) app = platform.apps.find_by_name(ctx.text.strip()) if app is None: return ctx.reply(texts.APP_NOT_FOUND) self.show_app(ctx, app) def current_app(self, ctx) -> DockerApp | None: platform = self.current_platform(ctx) app_id = ctx.data.get('app') return platform.apps.filter(pk=app_id).first() if platform and app_id else None def show_app(self, ctx, app: DockerApp): ctx.state = APP ctx.data['app'] = app.pk ctx.data.pop('service', None) ctx.reply(texts.app_card(app), keyboard=texts.APP_KEYBOARD, parse_mode=HTML) @text(texts.APP_MENU) @text(texts.CANCEL, state=APP_REMOVE) def app_menu(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) self.show_app(ctx, app) @text(texts.NEW_APP, state=PLATFORM) def ask_app_name(self, ctx): if self.idle_platform(ctx): self.ask(ctx, APP_NEW, texts.APP_NAME_PROMPT) @state(APP_NEW) def create_app(self, ctx): platform = self.idle_platform(ctx) if platform is None: return try: app = platforms.create_app(platform, ctx.text.strip()) except ValueError as error: return ctx.reply(str(error)) ctx.data['app'] = app.pk ctx.data['new_app'] = True self.ask_upload(ctx, AppVersion.Kind.FULL) @text(texts.UPLOAD_UPDATE, state=APP) def ask_update(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) if app.current_version() is None: return ctx.reply(texts.APP_NOT_DEPLOYED) self.ask_upload(ctx, AppVersion.Kind.UPDATE) @text(texts.UPLOAD_FULL, state=APP) def ask_full(self, ctx): if self.current_app(ctx) is None: return self.platform_menu(ctx) self.ask_upload(ctx, AppVersion.Kind.FULL) def ask_upload(self, ctx, kind: str): ctx.data['kind'] = kind prompt = texts.UPLOAD_UPDATE_PROMPT if kind == AppVersion.Kind.UPDATE else texts.UPLOAD_FULL_PROMPT self.ask(ctx, APP_UPLOAD, prompt) @state(APP_UPLOAD) def receive_upload(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) document = ctx.document if document is None: return ctx.reply(texts.UPLOAD_NOT_DOCUMENT) try: content = ctx.download_document(uploads.ARCHIVE_MAX) upload = platforms.prepare_upload(app, document.name, content, ctx.data.get('kind', AppVersion.Kind.UPDATE)) except (bot_api.ApiError, uploads.UploadError) as error: return ctx.reply(texts.upload_failed(error), parse_mode=HTML) self.show_upload(ctx, upload) def show_upload(self, ctx, upload: platforms.Upload): if upload.version is None: # ошибки - ждем исправленные файлы, без изменений - назад к приложению ctx.reply(texts.upload_report(upload), parse_mode=HTML) if not upload.errors: self.app_menu(ctx) return ctx.state = APP_CONFIRM ctx.data['version'] = upload.version.pk ctx.reply(texts.upload_report(upload), keyboard=texts.CONFIRM_KEYBOARD, parse_mode=HTML) @text(texts.CANCEL, state=APP_UPLOAD) @text(texts.CANCEL, state=APP_CONFIRM) def cancel_upload(self, ctx): app = self.current_app(ctx) version_id = ctx.data.pop('version', None) if app and version_id: app.versions.filter(pk=version_id, state=AppVersion.State.DRAFT).delete() if app and ctx.data.pop('new_app', False): platforms.discard_new_app(app) return self.platform_menu(ctx) self.app_menu(ctx) @text(texts.APPLY, state=APP_CONFIRM) def apply_upload(self, ctx): platform = self.idle_platform(ctx) app = self.current_app(ctx) if platform is None or app is None: return version = app.versions.filter(pk=ctx.data.get('version'), state=AppVersion.State.DRAFT).first() if version is None: ctx.reply(texts.VERSION_GONE) return self.app_menu(ctx) tasks.start(tasks.deploy_version, platform, Platform.Status.UPDATING, ctx.chat, version.pk) ctx.data.pop('version', None) ctx.data.pop('new_app', None) ctx.state = APP ctx.reply(texts.APPLY_STARTED, keyboard=texts.APP_KEYBOARD) @text(texts.ROLLBACK, state=APP) def rollback(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) try: upload = platforms.prepare_rollback(app) except ValueError: return ctx.reply(texts.ROLLBACK_NONE) self.show_upload(ctx, upload) # управление приложением и сервисом @text(texts.START, state=APP) @text(texts.STOP, state=APP) @text(texts.RESTART, state=APP) @text(texts.PULL, state=APP) @text(texts.START, state=SERVICE) @text(texts.STOP, state=SERVICE) @text(texts.RESTART, state=SERVICE) def control_app(self, ctx): platform = self.idle_platform(ctx) app = self.current_app(ctx) if platform is None or app is None: return if app.current_version() is None: return ctx.reply(texts.APP_NOT_DEPLOYED) service = ctx.data.get('service', '') if ctx.state == SERVICE else '' tasks.start(tasks.control_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, texts.ACTIONS[ctx.text], service) ctx.reply(texts.OPERATION_STARTED) @text(texts.LOGS, state=APP) @text(texts.LOGS, state=SERVICE) def send_logs(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) service = ctx.data.get('service', '') if ctx.state == SERVICE else '' tasks.send_logs.send(app.pk, service, ctx.chat.pk) ctx.reply(texts.LOGS_STARTED) @state(APP) def select_service(self, ctx): app = self.current_app(ctx) if app is None: return self.platform_menu(ctx) service = ctx.text.strip() services = app.platform.app_status(app.name).get('services', {}) if not services: return ctx.reply(texts.NO_SNAPSHOT) if service not in services: return ctx.reply(texts.APP_NOT_FOUND) ctx.state = SERVICE ctx.data['service'] = service ctx.reply(texts.service_card(app, service), keyboard=texts.SERVICE_KEYBOARD, parse_mode=HTML) @text(texts.REMOVE_APP, state=APP) def ask_remove_app(self, ctx): if self.idle_platform(ctx) and self.current_app(ctx): self.ask(ctx, APP_REMOVE, texts.REMOVE_APP_CONFIRM, texts.REMOVE_APP_KEYBOARD) @text(texts.REMOVE_KEEP_DATA, state=APP_REMOVE) @text(texts.REMOVE_WITH_DATA, state=APP_REMOVE) def remove_app(self, ctx): platform = self.idle_platform(ctx) app = self.current_app(ctx) if platform is None or app is None: return volumes = ctx.text == texts.REMOVE_WITH_DATA tasks.start(tasks.remove_app, platform, Platform.Status.UPDATING, ctx.chat, app.pk, volumes) ctx.data.pop('app', None) ctx.state = PLATFORM ctx.reply(texts.OPERATION_STARTED, keyboard=texts.PLATFORM_KEYBOARD)