diff --git a/babel.cfg b/babel.cfg index 5558354..1135a51 100644 --- a/babel.cfg +++ b/babel.cfg @@ -1,3 +1,3 @@ -[python: **.py] -[jinja2: **/templates/**.html] +[python: **.py] +[jinja2: **/templates/**.html] extensions=jinja2.ext.i18n \ No newline at end of file diff --git a/extensions.py b/extensions.py index d5b8b33..453d06b 100644 --- a/extensions.py +++ b/extensions.py @@ -1,11 +1,11 @@ -from flask_sqlalchemy import SQLAlchemy -from flask_migrate import Migrate -from flask_login import LoginManager -from flask_babel import Babel - -# Создаем экземпляры расширений здесь, без привязки к приложению. -# Теперь любой модуль может безопасно импортировать их отсюда. -db = SQLAlchemy() -migrate = Migrate() -login_manager = LoginManager() +from flask_sqlalchemy import SQLAlchemy +from flask_migrate import Migrate +from flask_login import LoginManager +from flask_babel import Babel + +# Создаем экземпляры расширений здесь, без привязки к приложению. +# Теперь любой модуль может безопасно импортировать их отсюда. +db = SQLAlchemy() +migrate = Migrate() +login_manager = LoginManager() babel = Babel() \ No newline at end of file diff --git a/prompt b/prompt index 15e823e..99addea 100644 --- a/prompt +++ b/prompt @@ -1,49 +1,49 @@ -Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets" -1. Обзор Проекта -Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы. -Стек технологий: -Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate -Работа с API: requests (для RMS), gspread (для Google Sheets) -Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS) -База данных: SQLite -Frontend: Jinja2, стандартный HTML/CSS/JS. -Текущий функционал: -Приложение уже реализует полный цикл работы для одного пользователя: -Регистрация и авторизация. -Настройка подключения к RMS API (хост, логин, пароль). -Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя. -Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы). -Получение и сохранение списка листов из Google Таблицы. -Сопоставление (маппинг) отчетов RMS с листами Google Таблицы. -Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные. -Предоставленные файлы: -app.py (основная логика Flask) -models.py (модели SQLAlchemy) -google_sheets.py (модуль для работы с Google Sheets API) -request_module.py (модуль для работы с RMS API) -utils.py (вспомогательные функции) -README.md (документация) -HTML-шаблоны (index.html, login.html, register.html) -2. Ключевые Задачи для Разработки -Задача 1: Отладка, Рефакторинг и Русификация Комментариев -Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя. -Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py. -Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал. -Задача 2: Интернационализация (i18n) и Перевод Интерфейса -Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности. -Механизм выбора языка: -На странице логина (login.html) добавить возможность выбора языка (Русский/Английский). -Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД). -В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧). -Перевод интерфейса: -Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода. -Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным. -Задача 3: Улучшение Среды Разработки для Windows -Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения. -Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта. -Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows. -3. Правила Взаимодействия -Язык общения: Всегда общайся на русском языке. -Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости. -Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня. -Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала. +Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets" +1. Обзор Проекта +Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы. +Стек технологий: +Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate +Работа с API: requests (для RMS), gspread (для Google Sheets) +Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS) +База данных: SQLite +Frontend: Jinja2, стандартный HTML/CSS/JS. +Текущий функционал: +Приложение уже реализует полный цикл работы для одного пользователя: +Регистрация и авторизация. +Настройка подключения к RMS API (хост, логин, пароль). +Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя. +Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы). +Получение и сохранение списка листов из Google Таблицы. +Сопоставление (маппинг) отчетов RMS с листами Google Таблицы. +Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные. +Предоставленные файлы: +app.py (основная логика Flask) +models.py (модели SQLAlchemy) +google_sheets.py (модуль для работы с Google Sheets API) +request_module.py (модуль для работы с RMS API) +utils.py (вспомогательные функции) +README.md (документация) +HTML-шаблоны (index.html, login.html, register.html) +2. Ключевые Задачи для Разработки +Задача 1: Отладка, Рефакторинг и Русификация Комментариев +Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя. +Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py. +Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал. +Задача 2: Интернационализация (i18n) и Перевод Интерфейса +Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности. +Механизм выбора языка: +На странице логина (login.html) добавить возможность выбора языка (Русский/Английский). +Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД). +В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧). +Перевод интерфейса: +Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода. +Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным. +Задача 3: Улучшение Среды Разработки для Windows +Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения. +Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта. +Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows. +3. Правила Взаимодействия +Язык общения: Всегда общайся на русском языке. +Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости. +Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня. +Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала. diff --git a/routes.py b/routes.py index 72a4dd0..2506b6b 100644 --- a/routes.py +++ b/routes.py @@ -1,432 +1,432 @@ -# routes.py -import os -import json -import shutil -from flask import ( - Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app -) -from flask_login import login_user, login_required, logout_user, current_user -from flask_babel import _ -from werkzeug.utils import secure_filename -import gspread - -# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ --- -# Импортируем экземпляры расширений, созданные в app.py -from extensions import db, login_manager -# Импортируем наши классы и утилиты -from models import User, UserConfig -from google_sheets import GoogleSheets -from request_module import ReqModule -from utils import get_dates, generate_template_from_preset, render_temp - - -# --- Создание блюпринта --- -main_bp = Blueprint('main', __name__) - - -# --- Регистрация обработчиков для расширений --- - -@login_manager.user_loader -def load_user(user_id): - """Загружает пользователя из БД для управления сессией.""" - return db.session.get(User, int(user_id)) - - -@main_bp.before_app_request -def load_user_specific_data(): - """Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса.""" - g.user_config = None - if current_user.is_authenticated: - g.user_config = get_user_config() - - -# --- Вспомогательные функции, специфичные для маршрутов --- - -def get_user_config(): - """Получает конфиг для текущего пользователя, создавая его при необходимости.""" - if not current_user.is_authenticated: - return None - config = UserConfig.query.filter_by(user_id=current_user.id).first() - if not config: - config = UserConfig(user_id=current_user.id) - db.session.add(config) - return config - -def get_user_upload_path(filename=""): - """Возвращает путь для загрузки файлов для текущего пользователя.""" - if not current_user.is_authenticated: - return None - user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id)) - os.makedirs(user_dir, exist_ok=True) - return os.path.join(user_dir, secure_filename(filename)) - - -# --- Маршруты --- - -@main_bp.route('/language/') -def set_language(language=None): - session['language'] = language - return redirect(request.referrer or url_for('.index')) - -@main_bp.route('/login', methods=['GET', 'POST']) -def login(): - if current_user.is_authenticated: - return redirect(url_for('.index')) - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - user = User.query.filter_by(username=username).first() - if user is None or not user.check_password(password): - flash(_('Invalid username or password'), 'error') - return redirect(url_for('.login')) - login_user(user, remember=request.form.get('remember')) - flash(_('Login successful!'), 'success') - next_page = request.args.get('next') - return redirect(next_page or url_for('.index')) - return render_template('login.html') - -@main_bp.route('/register', methods=['GET', 'POST']) -def register(): - if current_user.is_authenticated: - return redirect(url_for('.index')) - if request.method == 'POST': - username = request.form.get('username') - password = request.form.get('password') - if not username or not password: - flash(_('Username and password are required.'), 'error') - return redirect(url_for('.register')) - if User.query.filter_by(username=username).first(): - flash(_('Username already exists.'), 'error') - return redirect(url_for('.register')) - - user = User(username=username) - user.set_password(password) - user.config = UserConfig() - db.session.add(user) - try: - db.session.commit() - flash(_('Registration successful! Please log in.'), 'success') - return redirect(url_for('.login')) - except Exception as e: - db.session.rollback() - flash(_('An error occurred during registration. Please try again.'), 'error') - return redirect(url_for('.register')) - - return render_template('register.html') - -@main_bp.route('/logout') -@login_required -def logout(): - logout_user() - flash(_('You have been logged out.'), 'success') - return redirect(url_for('.index')) - -@main_bp.route('/') -@login_required -def index(): - config = g.user_config - return render_template( - 'index.html', - rms_config=config.get_rms_dict(), - google_config=config.get_google_dict(), - presets=config.presets, - sheets=config.sheets, - mappings=config.mappings, - client_email=config.google_client_email - ) - -@main_bp.route('/configure_rms', methods=['POST']) -@login_required -def configure_rms(): - config = g.user_config - try: - host = request.form.get('host', '').strip() - login = request.form.get('login', '').strip() - password = request.form.get('password', '') - - if not config.rms_password and not password: - flash(_('Password is required for the first time.'), 'error') - return redirect(url_for('.index')) - - if not host or not login: - flash(_('Host and Login fields must be filled.'), 'error') - return redirect(url_for('.index')) - - effective_password = password if password else config.rms_password - - req_module = ReqModule(host, login, effective_password) - if req_module.login(): - presets_data = req_module.take_presets() - req_module.logout() - - config.rms_host = host - config.rms_login = login - if password: - config.rms_password = password - config.presets = presets_data - - db.session.commit() - flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success') - else: - flash(_('Authorization error on RMS server. Check host, login or password.'), 'error') - - except Exception as e: - db.session.rollback() - flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error') - - return redirect(url_for('.index')) - -@main_bp.route('/upload_credentials', methods=['POST']) -@login_required -def upload_credentials(): - config = g.user_config - if 'cred_file' not in request.files or request.files['cred_file'].filename == '': - flash(_('No file was selected.'), 'error') - return redirect(url_for('.index')) - - cred_file = request.files['cred_file'] - filename = cred_file.filename - # Получаем путь для сохранения файла в папке пользователя - user_cred_path = get_user_upload_path(filename) - temp_path = None - - try: - # Сначала сохраняем файл во временную директорию для проверки - temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp") - os.makedirs(temp_dir, exist_ok=True) - temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}") - cred_file.save(temp_path) - - with open(temp_path, 'r', encoding='utf-8') as f: - cred_data = json.load(f) - client_email = cred_data.get('client_email') - - if not client_email: - flash(_('Could not find client_email in the credentials file.'), 'error') - # Не забываем удалить временный файл при ошибке - if os.path.exists(temp_path): - os.remove(temp_path) - return redirect(url_for('.index')) - - # Если все хорошо, перемещаем файл из временной папки в постоянную - shutil.move(temp_path, user_cred_path) - - # Сохраняем путь к файлу и email в базу данных - config.google_cred_file_path = user_cred_path - config.google_client_email = client_email - config.sheets = [] # Сбрасываем список листов при смене credentials - - db.session.commit() - flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success') - - except json.JSONDecodeError: - flash(_('Error: Uploaded file is not a valid JSON.'), 'error') - except Exception as e: - db.session.rollback() - flash(_('Error processing credentials: %(error)s', error=str(e)), 'error') - finally: - # Гарантированно удаляем временный файл, если он еще существует - if temp_path and os.path.exists(temp_path): - os.remove(temp_path) - - return redirect(url_for('.index')) - -@main_bp.route('/configure_google', methods=['POST']) -@login_required -def configure_google(): - config = g.user_config - sheet_url = request.form.get('sheet_url', '').strip() - - if not sheet_url: - flash(_('Sheet URL must be provided.'), 'error') - return redirect(url_for('.index')) - - config.google_sheet_url = sheet_url - - cred_path = config.google_cred_file_path - if not cred_path or not os.path.isfile(cred_path): - flash(_('Please upload a valid credentials file first.'), 'warning') - config.sheets = [] - db.session.commit() - return redirect(url_for('.index')) - - try: - gs_client = GoogleSheets(cred_path, sheet_url) - sheets_data = gs_client.get_sheets() - config.sheets = sheets_data - - db.session.commit() - flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success') - - except Exception as e: - db.session.rollback() - config.sheets = [] - flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error') - try: - db.session.commit() - except Exception: - db.session.rollback() - - return redirect(url_for('.index')) - -@main_bp.route('/mapping_set', methods=['POST']) -@login_required -def mapping_set(): - config = g.user_config - try: - new_mappings = {} - for sheet in config.sheets: - report_key = f"sheet_{sheet['id']}" - selected_report_id = request.form.get(report_key) - if selected_report_id: - new_mappings[sheet['title']] = selected_report_id - - config.mappings = new_mappings - db.session.commit() - - flash(_('Mappings updated successfully.'), 'success') - except Exception as e: - db.session.rollback() - flash(_('Error updating mappings: %(error)s', error=str(e)), 'error') - - return redirect(url_for('.index')) - -@main_bp.route('/render_olap', methods=['POST']) -@login_required -def render_olap(): - config = g.user_config - sheet_title = None - req_module = None - - try: - from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date')) - sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '') - if not sheet_title: - flash(_('Error: Could not determine which sheet to render the report for.'), 'error') - return redirect(url_for('.index')) - - report_id = config.mappings.get(sheet_title) - if not report_id: - flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error') - return redirect(url_for('.index')) - - if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]): - flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error') - return redirect(url_for('.index')) - - preset = next((p for p in config.presets if p.get('id') == report_id), None) - if not preset: - flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error') - return redirect(url_for('.index')) - - template = generate_template_from_preset(preset) - json_body = render_temp(template, {"from_date": from_date, "to_date": to_date}) - - req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password) - gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url) - - if req_module.login(): - result = req_module.take_olap(json_body) - - # --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ --- - - if 'data' not in result or not isinstance(result['data'], list): - flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error') - current_app.logger.error(f"Unexpected API response for report {report_id} ('{preset.get('name')}'). Response: {result}") - return redirect(url_for('.index')) - - report_data = result['data'] - - # Если отчет пуст, очищаем лист и уведомляем пользователя. - if not report_data: - gs_client.clear_and_write_data(sheet_title, []) - flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning') - return redirect(url_for('.index')) - - # Здесь будет храниться наш итоговый "плоский" список словарей - processed_data = [] - - # Проверяем структуру отчета: сводный (pivoted) или простой (flat) - first_item = report_data[0] - is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item - - if is_pivoted: - current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}") - # "Разворачиваем" (unpivot) данные в плоский список словарей - for row_item in report_data: - row_values = row_item.get('row', {}) - cells = row_item.get('cells', []) - if not cells: - # Обрабатываем строки, у которых может не быть данных в ячейках - processed_data.append(row_values.copy()) - else: - for cell in cells: - new_flat_row = row_values.copy() - new_flat_row.update(cell.get('col', {})) - new_flat_row.update(cell.get('values', {})) - processed_data.append(new_flat_row) - else: - current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}") - # Данные уже в виде плоского списка, просто присваиваем - processed_data = [item for item in report_data if isinstance(item, dict)] - - # --- Универсальное формирование заголовков и данных --- - - # 1. Собираем все уникальные ключи из всех строк для гарантии целостности. - all_keys = set() - for row in processed_data: - all_keys.update(row.keys()) - - # 2. Создаем упорядоченный список заголовков для лучшей читаемости. - # Используем поля из пресета для определения логического порядка. - row_group_fields = preset.get('groupByRowFields', []) - col_group_fields = preset.get('groupByColFields', []) - agg_fields = preset.get('aggregateFields', []) - - ordered_headers = [] - # Сначала добавляем известные поля из пресета в логической последовательности. - for field in row_group_fields + col_group_fields + agg_fields: - if field in all_keys: - ordered_headers.append(field) - all_keys.remove(field) - # Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту. - ordered_headers.extend(sorted(list(all_keys))) - - # 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам. - data_to_insert = [ordered_headers] - for row in processed_data: - row_data = [] - for header in ordered_headers: - value_str = str(row.get(header, '')) - if value_str.startswith(('=', '+', '-', '@')): - row_data.append("'" + value_str) - else: - row_data.append(value_str) - # Преобразуем None в пустую строку, а все остальное в строковое представление. - # Это предотвращает потенциальные ошибки типов со стороны Google Sheets API. - data_to_insert.append(row_data) - - - gs_client.clear_and_write_data(sheet_title, data_to_insert) - - rows_count = len(data_to_insert) - 1 - flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".', - name=preset.get('name', report_id), - rows=rows_count, - sheet=sheet_title), 'success') - else: - flash(_('Error authorizing on RMS server when trying to get a report.'), 'error') - - except ValueError as ve: - flash(_('Data Error: %(error)s', error=str(ve)), 'error') - except gspread.exceptions.APIError as api_err: - flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error') - current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True) - except Exception as e: - flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error') - current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True) - finally: - if req_module and req_module.token: - req_module.logout() - +# routes.py +import os +import json +import shutil +from flask import ( + Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app +) +from flask_login import login_user, login_required, logout_user, current_user +from flask_babel import _ +from werkzeug.utils import secure_filename +import gspread + +# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ --- +# Импортируем экземпляры расширений, созданные в app.py +from extensions import db, login_manager +# Импортируем наши классы и утилиты +from models import User, UserConfig +from google_sheets import GoogleSheets +from request_module import ReqModule +from utils import get_dates, generate_template_from_preset, render_temp + + +# --- Создание блюпринта --- +main_bp = Blueprint('main', __name__) + + +# --- Регистрация обработчиков для расширений --- + +@login_manager.user_loader +def load_user(user_id): + """Загружает пользователя из БД для управления сессией.""" + return db.session.get(User, int(user_id)) + + +@main_bp.before_app_request +def load_user_specific_data(): + """Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса.""" + g.user_config = None + if current_user.is_authenticated: + g.user_config = get_user_config() + + +# --- Вспомогательные функции, специфичные для маршрутов --- + +def get_user_config(): + """Получает конфиг для текущего пользователя, создавая его при необходимости.""" + if not current_user.is_authenticated: + return None + config = UserConfig.query.filter_by(user_id=current_user.id).first() + if not config: + config = UserConfig(user_id=current_user.id) + db.session.add(config) + return config + +def get_user_upload_path(filename=""): + """Возвращает путь для загрузки файлов для текущего пользователя.""" + if not current_user.is_authenticated: + return None + user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id)) + os.makedirs(user_dir, exist_ok=True) + return os.path.join(user_dir, secure_filename(filename)) + + +# --- Маршруты --- + +@main_bp.route('/language/') +def set_language(language=None): + session['language'] = language + return redirect(request.referrer or url_for('.index')) + +@main_bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('.index')) + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + user = User.query.filter_by(username=username).first() + if user is None or not user.check_password(password): + flash(_('Invalid username or password'), 'error') + return redirect(url_for('.login')) + login_user(user, remember=request.form.get('remember')) + flash(_('Login successful!'), 'success') + next_page = request.args.get('next') + return redirect(next_page or url_for('.index')) + return render_template('login.html') + +@main_bp.route('/register', methods=['GET', 'POST']) +def register(): + if current_user.is_authenticated: + return redirect(url_for('.index')) + if request.method == 'POST': + username = request.form.get('username') + password = request.form.get('password') + if not username or not password: + flash(_('Username and password are required.'), 'error') + return redirect(url_for('.register')) + if User.query.filter_by(username=username).first(): + flash(_('Username already exists.'), 'error') + return redirect(url_for('.register')) + + user = User(username=username) + user.set_password(password) + user.config = UserConfig() + db.session.add(user) + try: + db.session.commit() + flash(_('Registration successful! Please log in.'), 'success') + return redirect(url_for('.login')) + except Exception as e: + db.session.rollback() + flash(_('An error occurred during registration. Please try again.'), 'error') + return redirect(url_for('.register')) + + return render_template('register.html') + +@main_bp.route('/logout') +@login_required +def logout(): + logout_user() + flash(_('You have been logged out.'), 'success') + return redirect(url_for('.index')) + +@main_bp.route('/') +@login_required +def index(): + config = g.user_config + return render_template( + 'index.html', + rms_config=config.get_rms_dict(), + google_config=config.get_google_dict(), + presets=config.presets, + sheets=config.sheets, + mappings=config.mappings, + client_email=config.google_client_email + ) + +@main_bp.route('/configure_rms', methods=['POST']) +@login_required +def configure_rms(): + config = g.user_config + try: + host = request.form.get('host', '').strip() + login = request.form.get('login', '').strip() + password = request.form.get('password', '') + + if not config.rms_password and not password: + flash(_('Password is required for the first time.'), 'error') + return redirect(url_for('.index')) + + if not host or not login: + flash(_('Host and Login fields must be filled.'), 'error') + return redirect(url_for('.index')) + + effective_password = password if password else config.rms_password + + req_module = ReqModule(host, login, effective_password) + if req_module.login(): + presets_data = req_module.take_presets() + req_module.logout() + + config.rms_host = host + config.rms_login = login + if password: + config.rms_password = password + config.presets = presets_data + + db.session.commit() + flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success') + else: + flash(_('Authorization error on RMS server. Check host, login or password.'), 'error') + + except Exception as e: + db.session.rollback() + flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error') + + return redirect(url_for('.index')) + +@main_bp.route('/upload_credentials', methods=['POST']) +@login_required +def upload_credentials(): + config = g.user_config + if 'cred_file' not in request.files or request.files['cred_file'].filename == '': + flash(_('No file was selected.'), 'error') + return redirect(url_for('.index')) + + cred_file = request.files['cred_file'] + filename = cred_file.filename + # Получаем путь для сохранения файла в папке пользователя + user_cred_path = get_user_upload_path(filename) + temp_path = None + + try: + # Сначала сохраняем файл во временную директорию для проверки + temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp") + os.makedirs(temp_dir, exist_ok=True) + temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}") + cred_file.save(temp_path) + + with open(temp_path, 'r', encoding='utf-8') as f: + cred_data = json.load(f) + client_email = cred_data.get('client_email') + + if not client_email: + flash(_('Could not find client_email in the credentials file.'), 'error') + # Не забываем удалить временный файл при ошибке + if os.path.exists(temp_path): + os.remove(temp_path) + return redirect(url_for('.index')) + + # Если все хорошо, перемещаем файл из временной папки в постоянную + shutil.move(temp_path, user_cred_path) + + # Сохраняем путь к файлу и email в базу данных + config.google_cred_file_path = user_cred_path + config.google_client_email = client_email + config.sheets = [] # Сбрасываем список листов при смене credentials + + db.session.commit() + flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success') + + except json.JSONDecodeError: + flash(_('Error: Uploaded file is not a valid JSON.'), 'error') + except Exception as e: + db.session.rollback() + flash(_('Error processing credentials: %(error)s', error=str(e)), 'error') + finally: + # Гарантированно удаляем временный файл, если он еще существует + if temp_path and os.path.exists(temp_path): + os.remove(temp_path) + + return redirect(url_for('.index')) + +@main_bp.route('/configure_google', methods=['POST']) +@login_required +def configure_google(): + config = g.user_config + sheet_url = request.form.get('sheet_url', '').strip() + + if not sheet_url: + flash(_('Sheet URL must be provided.'), 'error') + return redirect(url_for('.index')) + + config.google_sheet_url = sheet_url + + cred_path = config.google_cred_file_path + if not cred_path or not os.path.isfile(cred_path): + flash(_('Please upload a valid credentials file first.'), 'warning') + config.sheets = [] + db.session.commit() + return redirect(url_for('.index')) + + try: + gs_client = GoogleSheets(cred_path, sheet_url) + sheets_data = gs_client.get_sheets() + config.sheets = sheets_data + + db.session.commit() + flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success') + + except Exception as e: + db.session.rollback() + config.sheets = [] + flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error') + try: + db.session.commit() + except Exception: + db.session.rollback() + + return redirect(url_for('.index')) + +@main_bp.route('/mapping_set', methods=['POST']) +@login_required +def mapping_set(): + config = g.user_config + try: + new_mappings = {} + for sheet in config.sheets: + report_key = f"sheet_{sheet['id']}" + selected_report_id = request.form.get(report_key) + if selected_report_id: + new_mappings[sheet['title']] = selected_report_id + + config.mappings = new_mappings + db.session.commit() + + flash(_('Mappings updated successfully.'), 'success') + except Exception as e: + db.session.rollback() + flash(_('Error updating mappings: %(error)s', error=str(e)), 'error') + + return redirect(url_for('.index')) + +@main_bp.route('/render_olap', methods=['POST']) +@login_required +def render_olap(): + config = g.user_config + sheet_title = None + req_module = None + + try: + from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date')) + sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '') + if not sheet_title: + flash(_('Error: Could not determine which sheet to render the report for.'), 'error') + return redirect(url_for('.index')) + + report_id = config.mappings.get(sheet_title) + if not report_id: + flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error') + return redirect(url_for('.index')) + + if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]): + flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error') + return redirect(url_for('.index')) + + preset = next((p for p in config.presets if p.get('id') == report_id), None) + if not preset: + flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error') + return redirect(url_for('.index')) + + template = generate_template_from_preset(preset) + json_body = render_temp(template, {"from_date": from_date, "to_date": to_date}) + + req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password) + gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url) + + if req_module.login(): + result = req_module.take_olap(json_body) + + # --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ --- + + if 'data' not in result or not isinstance(result['data'], list): + flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error') + current_app.logger.error(f"Unexpected API response for report {report_id} ('{preset.get('name')}'). Response: {result}") + return redirect(url_for('.index')) + + report_data = result['data'] + + # Если отчет пуст, очищаем лист и уведомляем пользователя. + if not report_data: + gs_client.clear_and_write_data(sheet_title, []) + flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning') + return redirect(url_for('.index')) + + # Здесь будет храниться наш итоговый "плоский" список словарей + processed_data = [] + + # Проверяем структуру отчета: сводный (pivoted) или простой (flat) + first_item = report_data[0] + is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item + + if is_pivoted: + current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}") + # "Разворачиваем" (unpivot) данные в плоский список словарей + for row_item in report_data: + row_values = row_item.get('row', {}) + cells = row_item.get('cells', []) + if not cells: + # Обрабатываем строки, у которых может не быть данных в ячейках + processed_data.append(row_values.copy()) + else: + for cell in cells: + new_flat_row = row_values.copy() + new_flat_row.update(cell.get('col', {})) + new_flat_row.update(cell.get('values', {})) + processed_data.append(new_flat_row) + else: + current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}") + # Данные уже в виде плоского списка, просто присваиваем + processed_data = [item for item in report_data if isinstance(item, dict)] + + # --- Универсальное формирование заголовков и данных --- + + # 1. Собираем все уникальные ключи из всех строк для гарантии целостности. + all_keys = set() + for row in processed_data: + all_keys.update(row.keys()) + + # 2. Создаем упорядоченный список заголовков для лучшей читаемости. + # Используем поля из пресета для определения логического порядка. + row_group_fields = preset.get('groupByRowFields', []) + col_group_fields = preset.get('groupByColFields', []) + agg_fields = preset.get('aggregateFields', []) + + ordered_headers = [] + # Сначала добавляем известные поля из пресета в логической последовательности. + for field in row_group_fields + col_group_fields + agg_fields: + if field in all_keys: + ordered_headers.append(field) + all_keys.remove(field) + # Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту. + ordered_headers.extend(sorted(list(all_keys))) + + # 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам. + data_to_insert = [ordered_headers] + for row in processed_data: + row_data = [] + for header in ordered_headers: + value_str = str(row.get(header, '')) + if value_str.startswith(('=', '+', '-', '@')): + row_data.append("'" + value_str) + else: + row_data.append(value_str) + # Преобразуем None в пустую строку, а все остальное в строковое представление. + # Это предотвращает потенциальные ошибки типов со стороны Google Sheets API. + data_to_insert.append(row_data) + + + gs_client.clear_and_write_data(sheet_title, data_to_insert) + + rows_count = len(data_to_insert) - 1 + flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".', + name=preset.get('name', report_id), + rows=rows_count, + sheet=sheet_title), 'success') + else: + flash(_('Error authorizing on RMS server when trying to get a report.'), 'error') + + except ValueError as ve: + flash(_('Data Error: %(error)s', error=str(ve)), 'error') + except gspread.exceptions.APIError as api_err: + flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error') + current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True) + except Exception as e: + flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error') + current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True) + finally: + if req_module and req_module.token: + req_module.logout() + return redirect(url_for('.index')) \ No newline at end of file