diff --git a/.gitea/workflows/testing.yml b/.gitea/workflows/testing.yml new file mode 100644 index 0000000..0ac62d6 --- /dev/null +++ b/.gitea/workflows/testing.yml @@ -0,0 +1,47 @@ +name: Test Build + +on: + push: + branches: + - main + +jobs: + test-build: + runs-on: [docker, host] + steps: + - name: Install Docker CLI (for Alpine-based runner) + run: | + if ! command -v docker &> /dev/null; then + apk update && apk add docker-cli + fi + + - name: Prepare SSH and clone repo + run: | + apk add --no-cache openssh git + mkdir -p /root/.ssh + chmod 700 /root/.ssh + ssh-keyscan -p 2222 10.25.100.250 >> /root/.ssh/known_hosts + rm -rf /tmp/olaper + git clone --branch main ssh://git@10.25.100.250:2222/serty/olaper.git /tmp/olaper + + - name: Build test Docker image + run: | + cd /tmp/olaper + docker build -t olaper:test . + + - name: (Optional) Run container for testing + run: | + # Удаляем предыдущий тестовый контейнер + if [ "$(docker ps -q -f name=olaper_test)" ]; then + docker stop olaper_test && docker rm olaper_test + fi + + docker run -d \ + --name olaper_test \ + -p 5050:5005 \ + -e SECRET_KEY=${{ secrets.SECRET_KEY }} \ + -e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \ + olaper:test + + - name: Cleanup source + run: rm -rf /tmp/olaper diff --git a/app.py b/app.py index 64098ee..c8f9023 100644 --- a/app.py +++ b/app.py @@ -1,533 +1,78 @@ -import json -from flask import Flask, render_template, request, redirect, url_for, flash, g, session -import gspread -from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user -from flask_migrate import Migrate import os -import logging -from werkzeug.utils import secure_filename -import shutil +from flask import Flask, session, request +from dotenv import load_dotenv + +# 1. Загрузка переменных окружения - в самом верху +load_dotenv() + +# 2. Импорт расширений из центрального файла +from extensions import db, migrate, login_manager, babel +from models import init_encryption + +# 3. Фабрика приложений +def create_app(): + """ + Создает и конфигурирует экземпляр Flask приложения. + """ + app = Flask(__name__) + + # --- Конфигурация приложения --- + app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-super-secret-key-for-dev') + + # --- НАДЕЖНАЯ НАСТРОЙКА ПУТЕЙ --- + # Получаем абсолютный путь к директории, где находится app.py + basedir = os.path.abspath(os.path.dirname(__file__)) + # Устанавливаем путь к папке data + data_dir = os.path.join(basedir, os.environ.get('DATA_DIR', 'data')) + # Создаем эту директорию, если ее не существует. Это ключевой момент. + os.makedirs(data_dir, exist_ok=True) + + app.config['DATA_DIR'] = data_dir + + # Устанавливаем путь к БД + db_path = os.path.join(data_dir, 'app.db') + app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', f"sqlite:///{db_path}") + + app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False + app.config['BABEL_DEFAULT_LOCALE'] = 'ru' + app.config['ENCRYPTION_KEY'] = os.environ.get('ENCRYPTION_KEY') + + + # --- Определяем селектор языка --- + def get_locale(): + if 'language' in session: + return session['language'] + return request.accept_languages.best_match(['ru', 'en']) + + # --- Инициализация расширений с приложением --- + db.init_app(app) + migrate.init_app(app, db) + login_manager.init_app(app) + babel.init_app(app, locale_selector=get_locale) + init_encryption(app) + + # --- Регистрация блюпринтов --- + from routes import main_bp + app.register_blueprint(main_bp) + + login_manager.login_view = 'main.login' + login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице." + login_manager.login_message_category = "info" + + # --- Регистрация команд CLI --- + from models import User, UserConfig + @app.cli.command('init-db') + def init_db_command(): + """Создает или пересоздает таблицы в базе данных.""" + print("Creating database tables...") + db.create_all() + print("Database tables created successfully.") + + return app + +# --- Точка входа для запуска --- +app = create_app() -from google_sheets import GoogleSheets -from request_module import ReqModule -from utils import * -from models import db, User, UserConfig - - -app = Flask(__name__) -app.secret_key = os.environ.get('SECRET_KEY', '994525') -DATA_DIR = os.environ.get('DATA_DIR', '/app/data') -app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', f'sqlite:///{DATA_DIR}/app.db') -app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False - -db.init_app(app) -migrate = Migrate(app, db) - -os.makedirs(DATA_DIR, exist_ok=True) - -# --- Flask-Login Configuration --- -login_manager = LoginManager() -login_manager.init_app(app) -login_manager.login_view = 'login' # Redirect to 'login' view if user tries to access protected page - -@login_manager.user_loader -def load_user(user_id): - """Loads user from DB for session management.""" - return db.session.get(User, int(user_id)) - -# --- Logging Configuration --- -logger = logging.getLogger() -logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') - - -# --- Helper Functions --- -def get_user_config(): - """Gets the config for the currently logged-in user, creating if it doesn't exist.""" - if not current_user.is_authenticated: - return None # Or return a default empty config object if preferred for anonymous users - 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) - # Commit immediately or defer, depending on workflow - # db.session.commit() # Let's commit when saving changes - logger.info(f"Created new UserConfig for user {current_user.id}") - return config - -def get_user_upload_path(filename=""): - """Gets the upload path for the current user.""" - if not current_user.is_authenticated: - return None # Or raise an error - user_dir = os.path.join(DATA_DIR, str(current_user.id)) - os.makedirs(user_dir, exist_ok=True) - return os.path.join(user_dir, secure_filename(filename)) - - -rms_config = {} -google_config = {} -presets = [] -sheets = [] -mappings = [] - -@app.before_request -def load_user_specific_data(): - """Load user-specific data into Flask's 'g' object for the current request context.""" - g.user_config = None - if current_user.is_authenticated: - g.user_config = get_user_config() - # You could preload other user-specific things here if needed - # g.presets = g.user_config.presets # Example - # g.sheets = g.user_config.sheets # Example - # g.mappings = g.user_config.mappings # Example - else: - # Define defaults for anonymous users if necessary - # g.presets = [] - # g.sheets = [] - # g.mappings = {} - pass - -# --- Authentication Routes --- - -@app.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') - -@app.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() - user.config = user_config - db.session.add(user) - # Create associated config immediately - try: - db.session.commit() - flash('Registration successful! Please log in.', 'success') - logger.info(f"User '{username}' registered successfully.") - return redirect(url_for('login')) - except Exception as e: - db.session.rollback() - logger.error(f"Error during registration for {username}: {e}") - flash('An error occurred during registration. Please try again.', 'error') - return redirect(url_for('register')) - - return render_template('register.html') - -@app.route('/logout') -@login_required -def logout(): - logout_user() - flash('You have been logged out.', 'success') - return redirect(url_for('index')) - - -@app.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 - ) - -@app.route('/configure_rms', methods=['POST']) -@login_required -def configure_rms(): - """Настройка параметров RMS-сервера.""" - config = g.user_config - try: - # Логируем вызов функции и параметры - logger.info(f"User {current_user.id}: Вызов configure_rms с параметрами: {request.form}") - - host = request.form.get('host', '').strip() - login = request.form.get('login', '').strip() - password = request.form.get('password', '').strip() - - # Проверяем, что все поля заполнены - if not host or not login or not password: - flash('All RMS fields must be filled.', 'error') - return redirect(url_for('index')) - - # Авторизация на RMS-сервере - req_module = ReqModule(host, login, password) - if req_module.login(): - presets_data = req_module.take_presets() # Сохраняем пресеты в g - req_module.logout() - - # Обновляем конфигурацию RMS-сервера - config.rms_host = host - config.rms_login = login - config.rms_password = password - config.presets = presets_data - - db.session.commit() - flash(f"Successfully authorized on RMS server. Received {len(presets_data)} presets.", 'success') - logger.info(f"User {current_user.id}: RMS config updated successfully.") - else: - flash('Authorization error on RMS server.', 'error') - - except Exception as e: - db.session.rollback() - logger.error(f"User {current_user.id}: Ошибка при настройке RMS: {str(e)}") - flash(f'Error configuring RMS: {str(e)}', 'error') - - return redirect(url_for('index')) - -@app.route('/upload_credentials', methods=['POST']) -@login_required -def upload_credentials(): - """Обработчик для загрузки файла credentials для текущего пользователя.""" - config = g.user_config - if 'cred_file' in request.files: - cred_file = request.files['cred_file'] - if cred_file.filename != '': - - filename = secure_filename(cred_file.filename) - user_cred_path = get_user_upload_path(filename) - - try: - # Save the file temporarily first to read it - temp_path = os.path.join("data", f"temp_{current_user.id}_{filename}") # Temp generic uploads dir - cred_file.save(temp_path) - - # Извлекаем client_email из JSON-файла - client_email = None - with open(temp_path, 'r', encoding='utf-8') as temp_cred_file: - cred_data = json.load(temp_cred_file) - client_email = cred_data.get('client_email') - - if not client_email: - flash('Could not find client_email in the credentials file.', 'error') - os.remove(temp_path) # Clean up temp file - return redirect(url_for('index')) - - # Move the validated file to the user's persistent directory - shutil.move(temp_path, user_cred_path) - - # Update config object in DB - config.google_cred_file_path = user_cred_path - config.google_client_email = client_email - # Clear existing sheets list if creds change - config.sheets = [] - # Optionally clear mappings too? - # config.mappings = {} - - db.session.commit() - flash(f'Credentials file successfully uploaded and saved. Email: {client_email}', 'success') - logger.info(f"User {current_user.id}: Credentials file uploaded to {user_cred_path}") - - except json.JSONDecodeError: - flash('Error: Uploaded file is not a valid JSON.', 'error') - if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file - logger.warning(f"User {current_user.id}: Uploaded invalid JSON credentials file.") - except Exception as e: - db.session.rollback() - logger.error(f"User {current_user.id}: Ошибка при загрузке credentials: {str(e)}") - flash(f'Error processing credentials: {str(e)}', 'error') - if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file - else: - flash('No file was selected.', 'error') - else: - flash('Error: Credentials file not found in request.', 'error') - - return redirect(url_for('index')) - -@app.route('/configure_google', methods=['POST']) -@login_required -def configure_google(): - """Настройка параметров Google Sheets для текущего пользователя.""" - config = g.user_config - try: - logger.info(f"User {current_user.id}: Вызов configure_google с параметрами: {request.form}") - - sheet_url = request.form.get('sheet_url', '').strip() - if not sheet_url: - flash('Sheet URL must be provided.', 'error') - return redirect(url_for('index')) - - # Check if credentials file path exists in config and on disk - 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') - # Save the URL anyway? Or require creds first? Let's save URL. - config.google_sheet_url = sheet_url - config.sheets = [] # Clear sheets if creds are missing/invalid - # Optionally clear mappings - # config.mappings = {} - db.session.commit() - return redirect(url_for('index')) - - # Update sheet URL in config - config.google_sheet_url = sheet_url - - # Подключение к Google Sheets - gs_client = GoogleSheets(cred_path, sheet_url) # Use path from user config - sheets_data = gs_client.get_sheets() - - # Update sheets list in config - config.sheets = sheets_data - - # Optionally clear mappings when sheet URL or creds change? - # config.mappings = {} - - db.session.commit() - flash(f'Successfully connected to Google Sheets. Found {len(sheets_data)} sheets. Settings saved.', 'success') - logger.info(f"User {current_user.id}: Google Sheets config updated. URL: {sheet_url}") - - except Exception as e: - db.session.rollback() - # Don't clear sheets list on temporary connection error - logger.error(f"User {current_user.id}: Ошибка при настройке Google Sheets: {str(e)}") - flash(f'Error connecting to Google Sheets: {str(e)}. Check the URL and service account permissions.', 'error') - # Still save the URL entered by the user - config.google_sheet_url = sheet_url - try: - db.session.commit() - except Exception as commit_err: - logger.error(f"User {current_user.id}: Error committing Google Sheet URL after connection error: {commit_err}") - db.session.rollback() - - - return redirect(url_for('index')) - -@app.route('/mapping_set', methods=['POST']) -@login_required -def mapping_set(): - """Обновление сопоставлений листов и отчетов для текущего пользователя.""" - config = g.user_config - try: - logger.info(f"User {current_user.id}: Вызов mapping_set с параметрами: {request.form}") - - new_mappings = {} - # Use sheets stored in the user's config for iteration - for sheet in config.sheets: - report_key = f"sheet_{sheet['id']}" - selected_report_id = request.form.get(report_key) - if selected_report_id: # Only store non-empty selections - # Store mapping using sheet title as key, report ID as value - new_mappings[sheet['title']] = selected_report_id - # else: # Handle case where user unselects a mapping - # If sheet title existed in old mappings, remove it? Or keep structure? - # Keeping it simple: only store active mappings from the form. - - config.mappings = new_mappings # Use the setter - db.session.commit() - - flash('Mappings updated successfully.', 'success') - logger.info(f"User {current_user.id}: Mappings updated: {new_mappings}") - except Exception as e: - db.session.rollback() - logger.error(f"User {current_user.id}: Ошибка при обновлении сопоставлений: {str(e)}") - flash(f'Error updating mappings: {str(e)}', 'error') - - return redirect(url_for('index')) - -@app.route('/render_olap', methods=['POST']) -@login_required -def render_olap(): - """Отрисовка данных отчета на листе для текущего пользователя.""" - config = g.user_config - sheet_title = None - report_id = None - preset = None - req_module = None - gs_client = None # Инициализируем здесь для finally - - 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') - return redirect(url_for('index')) - - logger.info(f"User {current_user.id}: Попытка отрисовки OLAP для листа '{sheet_title}'") - - # --- Получаем данные из конфига пользователя --- - report_id = config.mappings.get(sheet_title) - rms_host = config.rms_host - rms_login = config.rms_login - rms_password = config.rms_password # Decrypted via property getter - cred_path = config.google_cred_file_path - sheet_url = config.google_sheet_url - all_presets = config.presets - - # --- Проверки --- - if not report_id: - flash(f"Ошибка: Для листа '{sheet_title}' не назначен отчет.", 'error') - return redirect(url_for('index')) - if not all([rms_host, rms_login, rms_password]): - flash('Ошибка: Конфигурация RMS не завершена.', 'error') - return redirect(url_for('index')) - if not cred_path or not sheet_url or not os.path.isfile(cred_path): - flash('Ошибка: Конфигурация Google Sheets не завершена или файл credentials недоступен.', 'error') - return redirect(url_for('index')) - - preset = next((p for p in all_presets if p.get('id') == report_id), None) # Безопасное получение id - if not preset: - flash(f"Ошибка: Пресет с ID '{report_id}' не найден в сохраненной конфигурации.", 'error') - logger.warning(f"User {current_user.id}: Пресет ID '{report_id}' не найден в сохраненных пресетах для листа '{sheet_title}'") - return redirect(url_for('index')) - - # --- Генерируем шаблон из одного пресета --- - try: - # Передаем сам словарь пресета - template = generate_template_from_preset(preset) - except ValueError as e: - flash(f"Ошибка генерации шаблона для отчета '{preset.get('name', report_id)}': {e}", 'error') - return redirect(url_for('index')) - except Exception as e: - flash(f"Непредвиденная ошибка при генерации шаблона для отчета '{preset.get('name', report_id)}': {e}", 'error') - logger.error(f"User {current_user.id}: Ошибка generate_template_from_preset: {e}", exc_info=True) - return redirect(url_for('index')) - - if not template: # Дополнительная проверка, хотя функция теперь вызывает exception - flash(f"Ошибка: Не удалось сгенерировать шаблон для отчета '{preset.get('name', report_id)}'.", 'error') - return redirect(url_for('index')) - - # --- Рендерим шаблон --- - context = {"from_date": from_date, "to_date": to_date} - try: - # Используем переименованную функцию - json_body = render_temp(template, context) - except Exception as e: - flash(f"Ошибка подготовки запроса для отчета '{preset.get('name', report_id)}': {e}", 'error') - logger.error(f"User {current_user.id}: Ошибка render_temp: {e}", exc_info=True) - return redirect(url_for('index')) - - - # --- Инициализация модулей --- - req_module = ReqModule(rms_host, rms_login, rms_password) - gs_client = GoogleSheets(cred_path, sheet_url) # Обработка ошибок инициализации уже внутри __init__ - - # --- Выполняем запросы --- - if req_module.login(): - try: - logger.info(f"User {current_user.id}: Отправка OLAP-запроса для отчета {report_id} ('{preset.get('name', '')}')") - result = req_module.take_olap(json_body) - # Уменьшим логирование полного результата, если он большой - logger.debug(f"User {current_user.id}: Получен OLAP-результат (наличие ключа data: {'data' in result}, тип: {type(result.get('data'))})") - - # Обрабатываем данные - if 'data' in result and isinstance(result['data'], list): - headers = [] - data_to_insert = [] - - if result['data']: - # Получаем заголовки из первого элемента - headers = list(result['data'][0].keys()) - data_to_insert.append(headers) # Добавляем строку заголовков - - for item in result['data']: - row = [item.get(h, '') for h in headers] - data_to_insert.append(row) - logger.info(f"User {current_user.id}: Подготовлено {len(data_to_insert) - 1} строк данных для записи в '{sheet_title}'.") - else: - logger.warning(f"User {current_user.id}: OLAP-отчет {report_id} ('{preset.get('name', '')}') не вернул данных за период {from_date} - {to_date}.") - # Если данных нет, data_to_insert будет содержать только заголовки (если они были) или будет пуст - - # --- Запись в Google Sheets --- - try: - # Если данных нет (только заголовки или пустой список), метод очистит лист - gs_client.clear_and_write_data(sheet_title, data_to_insert, start_cell="A1") - - if len(data_to_insert) > 1 : # Были записаны строки данных - flash(f"Данные отчета '{preset.get('name', report_id)}' успешно записаны в лист '{sheet_title}'.", 'success') - elif len(data_to_insert) == 1: # Был записан только заголовок - flash(f"Отчет '{preset.get('name', report_id)}' не вернул данных за указанный период. Лист '{sheet_title}' очищен и записан заголовок.", 'warning') - else: # Не было ни данных, ни заголовков (пустой result['data']) - flash(f"Отчет '{preset.get('name', report_id)}' не вернул данных за указанный период. Лист '{sheet_title}' очищен.", 'warning') - - except Exception as gs_error: - logger.error(f"User {current_user.id}: Не удалось записать данные в Google Sheet '{sheet_title}'. Ошибка: {gs_error}", exc_info=True) - # Не используем f-string в flash для потенциально длинных ошибок - flash(f"Не удалось записать данные в Google Sheet '{sheet_title}'. Детали в логах.", 'error') - - else: - logger.error(f"User {current_user.id}: Неожиданный формат ответа OLAP: ключи={list(result.keys()) if isinstance(result, dict) else 'Не словарь'}") - flash(f"Ошибка: Неожиданный формат ответа от RMS для отчета '{preset.get('name', report_id)}'.", 'error') - - except Exception as report_err: - logger.error(f"User {current_user.id}: Ошибка при получении/записи отчета {report_id}: {report_err}", exc_info=True) - flash(f"Ошибка при получении/записи отчета '{preset.get('name', report_id)}'. Детали в логах.", 'error') - finally: - if req_module and req_module.token: - try: - req_module.logout() - except Exception as logout_err: - logger.warning(f"User {current_user.id}: Ошибка при logout из RMS: {logout_err}") - else: - # Ошибка req_module.login() была залогирована внутри метода - flash('Ошибка авторизации на сервере RMS при попытке получить отчет.', 'error') - - except ValueError as ve: # Ошибка валидации дат или генерации шаблона - flash(f'Ошибка данных: {str(ve)}', 'error') - logger.warning(f"User {current_user.id}: Ошибка ValueError в render_olap: {ve}") - except gspread.exceptions.APIError as api_err: # Ловим ошибки Google API отдельно - logger.error(f"User {current_user.id}: Ошибка Google API: {api_err}", exc_info=True) - flash(f"Ошибка Google API при доступе к таблице/листу '{sheet_title}'. Проверьте права доступа сервисного аккаунта.", 'error') - except Exception as e: - logger.error(f"User {current_user.id}: Общая ошибка в render_olap для листа '{sheet_title}': {str(e)}", exc_info=True) - flash(f"Произошла непредвиденная ошибка: {str(e)}", 'error') - finally: - # Дополнительная проверка logout, если ошибка произошла до блока finally внутри 'if req_module.login()' - if req_module and req_module.token: - try: - req_module.logout() - except Exception as logout_err: - logger.warning(f"User {current_user.id}: Ошибка при финальной попытке logout из RMS: {logout_err}") - - return redirect(url_for('index')) - -# --- Command Line Interface for DB Management --- -# Run 'flask db init' first time -# Run 'flask db migrate -m "Some description"' after changing models -# Run 'flask db upgrade' to apply migrations - -@app.cli.command('init-db') -def init_db_command(): - """Creates the database tables.""" - db.create_all() - print('Initialized the database.') - -# --- Main Execution --- if __name__ == '__main__': - # Ensure the database exists before running - with app.app_context(): - db.create_all() # Create tables if they don't exist - # Run Flask app - # Set debug=False for production! - app.run(debug=False, host='0.0.0.0', port=int(os.environ.get("PORT", 5005))) # Listen on all interfaces if needed \ No newline at end of file + # Для прямого запуска через `python app.py` (удобно для отладки) + app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005))) \ No newline at end of file diff --git a/babel.cfg b/babel.cfg new file mode 100644 index 0000000..1135a51 --- /dev/null +++ b/babel.cfg @@ -0,0 +1,3 @@ +[python: **.py] +[jinja2: **/templates/**.html] +extensions=jinja2.ext.i18n \ No newline at end of file diff --git a/extensions.py b/extensions.py new file mode 100644 index 0000000..453d06b --- /dev/null +++ b/extensions.py @@ -0,0 +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() +babel = Babel() \ No newline at end of file diff --git a/google_sheets.py b/google_sheets.py index ff5905a..4ec7daa 100644 --- a/google_sheets.py +++ b/google_sheets.py @@ -5,127 +5,99 @@ from gspread.utils import rowcol_to_a1 # Настройка логирования logger = logging.getLogger(__name__) - def log_exceptions(func): - """Декоратор для логирования исключений.""" + """Декоратор для стандартизированного логирования исключений в методах класса.""" def wrapper(self, *args, **kwargs): try: return func(self, *args, **kwargs) except gspread.exceptions.APIError as e: - # Логируем специфичные ошибки API Google - logger.error(f"Google API Error in {func.__name__}: {e.response.status_code} - {e.response.text}") - raise # Перевыбрасываем, чтобы app.py мог обработать + # Логируем специфичные ошибки API Google с деталями + error_details = e.response.json().get('error', {}) + status = error_details.get('status') + message = error_details.get('message') + logger.error( + f"Ошибка Google API в методе {func.__name__}: {status} - {message}. " + f"Проверьте права доступа сервисного аккаунта ({self.client_email}) к таблице." + ) + # Перевыбрасываем, чтобы вызывающий код мог ее обработать + raise except Exception as e: - logger.error(f"Error in {func.__name__}: {e}", exc_info=True) + logger.error(f"Непредвиденная ошибка в {func.__name__}: {e}", exc_info=True) raise return wrapper - class GoogleSheets: def __init__(self, cred_file, sheet_url): - """Инициализация клиента Google Sheets.""" + """ + Инициализация клиента для работы с Google Sheets. + + Args: + cred_file (str): Путь к JSON-файлу с учетными данными сервисного аккаунта. + sheet_url (str): URL Google Таблицы. + """ try: + # Используем service_account для аутентификации self.client = gspread.service_account(filename=cred_file) - self.sheet_url = sheet_url + # Открываем таблицу по URL self.spreadsheet = self.client.open_by_url(sheet_url) - logger.info(f"Successfully connected to Google Sheet: {self.spreadsheet.title}") + logger.info(f"Успешное подключение к Google Sheet: '{self.spreadsheet.title}'") + except gspread.exceptions.SpreadsheetNotFound: + logger.error(f"Таблица по URL '{sheet_url}' не найдена. Проверьте URL и права доступа.") + raise + except FileNotFoundError: + logger.error(f"Файл с учетными данными не найден по пути: {cred_file}") + raise except Exception as e: - logger.error(f"Failed to initialize GoogleSheets client or open sheet {sheet_url}: {e}", exc_info=True) + logger.error(f"Ошибка инициализации клиента GoogleSheets или открытия таблицы {sheet_url}: {e}", exc_info=True) raise @log_exceptions def get_sheet(self, sheet_name): - """Возвращает объект листа по имени.""" + """Возвращает объект листа (worksheet) по его имени.""" try: sheet = self.spreadsheet.worksheet(sheet_name) - logger.debug(f"Retrieved worksheet object for '{sheet_name}'") + logger.debug(f"Получен объект листа '{sheet_name}'") return sheet except gspread.exceptions.WorksheetNotFound: - logger.error(f"Worksheet '{sheet_name}' not found in spreadsheet '{self.spreadsheet.title}'.") + logger.error(f"Лист '{sheet_name}' не найден в таблице '{self.spreadsheet.title}'.") raise - except Exception: - raise @log_exceptions def get_sheets(self): - """Получение списка листов в таблице.""" + """Получает список всех листов в таблице.""" sheets = self.spreadsheet.worksheets() + # Собираем информацию о листах: название и ID sheet_data = [{"title": sheet.title, "id": sheet.id} for sheet in sheets] - logger.debug(f"Retrieved {len(sheet_data)} sheets: {[s['title'] for s in sheet_data]}") + logger.debug(f"Получено {len(sheet_data)} листов: {[s['title'] for s in sheet_data]}") return sheet_data @log_exceptions - def update_cell(self, sheet_name, cell, new_value): - """Обновляет значение ячейки с логированием старого значения.""" - sheet = self.get_sheet(sheet_name) - # Используем try-except для получения старого значения, т.к. ячейка может быть пустой - old_value = None - try: - old_value = sheet.acell(cell).value - except Exception as e: - logger.warning(f"Could not get old value for cell {cell} in sheet {sheet_name}: {e}") - - # gspread рекомендует использовать update для одиночных ячеек тоже - sheet.update(cell, new_value, value_input_option='USER_ENTERED') - # Логируем новое значение, т.к. оно могло быть преобразовано Google Sheets - try: - logged_new_value = sheet.acell(cell).value - except Exception: - logged_new_value = new_value # Fallback if reading back fails - - logger.info(f"Cell {cell} in sheet '{sheet_name}' updated. Old: '{old_value}', New: '{logged_new_value}'") - - @log_exceptions - def clear_and_write_data(self, sheet_name, data, start_cell="A1"): + def clear_and_write_data(self, sheet_name, data): """ - Очищает ВЕСЬ указанный лист и записывает новые данные (список списков), - начиная с ячейки start_cell. + Очищает указанный лист и записывает на него новые данные. + + Args: + sheet_name (str): Имя листа для записи. + data (list): Список списков с данными для записи (первый список - заголовки). """ if not isinstance(data, list): - raise TypeError("Data must be a list of lists.") + raise TypeError("Данные для записи должны быть списком списков.") sheet = self.get_sheet(sheet_name) - logger.info(f"Clearing entire sheet '{sheet_name}'...") - sheet.clear() # Очищаем весь лист - logger.info(f"Sheet '{sheet_name}' cleared.") + logger.info(f"Очистка листа '{sheet_name}'...") + sheet.clear() + logger.info(f"Лист '{sheet_name}' очищен.") - if not data or not data[0]: # Проверяем, есть ли вообще данные для записи - logger.warning(f"No data provided to write to sheet '{sheet_name}' after clearing.") - return # Ничего не записываем, если данных нет + # Проверяем, есть ли данные для записи + if not data: + logger.warning(f"Нет данных для записи на лист '{sheet_name}' после очистки.") + return # Завершаем, если список данных пуст num_rows = len(data) - num_cols = len(data[0]) # Предполагаем, что все строки имеют одинаковую длину + num_cols = len(data[0]) if data and data[0] else 0 - # Рассчитываем конечную ячейку на основе начальной и размеров данных - try: - start_row, start_col = gspread.utils.a1_to_rowcol(start_cell) - end_row = start_row + num_rows - 1 - end_col = start_col + num_cols - 1 - end_cell = rowcol_to_a1(end_row, end_col) - range_to_write = f"{start_cell}:{end_cell}" - except Exception as e: - logger.error(f"Failed to calculate range from start_cell '{start_cell}' and data dimensions ({num_rows}x{num_cols}): {e}. Defaulting to A1 notation if possible.") - # Фоллбэк на стандартный A1 диапазон, если расчет сломался - end_cell_simple = rowcol_to_a1(num_rows, num_cols) - range_to_write = f"A1:{end_cell_simple}" - if start_cell != "A1": - logger.warning(f"Using default range {range_to_write} as calculation from start_cell failed.") - - - logger.info(f"Writing {num_rows} rows and {num_cols} columns to sheet '{sheet_name}' in range {range_to_write}...") - # Используем update для записи всего диапазона одним запросом - sheet.update(range_to_write, data, value_input_option='USER_ENTERED') - logger.info(f"Successfully wrote data to sheet '{sheet_name}', range {range_to_write}.") - - @log_exceptions - def read_range(self, sheet_name, range_a1): - """Чтение значений из диапазона.""" - sheet = self.get_sheet(sheet_name) - # batch_get возвращает список списков значений [[...], [...]] - # Используем get() для более простого чтения диапазона - values = sheet.get(range_a1) - # values = sheet.batch_get([range_a1])[0] # batch_get возвращает [[values_for_range1], [values_for_range2], ...] - logger.debug(f"Read {len(values)} rows from sheet '{sheet_name}', range {range_a1}.") - # logger.debug(f"Значения из диапазона {range_a1}: {values}") # Может быть слишком много данных для лога - return values + logger.info(f"Запись {num_rows} строк и {num_cols} колонок на лист '{sheet_name}'...") + # Используем метод update для записи всего диапазона одним API-вызовом + sheet.update(data, value_input_option='USER_ENTERED') + logger.info(f"Данные успешно записаны на лист '{sheet_name}'.") \ No newline at end of file diff --git a/messages.pot b/messages.pot new file mode 100644 index 0000000..3004433 --- /dev/null +++ b/messages.pot @@ -0,0 +1,529 @@ +# Translations template for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-07-26 03:16+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:46 +msgid "Please log in to access this page." +msgstr "" + +#: app.py:114 +msgid "Invalid username or password" +msgstr "" + +#: app.py:117 +msgid "Login successful!" +msgstr "" + +#: app.py:130 +msgid "Username and password are required." +msgstr "" + +#: app.py:133 +msgid "Username already exists." +msgstr "" + +#: app.py:142 +msgid "Registration successful! Please log in." +msgstr "" + +#: app.py:148 +msgid "An error occurred during registration. Please try again." +msgstr "" + +#: app.py:157 +msgid "You have been logged out." +msgstr "" + +#: app.py:189 +msgid "Password is required for the first time." +msgstr "" + +#: app.py:193 +msgid "Host and Login fields must be filled." +msgstr "" + +#: app.py:211 +#, python-format +msgid "Successfully authorized on RMS server. Received %(num)s presets." +msgstr "" + +#: app.py:214 +msgid "Authorization error on RMS server. Check host, login or password." +msgstr "" + +#: app.py:219 +#, python-format +msgid "Error configuring RMS: %(error)s" +msgstr "" + +#: app.py:229 +msgid "No file was selected." +msgstr "" + +#: app.py:248 +msgid "Could not find client_email in the credentials file." +msgstr "" + +#: app.py:258 +#, python-format +msgid "Credentials file successfully uploaded. Email: %(email)s" +msgstr "" + +#: app.py:262 +msgid "Error: Uploaded file is not a valid JSON." +msgstr "" + +#: app.py:267 +#, python-format +msgid "Error processing credentials: %(error)s" +msgstr "" + +#: app.py:282 +msgid "Sheet URL must be provided." +msgstr "" + +#: app.py:289 +msgid "Please upload a valid credentials file first." +msgstr "" + +#: app.py:300 +#, python-format +msgid "" +"Successfully connected to Google Sheets. Found %(num)s sheets. Settings " +"saved." +msgstr "" + +#: app.py:307 +#, python-format +msgid "" +"Error connecting to Google Sheets: %(error)s. Check the URL and service " +"account permissions." +msgstr "" + +#: app.py:333 +msgid "Mappings updated successfully." +msgstr "" + +#: app.py:338 +#, python-format +msgid "Error updating mappings: %(error)s" +msgstr "" + +#: app.py:354 +msgid "Error: Could not determine which sheet to render the report for." +msgstr "" + +#: app.py:361 +#, python-format +msgid "Error: No report is assigned to sheet \"%(sheet)s\"." +msgstr "" + +#: app.py:366 +msgid "Error: RMS or Google Sheets configuration is incomplete." +msgstr "" + +#: app.py:371 +#, python-format +msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration." +msgstr "" + +#: app.py:387 +#, python-format +msgid "Error: Unexpected response format from RMS for report \"%(name)s\"." +msgstr "" + +#: app.py:400 +#, python-format +msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"." +msgstr "" + +#: app.py:402 +#, python-format +msgid "" +"Report \"%(name)s\" returned no data for the selected period. Sheet " +"\"%(sheet)s\" has been cleared." +msgstr "" + +#: app.py:404 +msgid "Error authorizing on RMS server when trying to get a report." +msgstr "" + +#: app.py:407 +#, python-format +msgid "Data Error: %(error)s" +msgstr "" + +#: app.py:410 +#, python-format +msgid "" +"Google API Error accessing sheet \"%(sheet)s\". Check service account " +"permissions." +msgstr "" + +#: app.py:413 +#, python-format +msgid "An unexpected error occurred: %(error)s" +msgstr "" + +#: templates/index.html:6 +msgid "MyHoreca OLAPer" +msgstr "" + +#: templates/index.html:11 +msgid "MyHoreca OLAP-to-GoogleSheets" +msgstr "" + +#: templates/index.html:15 +msgid "Logged in as:" +msgstr "" + +#: templates/index.html:16 +msgid "Logout" +msgstr "" + +#: templates/index.html:18 +msgid "Русский" +msgstr "" + +#: templates/index.html:19 +msgid "English" +msgstr "" + +#: templates/index.html:24 templates/login.html:4 templates/login.html:13 +#: templates/login.html:29 +msgid "Login" +msgstr "" + +#: templates/index.html:25 templates/register.html:4 templates/register.html:13 +#: templates/register.html:26 +msgid "Register" +msgstr "" + +#: templates/index.html:41 +msgid "Connection to RMS-server" +msgstr "" + +#: templates/index.html:43 +msgid "RMS Server Configuration" +msgstr "" + +#: templates/index.html:45 +msgid "" +"Enter the details for your RMS server API. This information is used to " +"connect,\n" +" authenticate, and retrieve the list of available OLAP report " +"presets." +msgstr "" + +#: templates/index.html:49 +msgid "RMS-host (e.g., http://your-rms-api.com/resto):" +msgstr "" + +#: templates/index.html:52 +msgid "API Login:" +msgstr "" + +#: templates/index.html:55 +msgid "API Password:" +msgstr "" + +#: templates/index.html:58 +msgid "Password is saved. Enter a new one only if you need to change it." +msgstr "" + +#: templates/index.html:60 +msgid "Enter the API password for your RMS server." +msgstr "" + +#: templates/index.html:63 +msgid "Check and Save RMS-config" +msgstr "" + +#: templates/index.html:66 templates/index.html:68 templates/index.html:116 +#: templates/index.html:118 +msgid "Status:" +msgstr "" + +#: templates/index.html:66 +#, python-format +msgid "Successfully connected to RMS. Found %(num)s OLAP presets." +msgstr "" + +#: templates/index.html:68 +msgid "RMS configuration saved. Presets not yet loaded or connection failed." +msgstr "" + +#: templates/index.html:73 +msgid "Configure RMS first" +msgstr "" + +#: templates/index.html:74 templates/index.html:77 +msgid "Google Sheets Configuration" +msgstr "" + +#: templates/index.html:79 +msgid "" +"To allow the application to write to your Google Sheet, you need to " +"provide\n" +" credentials for a Google Service Account. This account will act" +" on behalf\n" +" of the application." +msgstr "" + +#: templates/index.html:84 +msgid "How to get credentials:" +msgstr "" + +#: templates/index.html:85 +msgid "Go to Google Cloud Console." +msgstr "" + +#: templates/index.html:86 +msgid "Create a new project or select an existing one." +msgstr "" + +#: templates/index.html:87 +msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project." +msgstr "" + +#: templates/index.html:88 +msgid "" +"Go to \"Credentials\", click \"Create Credentials\", choose \"Service " +"Account\"." +msgstr "" + +#: templates/index.html:89 +msgid "Give it a name and grant it the \"Editor\" role." +msgstr "" + +#: templates/index.html:90 +msgid "Create a JSON key for the service account and download the file." +msgstr "" + +#: templates/index.html:91 +msgid "" +"Share your target Google Sheet with the service account's email address " +"(found in the downloaded JSON file, key `client_email`)." +msgstr "" + +#: templates/index.html:94 +msgid "Service Account Credentials (JSON file):" +msgstr "" + +#: templates/index.html:97 +msgid "Current Service Account Email:" +msgstr "" + +#: templates/index.html:98 +msgid "Upload a new file only if you need to change credentials." +msgstr "" + +#: templates/index.html:100 +msgid "Upload the JSON file downloaded from Google Cloud Console." +msgstr "" + +#: templates/index.html:102 +msgid "Upload Credentials" +msgstr "" + +#: templates/index.html:106 +msgid "" +"Enter the URL of the Google Sheet you want to use. The service account " +"email\n" +" (shown above after uploading credentials) must have edit " +"access to this sheet." +msgstr "" + +#: templates/index.html:110 +msgid "Google Sheet URL:" +msgstr "" + +#: templates/index.html:112 +msgid "Upload Service Account Credentials first" +msgstr "" + +#: templates/index.html:113 +msgid "Connect Google Sheets" +msgstr "" + +#: templates/index.html:116 +#, python-format +msgid "Successfully connected to Google Sheet. Found %(num)s worksheets." +msgstr "" + +#: templates/index.html:118 +msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed." +msgstr "" + +#: templates/index.html:124 +msgid "Configure RMS and Google Sheets first" +msgstr "" + +#: templates/index.html:125 +msgid "Mapping Sheets to OLAP Reports" +msgstr "" + +#: templates/index.html:128 +msgid "Map Worksheets to OLAP Reports" +msgstr "" + +#: templates/index.html:130 +msgid "" +"Select which OLAP report from RMS should be rendered into each specific " +"worksheet\n" +" (tab) in your Google Sheet." +msgstr "" + +#: templates/index.html:138 +msgid "Worksheet (Google Sheets)" +msgstr "" + +#: templates/index.html:139 +msgid "OLAP-report (RMS)" +msgstr "" + +#: templates/index.html:148 +msgid "Not set" +msgstr "" + +#: templates/index.html:160 +msgid "Save Mappings" +msgstr "" + +#: templates/index.html:163 +msgid "" +"Worksheets and OLAP presets are not loaded. Please configure RMS and " +"Google Sheets first." +msgstr "" + +#: templates/index.html:165 +msgid "Worksheets are not loaded. Check Google Sheets configuration." +msgstr "" + +#: templates/index.html:167 +msgid "OLAP presets are not loaded. Check RMS configuration." +msgstr "" + +#: templates/index.html:172 +msgid "Configure Mappings first" +msgstr "" + +#: templates/index.html:173 +msgid "Render Reports to Sheets" +msgstr "" + +#: templates/index.html:176 +msgid "Render Reports" +msgstr "" + +#: templates/index.html:178 +msgid "" +"Select the date range and click \"Render to sheet\" for each mapping you " +"wish to execute.\n" +" The application will retrieve the OLAP data from RMS for the " +"selected report and period,\n" +" clear the corresponding worksheet in Google Sheets, and write " +"the new data." +msgstr "" + +#: templates/index.html:184 +msgid "From Date:" +msgstr "" + +#: templates/index.html:187 +msgid "To Date:" +msgstr "" + +#: templates/index.html:193 +msgid "Worksheet" +msgstr "" + +#: templates/index.html:194 +msgid "Mapped OLAP Report" +msgstr "" + +#: templates/index.html:195 +msgid "Action" +msgstr "" + +#: templates/index.html:203 +msgid "ID: " +msgstr "" + +#: templates/index.html:206 +msgid "Unnamed Preset" +msgstr "" + +#: templates/index.html:214 +msgid "Render to sheet" +msgstr "" + +#: templates/index.html:224 +msgid "No mappings configured yet." +msgstr "" + +#: templates/index.html:225 +msgid "" +"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to " +"set up mappings." +msgstr "" + +#: templates/index.html:248 +msgid "Please," +msgstr "" + +#: templates/index.html:248 +msgid "login" +msgstr "" + +#: templates/index.html:248 +msgid "or" +msgstr "" + +#: templates/index.html:248 +msgid "register" +msgstr "" + +#: templates/login.html:22 templates/register.html:22 +msgid "Username:" +msgstr "" + +#: templates/login.html:24 templates/register.html:24 +msgid "Password:" +msgstr "" + +#: templates/login.html:27 +msgid "Remember Me" +msgstr "" + +#: templates/login.html:31 +msgid "Don't have an account?" +msgstr "" + +#: templates/login.html:31 +msgid "Register here" +msgstr "" + +#: templates/register.html:28 +msgid "Already have an account?" +msgstr "" + +#: templates/register.html:28 +msgid "Login here" +msgstr "" + diff --git a/models.py b/models.py index e16651d..230b9a5 100644 --- a/models.py +++ b/models.py @@ -8,34 +8,36 @@ import logging logger = logging.getLogger(__name__) -db = SQLAlchemy() +# 1. Инициализируем расширение без привязки к app +from extensions import db -# Generate a key for encryption. STORE THIS SECURELY in production (e.g., env variable) -# For development, we can generate/load it from a file. -encryption_key_str = os.environ.get('ENCRYPTION_KEY') -if not encryption_key_str: - logger.error("ENCRYPTION_KEY environment variable not set! RMS password encryption will fail.") - # Можно либо упасть с ошибкой, либо использовать временный ключ (НЕ РЕКОМЕНДУЕТСЯ для продакшена) - # raise ValueError("ENCRYPTION_KEY environment variable is required.") - # Для локального запуска без установки переменной, можно временно сгенерировать: - logger.warning("Generating temporary encryption key. SET ENCRYPTION_KEY ENV VAR FOR PRODUCTION!") - ENCRYPTION_KEY = Fernet.generate_key() -else: - try: - ENCRYPTION_KEY = encryption_key_str.encode('utf-8') - # Простая проверка, что ключ валидный для Fernet - Fernet(ENCRYPTION_KEY) - logger.info("Successfully loaded ENCRYPTION_KEY from environment variable.") - except Exception as e: - logger.error(f"Invalid ENCRYPTION_KEY format in environment variable: {e}") - raise ValueError("Invalid ENCRYPTION_KEY format.") from e +# 2. Инициализация Fernet вынесена в функцию, чтобы она вызывалась ПОСЛЕ загрузки .env +fernet = None + +def init_encryption(app): + """Инициализирует Fernet после того, как конфигурация загружена.""" + global fernet + encryption_key_str = app.config.get('ENCRYPTION_KEY') + if not encryption_key_str: + logger.error("Переменная окружения ENCRYPTION_KEY не установлена! Шифрование паролей RMS не будет работать.") + logger.warning("Генерируется временный ключ шифрования. Для продакшена ОБЯЗАТЕЛЬНО установите ENCRYPTION_KEY!") + encryption_key = Fernet.generate_key() + else: + try: + encryption_key = encryption_key_str.encode('utf-8') + Fernet(encryption_key) + logger.info("Ключ шифрования ENCRYPTION_KEY успешно загружен.") + except Exception as e: + logger.critical(f"Недопустимый формат ключа ENCRYPTION_KEY: {e}") + raise ValueError("Недопустимый формат ключа ENCRYPTION_KEY.") from e + fernet = Fernet(encryption_key) -fernet = Fernet(ENCRYPTION_KEY) class User(db.Model, UserMixin): + # ... код класса User остается без изменений ... id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) - password_hash = db.Column(db.String(128)) + password_hash = db.Column(db.String(256)) config = db.relationship('UserConfig', backref='user', uselist=False, cascade="all, delete-orphan") def set_password(self, password): @@ -47,81 +49,73 @@ class User(db.Model, UserMixin): def __repr__(self): return f'' + class UserConfig(db.Model): + # ... код класса UserConfig почти без изменений ... id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True) - - # RMS Config rms_host = db.Column(db.String(200)) rms_login = db.Column(db.String(100)) - rms_password_encrypted = db.Column(db.LargeBinary) # Store encrypted password - - # Google Config - google_cred_file_path = db.Column(db.String(300)) # Store path, not content + rms_password_encrypted = db.Column(db.LargeBinary) + google_cred_file_path = db.Column(db.String(300)) google_sheet_url = db.Column(db.String(300)) - google_client_email = db.Column(db.String(200)) # Store for display - - # Mappings, Presets, Sheets (Stored as JSON strings) - mappings_json = db.Column(db.Text, default='{}') + google_client_email = db.Column(db.String(200)) presets_json = db.Column(db.Text, default='[]') sheets_json = db.Column(db.Text, default='[]') - - # --- Helper properties for easy access --- + mappings_json = db.Column(db.Text, default='{}') @property def rms_password(self): + """Дешифрует пароль RMS при доступе.""" + if not fernet: + raise RuntimeError("Fernet encryption is not initialized. Call init_encryption(app) first.") if self.rms_password_encrypted: try: return fernet.decrypt(self.rms_password_encrypted).decode('utf-8') - except Exception: # Handle potential decryption errors + except Exception as e: + logger.error(f"Ошибка дешифрования пароля для user_id {self.user_id}: {e}") return None return None @rms_password.setter def rms_password(self, value): + """Шифрует пароль RMS при установке.""" + if not fernet: + raise RuntimeError("Fernet encryption is not initialized. Call init_encryption(app) first.") if value: self.rms_password_encrypted = fernet.encrypt(value.encode('utf-8')) else: - self.rms_password_encrypted = None # Or handle as needed - - @property - def mappings(self): - return json.loads(self.mappings_json or '{}') - - @mappings.setter - def mappings(self, value): - self.mappings_json = json.dumps(value or {}, ensure_ascii=False) + self.rms_password_encrypted = None + # ... остальные properties (presets, sheets, mappings) и методы (get_rms_dict, get_google_dict) остаются без изменений ... @property def presets(self): return json.loads(self.presets_json or '[]') - @presets.setter def presets(self, value): self.presets_json = json.dumps(value or [], ensure_ascii=False) - @property def sheets(self): return json.loads(self.sheets_json or '[]') - @sheets.setter def sheets(self, value): self.sheets_json = json.dumps(value or [], ensure_ascii=False) - - # Convenience getter for template display + @property + def mappings(self): + return json.loads(self.mappings_json or '{}') + @mappings.setter + def mappings(self, value): + self.mappings_json = json.dumps(value or {}, ensure_ascii=False) def get_rms_dict(self): return { 'host': self.rms_host or '', 'login': self.rms_login or '', - 'password': self.rms_password or '' # Use decrypted password here if needed for display/form population (be cautious!) - # Usually, password fields are left blank in forms for security. + 'password_is_set': bool(self.rms_password_encrypted) } - def get_google_dict(self): - return { - 'cred_file': self.google_cred_file_path or '', # Maybe just indicate if file exists? + return { + 'cred_file_is_set': bool(self.google_cred_file_path and os.path.exists(self.google_cred_file_path)), 'sheet_url': self.google_sheet_url or '' - } - + } def __repr__(self): return f'' \ No newline at end of file diff --git a/prompt b/prompt new file mode 100644 index 0000000..99addea --- /dev/null +++ b/prompt @@ -0,0 +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, пути, названия), всегда уточняй их у меня. +Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала. diff --git a/request_module.py b/request_module.py index 5f674c6..6837c15 100644 --- a/request_module.py +++ b/request_module.py @@ -4,17 +4,20 @@ import hashlib # Настройка логирования logger = logging.getLogger(__name__) -logger.setLevel(logging.DEBUG) +# Уровень логирования настраивается в основном модуле app.py +# logger.setLevel(logging.DEBUG) class ReqModule: def __init__(self, host, rmsLogin, password): self.host = host self.rmsLogin = rmsLogin + # Пароль для API iiko/Syrve должен передаваться в виде SHA1-хэша self.password = hashlib.sha1(password.encode('utf-8')).hexdigest() self.token = None self.session = requests.Session() def login(self): + """Выполняет авторизацию на сервере RMS и получает токен.""" logger.info(f"Вызов метода login с логином: {self.rmsLogin}") try: response = self.session.post( @@ -22,19 +25,25 @@ class ReqModule: data={'login': self.rmsLogin, 'pass': self.password}, headers={'Content-Type': 'application/x-www-form-urlencoded'} ) - if response.status_code == 200: - self.token = response.text - logger.info(f'Получен токен: {self.token}') - return True - elif response.status_code == 401: - logger.error(f'Ошибка авторизации. {response.text}') - raise Exception('Unauthorized') + response.raise_for_status() # Вызовет исключение для статусов 4xx/5xx + self.token = response.text + logger.info(f'Успешно получен токен: {self.token[:8]}...') # Логируем только часть токена + return True + except requests.exceptions.HTTPError as e: + if e.response.status_code == 401: + logger.error(f'Ошибка авторизации (401). Неверный логин или пароль. Ответ сервера: {e.response.text}') + else: + logger.error(f'HTTP ошибка при авторизации: {e}') + return False # Возвращаем False вместо выброса исключения для удобства обработки в app.py except Exception as e: - logger.error(f'Error in get_token: {str(e)}') - raise + logger.error(f'Непредвиденная ошибка в login: {str(e)}') + raise # Выбрасываем исключение для критических ошибок (например, недоступность хоста) def logout(self): - """Функция для освобождения токена авторизации.""" + """Освобождает токен авторизации на сервере RMS.""" + if not self.token: + logger.warning("Попытка вызова logout без активного токена.") + return False try: response = self.session.post( f'{self.host}/api/logout', @@ -42,15 +51,20 @@ class ReqModule: headers={'Content-Type': 'application/x-www-form-urlencoded'} ) if response.status_code == 200: - logger.info(f"{self.token} -- Токен освобожден") + logger.info(f"Токен {self.token[:8]}... успешно освобожден.") self.token = None return True + else: + logger.warning(f"Не удалось освободить токен. Статус: {response.status_code}, Ответ: {response.text}") + return False except Exception as e: - logger.error(f'Ошибка освобождения токена. {str(e)}') + logger.error(f'Ошибка при освобождении токена: {str(e)}') raise def take_olap(self, params): - """Функция для отправки кастомного OLAP-запроса.""" + """Отправляет кастомный OLAP-запрос на сервер RMS.""" + if not self.token: + raise Exception("Невозможно выполнить запрос take_olap: отсутствует токен авторизации.") try: cookies = {'key': self.token} response = self.session.post( @@ -58,30 +72,32 @@ class ReqModule: json=params, cookies=cookies ) - if response.status_code == 200: - return response.json() - else: - logger.error(f'Не удалось получить кастомный OLAP. Status code: {response.status_code} \nText: {response.text}') - raise Exception('Request failed') + response.raise_for_status() # Проверка на HTTP ошибки + return response.json() + except requests.exceptions.RequestException as e: + logger.error(f'Ошибка при выполнении OLAP-запроса: {e}. URL: {e.request.url if e.request else "N/A"}') + raise Exception(f'Ошибка сети при запросе OLAP: {e}') except Exception as e: - logger.error(f'Error in send_olap_request: {str(e)}') + logger.error(f'Непредвиденная ошибка в take_olap: {str(e)}') raise def take_presets(self): - """Функция генерации шаблонов OLAP-запросов""" + """Получает список доступных OLAP-отчетов (пресетов) с сервера RMS.""" + if not self.token: + raise Exception("Невозможно выполнить запрос take_presets: отсутствует токен авторизации.") try: cookies = {'key': self.token} response = self.session.get( f'{self.host}/api/v2/reports/olap/presets', cookies=cookies ) - if response.status_code == 200: - presets = response.json() - logger.info('Пресеты переданы в генератор шаблонов') - return presets - else: - logger.error(f"Не удалось получить пресеты. {response.text}") - raise Exception('Take presets failed') + response.raise_for_status() # Проверка на HTTP ошибки + presets = response.json() + logger.info(f"Успешно получено {len(presets)} пресетов OLAP-отчетов.") + return presets + except requests.exceptions.RequestException as e: + logger.error(f"Сетевая ошибка при получении пресетов: {e}") + raise Exception(f'Ошибка сети при получении пресетов: {e}') except Exception as e: - logger.error(f'Ошибка получения пресетов: {str(e)}') + logger.error(f'Непредвиденная ошибка в take_presets: {str(e)}') raise \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 5935e5d..416c572 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..2506b6b --- /dev/null +++ b/routes.py @@ -0,0 +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() + + return redirect(url_for('.index')) \ No newline at end of file diff --git a/static/style.css b/static/style.css index 78405e3..525d488 100644 --- a/static/style.css +++ b/static/style.css @@ -107,17 +107,18 @@ button:disabled { } .content { - padding: 15px; - display: none; + padding: 0 18px; /* Добавляем горизонтальный паддинг, но убираем вертикальный */ + max-height: 0; /* Изначально контент сжат по высоте */ overflow: hidden; - background-color: #fefefe; /* Very light background */ + transition: max-height 0.3s ease-out; /* Плавный переход для высоты */ + background-color: #fefefe; width: 100%; max-width: 500px; margin: 0 auto; box-sizing: border-box; border: 1px solid #eee; - border-top: none; /* No top border to connect visually with collapsible */ - border-radius: 0 0 8px 8px; /* Rounded corners only at the bottom */ + border-top: none; + border-radius: 0 0 8px 8px; } .content h3 { /* Style for internal content headings */ diff --git a/templates/index.html b/templates/index.html index dc92904..6c9cda8 100644 --- a/templates/index.html +++ b/templates/index.html @@ -1,24 +1,28 @@ - + - MyHoreca OLAPer + {{ _('MyHoreca OLAPer') }} -

MyHoreca OLAP-to-GoogleSheets

+

{{ _('MyHoreca OLAP-to-GoogleSheets') }}

{% if current_user.is_authenticated %} {% else %} {% endif %} @@ -34,105 +38,105 @@ {% if current_user.is_authenticated %}
- +
-

RMS Server Configuration

+

{{ _('RMS Server Configuration') }}

- Enter the details for your RMS server API. This information is used to connect, - authenticate, and retrieve the list of available OLAP report presets. + {% trans %}Enter the details for your RMS server API. This information is used to connect, + authenticate, and retrieve the list of available OLAP report presets.{% endtrans %}

-
- + +
- +
- -
- {% if rms_config.get('password') %} - Password is saved and will be used. Enter only if you need to change it.
+ +
+ {% if rms_config.password_is_set %} + {{ _('Password is saved. Enter a new one only if you need to change it.') }}
{% else %} - Enter the API password for your RMS server.
+ {{ _('Enter the API password for your RMS server.') }}
{% endif %} - +
{% if presets %} -

Status: Successfully connected to RMS. Found {{ presets|length }} OLAP presets.

+

{{ _('Status:') }} {% trans num=presets|length %}Successfully connected to RMS. Found %(num)s OLAP presets.{% endtrans %}

{% elif rms_config.get('host') %} -

Status: RMS configuration saved. Presets not yet loaded or connection failed.

+

{{ _('Status:') }} {{ _('RMS configuration saved. Presets not yet loaded or connection failed.') }}

{% endif %}
-
-

Google Sheets Configuration

+

{{ _('Google Sheets Configuration') }}

- To allow the application to write to your Google Sheet, you need to provide + {% trans %}To allow the application to write to your Google Sheet, you need to provide credentials for a Google Service Account. This account will act on behalf - of the application. + of the application.{% endtrans %}

- How to get credentials: -
1. Go to Google Cloud Console. -
2. Create a new project or select an existing one. -
3. Enable the "Google Sheets API" and "Google Drive API" for the project. -
4. Go to "Credentials", click "Create Credentials", choose "Service Account". -
5. Give it a name, grant it necessary permissions (e.g., Editor role for simplicity, or more granular roles for Sheets/Drive). -
6. Create a JSON key for the service account. Download this file. -
7. Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`). + {{ _('How to get credentials:') }} +
1. {{ _('Go to Google Cloud Console.') }} +
2. {{ _('Create a new project or select an existing one.') }} +
3. {{ _('Enable the "Google Sheets API" and "Google Drive API" for the project.') }} +
4. {{ _('Go to "Credentials", click "Create Credentials", choose "Service Account".') }} +
5. {{ _('Give it a name and grant it the "Editor" role.') }} +
6. {{ _('Create a JSON key for the service account and download the file.') }} +
7. {% trans %}Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`).{% endtrans %}

-
- -
+ + +
{% if client_email %} -

Current Service Account Email: {{ client_email }}

- Upload a new file only if you need to change credentials.
+

{{ _('Current Service Account Email:') }} {{ client_email }}

+ {{ _('Upload a new file only if you need to change credentials.') }}
{% else %} - Upload the JSON file downloaded from Google Cloud Console.
+ {{ _('Upload the JSON file downloaded from Google Cloud Console.') }}
{% endif %} - +

- Enter the URL of the Google Sheet you want to use. The service account email - (shown above after uploading credentials) must have edit access to this sheet. + {% trans %}Enter the URL of the Google Sheet you want to use. The service account email + (shown above after uploading credentials) must have edit access to this sheet.{% endtrans %}

-
- + + - {% if sheets %} -

Status: Successfully connected to Google Sheet. Found {{ sheets|length }} worksheets.

+

{{ _('Status:') }} {% trans num=sheets|length %}Successfully connected to Google Sheet. Found %(num)s worksheets.{% endtrans %}

{% elif google_config.get('sheet_url') %} -

Status: Google Sheet URL saved. Worksheets not yet loaded or connection failed.

+

{{ _('Status:') }} {{ _('Google Sheet URL saved. Worksheets not yet loaded or connection failed.') }}

{% endif %}
-
-

Map Worksheets to OLAP Reports

+

{{ _('Map Worksheets to OLAP Reports') }}

- Select which OLAP report from RMS should be rendered into each specific worksheet - (tab) in your Google Sheet. + {% trans %}Select which OLAP report from RMS should be rendered into each specific worksheet + (tab) in your Google Sheet.{% endtrans %}

{% if sheets and presets %} -
+ - - + + @@ -140,9 +144,8 @@
Worksheet (Google Sheets)OLAP-report (RMS){{ _('Worksheet (Google Sheets)') }}{{ _('OLAP-report (RMS)') }}
{{ sheet.title }} -
- +
{% elif not sheets and not presets %} -

Worksheets and OLAP presets are not loaded. Please configure RMS and Google Sheets first.

+

{{ _('Worksheets and OLAP presets are not loaded. Please configure RMS and Google Sheets first.') }}

{% elif not sheets %} -

Worksheets are not loaded. Check Google Sheets configuration.

+

{{ _('Worksheets are not loaded. Check Google Sheets configuration.') }}

{% elif not presets %} -

OLAP presets are not loaded. Check RMS configuration.

+

{{ _('OLAP presets are not loaded. Check RMS configuration.') }}

{% endif %}
-
-

Render Reports

+

{{ _('Render Reports') }}

- Select the date range and click "Render to sheet" for each mapping you wish to execute. + {% trans %}Select the date range and click "Render to sheet" for each mapping you wish to execute. The application will retrieve the OLAP data from RMS for the selected report and period, - clear the corresponding worksheet in Google Sheets, and write the new data. + clear the corresponding worksheet in Google Sheets, and write the new data.{% endtrans %}

{% if mappings and mappings|length > 0 %} -
- + +
- +
- - - + + + - {# Iterate through sheets loaded from Google, check for mapping #} {% for sheet in sheets %} {% set report_id = mappings.get(sheet.title) %} - {% if report_id %} {# Only display rows with a valid mapping #} - {# Find the preset name by ID using Jinja filters #} - {# Find the preset dictionary where 'id' attribute equals report_id #} + {% if report_id %} {% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %} - {% set preset_name = 'ID: ' + report_id %} {# Default display if preset not found or unnamed #} - - {# If a matching preset was found, get its name #} + {% set preset_name = _('ID: ') + report_id %} {% if matching_presets %} {% set preset = matching_presets[0] %} - {% set preset_name = preset.get('name', 'Unnamed Preset') %} + {% set preset_name = preset.get('name', _('Unnamed Preset')) %} {% endif %} @@ -213,7 +211,7 @@ @@ -223,8 +221,8 @@
WorksheetMapped OLAP ReportAction{{ _('Worksheet') }}{{ _('Mapped OLAP Report') }}{{ _('Action') }}
{{ preset_name }}
{% else %} -

No mappings configured yet.

-

Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.

+

{{ _('No mappings configured yet.') }}

+

{{ _('Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.') }}

{% endif %}
@@ -235,56 +233,27 @@ var coll = document.getElementsByClassName("collapsible"); for (var i = 0; i < coll.length; i++) { coll[i].addEventListener("click", function () { - // Не переключать, если кнопка отключена - if (this.disabled) return; + // Не выполнять действие, если кнопка отключена + if (this.disabled) { + return; + } this.classList.toggle("active"); var content = this.nextElementSibling; - if (content.style.display === "block") { - content.style.display = "none"; + + // Если max-height установлен (т.е. секция открыта), то скрыть ее + if (content.style.maxHeight) { + content.style.maxHeight = null; } else { - content.style.display = "block"; + // Иначе (секция закрыта), установить max-height равным высоте контента + // Это "раскроет" секцию с плавной анимацией + content.style.maxHeight = content.scrollHeight + "px"; } }); } - - // Optional: Auto-expand sections based on config state? - // This requires passing more state from the Flask app to the template. - // For now, keep it simple with manual expansion. - // window.addEventListener('load', () => { - // // Example logic: if RMS configured but Google not, open Google section - // const rmsConfigured = '{{ rms_config.get("host") }}' !== ''; - // const googleCredsExist = '{{ client_email }}' !== ''; - // const googleSheetUrlSet = '{{ google_config.get("sheet_url") }}' !== ''; - // // Corrected lines: - // const presetsLoaded = {{ (presets|length > 0) | lower }}; - // const sheetsLoaded = {{ (sheets|length > 0) | lower }}; - // const mappingsExist = {{ (mappings|length > 0) | lower }}; - - // const collapsibles = document.getElementsByClassName("collapsible"); - - // if (rmsConfigured && !googleCredsExist) { - // // Find and click Google Sheets collapsible - // for (let i = 0; i < collapsibles.length; i++) { - // if (collapsibles[i].innerText.includes("Google Sheets Configuration")) { - // collapsibles[i].click(); - // break; - // } - // } - // } else if (rmsConfigured && googleCredsExist && googleSheetUrlSet && presetsLoaded && sheetsLoaded && !mappingsExist) { - // // Find and click Mapping collapsible - // for (let i = 0; i in collapsibles.length; i++) { // <-- Potential typo here, should be < - // if (collapsibles[i].innerText.includes("Mapping Sheets to OLAP Reports")) { - // collapsibles[i].click(); - // break; - // } - // } - // } - // // Add more conditions as needed - // }); {% else %} -

Please, login or register

+

{{ _('Please,') }} {{ _('login') }} {{ _('or') }} {{ _('register') }}

{% endif %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html index fcfb7bc..4a411d5 100644 --- a/templates/login.html +++ b/templates/login.html @@ -1,12 +1,16 @@ - Login - + {{ _('Login') }} + -
{# <-- Add this wrapper div #} -

Login

+
+ +

{{ _('Login') }}

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} @@ -15,17 +19,16 @@ {% endif %} {% endwith %}
- +
- +
- {# Adjusted label and input for "Remember Me" #}
- +
-

Don't have an account? Register here

-
{# <-- Close the wrapper div #} +

{{ _("Don't have an account?") }} {{ _('Register here') }}

+
\ No newline at end of file diff --git a/templates/register.html b/templates/register.html index 7b8f6d4..4cfa6dd 100644 --- a/templates/register.html +++ b/templates/register.html @@ -1,12 +1,16 @@ - Register + {{ _('Register') }} -
{# <-- Add this wrapper div #} -

Register

+
+ +

{{ _('Register') }}

{% with messages = get_flashed_messages(with_categories=true) %} {% if messages %} {% for category, message in messages %} @@ -15,13 +19,13 @@ {% endif %} {% endwith %}
- +
- +
- +
-

Already have an account? Login here

-
{# <-- Close the wrapper div #} +

{{ _("Already have an account?") }} {{ _('Login here') }}

+
\ No newline at end of file diff --git a/translations/en/LC_MESSAGES/messages.mo b/translations/en/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..985a11b Binary files /dev/null and b/translations/en/LC_MESSAGES/messages.mo differ diff --git a/translations/en/LC_MESSAGES/messages.po b/translations/en/LC_MESSAGES/messages.po new file mode 100644 index 0000000..3c3247b --- /dev/null +++ b/translations/en/LC_MESSAGES/messages.po @@ -0,0 +1,530 @@ +# English translations for PROJECT. +# Copyright (C) 2025 ORGANIZATION +# This file is distributed under the same license as the PROJECT project. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-07-26 03:16+0300\n" +"PO-Revision-Date: 2025-07-26 03:24+0300\n" +"Last-Translator: FULL NAME \n" +"Language: en\n" +"Language-Team: en \n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:46 +msgid "Please log in to access this page." +msgstr "" + +#: app.py:114 +msgid "Invalid username or password" +msgstr "" + +#: app.py:117 +msgid "Login successful!" +msgstr "" + +#: app.py:130 +msgid "Username and password are required." +msgstr "" + +#: app.py:133 +msgid "Username already exists." +msgstr "" + +#: app.py:142 +msgid "Registration successful! Please log in." +msgstr "" + +#: app.py:148 +msgid "An error occurred during registration. Please try again." +msgstr "" + +#: app.py:157 +msgid "You have been logged out." +msgstr "" + +#: app.py:189 +msgid "Password is required for the first time." +msgstr "" + +#: app.py:193 +msgid "Host and Login fields must be filled." +msgstr "" + +#: app.py:211 +#, python-format +msgid "Successfully authorized on RMS server. Received %(num)s presets." +msgstr "" + +#: app.py:214 +msgid "Authorization error on RMS server. Check host, login or password." +msgstr "" + +#: app.py:219 +#, python-format +msgid "Error configuring RMS: %(error)s" +msgstr "" + +#: app.py:229 +msgid "No file was selected." +msgstr "" + +#: app.py:248 +msgid "Could not find client_email in the credentials file." +msgstr "" + +#: app.py:258 +#, python-format +msgid "Credentials file successfully uploaded. Email: %(email)s" +msgstr "" + +#: app.py:262 +msgid "Error: Uploaded file is not a valid JSON." +msgstr "" + +#: app.py:267 +#, python-format +msgid "Error processing credentials: %(error)s" +msgstr "" + +#: app.py:282 +msgid "Sheet URL must be provided." +msgstr "" + +#: app.py:289 +msgid "Please upload a valid credentials file first." +msgstr "" + +#: app.py:300 +#, python-format +msgid "" +"Successfully connected to Google Sheets. Found %(num)s sheets. Settings " +"saved." +msgstr "" + +#: app.py:307 +#, python-format +msgid "" +"Error connecting to Google Sheets: %(error)s. Check the URL and service " +"account permissions." +msgstr "" + +#: app.py:333 +msgid "Mappings updated successfully." +msgstr "" + +#: app.py:338 +#, python-format +msgid "Error updating mappings: %(error)s" +msgstr "" + +#: app.py:354 +msgid "Error: Could not determine which sheet to render the report for." +msgstr "" + +#: app.py:361 +#, python-format +msgid "Error: No report is assigned to sheet \"%(sheet)s\"." +msgstr "" + +#: app.py:366 +msgid "Error: RMS or Google Sheets configuration is incomplete." +msgstr "" + +#: app.py:371 +#, python-format +msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration." +msgstr "" + +#: app.py:387 +#, python-format +msgid "Error: Unexpected response format from RMS for report \"%(name)s\"." +msgstr "" + +#: app.py:400 +#, python-format +msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"." +msgstr "" + +#: app.py:402 +#, python-format +msgid "" +"Report \"%(name)s\" returned no data for the selected period. Sheet " +"\"%(sheet)s\" has been cleared." +msgstr "" + +#: app.py:404 +msgid "Error authorizing on RMS server when trying to get a report." +msgstr "" + +#: app.py:407 +#, python-format +msgid "Data Error: %(error)s" +msgstr "" + +#: app.py:410 +#, python-format +msgid "" +"Google API Error accessing sheet \"%(sheet)s\". Check service account " +"permissions." +msgstr "" + +#: app.py:413 +#, python-format +msgid "An unexpected error occurred: %(error)s" +msgstr "" + +#: templates/index.html:6 +msgid "MyHoreca OLAPer" +msgstr "" + +#: templates/index.html:11 +msgid "MyHoreca OLAP-to-GoogleSheets" +msgstr "" + +#: templates/index.html:15 +msgid "Logged in as:" +msgstr "" + +#: templates/index.html:16 +msgid "Logout" +msgstr "" + +#: templates/index.html:18 +msgid "Русский" +msgstr "" + +#: templates/index.html:19 +msgid "English" +msgstr "" + +#: templates/index.html:24 templates/login.html:4 templates/login.html:13 +#: templates/login.html:29 +msgid "Login" +msgstr "" + +#: templates/index.html:25 templates/register.html:4 templates/register.html:13 +#: templates/register.html:26 +msgid "Register" +msgstr "" + +#: templates/index.html:41 +msgid "Connection to RMS-server" +msgstr "" + +#: templates/index.html:43 +msgid "RMS Server Configuration" +msgstr "" + +#: templates/index.html:45 +msgid "" +"Enter the details for your RMS server API. This information is used to " +"connect,\n" +" authenticate, and retrieve the list of available OLAP report " +"presets." +msgstr "" + +#: templates/index.html:49 +msgid "RMS-host (e.g., http://your-rms-api.com/resto):" +msgstr "" + +#: templates/index.html:52 +msgid "API Login:" +msgstr "" + +#: templates/index.html:55 +msgid "API Password:" +msgstr "" + +#: templates/index.html:58 +msgid "Password is saved. Enter a new one only if you need to change it." +msgstr "" + +#: templates/index.html:60 +msgid "Enter the API password for your RMS server." +msgstr "" + +#: templates/index.html:63 +msgid "Check and Save RMS-config" +msgstr "" + +#: templates/index.html:66 templates/index.html:68 templates/index.html:116 +#: templates/index.html:118 +msgid "Status:" +msgstr "" + +#: templates/index.html:66 +#, python-format +msgid "Successfully connected to RMS. Found %(num)s OLAP presets." +msgstr "" + +#: templates/index.html:68 +msgid "RMS configuration saved. Presets not yet loaded or connection failed." +msgstr "" + +#: templates/index.html:73 +msgid "Configure RMS first" +msgstr "" + +#: templates/index.html:74 templates/index.html:77 +msgid "Google Sheets Configuration" +msgstr "" + +#: templates/index.html:79 +msgid "" +"To allow the application to write to your Google Sheet, you need to " +"provide\n" +" credentials for a Google Service Account. This account will act" +" on behalf\n" +" of the application." +msgstr "" + +#: templates/index.html:84 +msgid "How to get credentials:" +msgstr "" + +#: templates/index.html:85 +msgid "Go to Google Cloud Console." +msgstr "" + +#: templates/index.html:86 +msgid "Create a new project or select an existing one." +msgstr "" + +#: templates/index.html:87 +msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project." +msgstr "" + +#: templates/index.html:88 +msgid "" +"Go to \"Credentials\", click \"Create Credentials\", choose \"Service " +"Account\"." +msgstr "" + +#: templates/index.html:89 +msgid "Give it a name and grant it the \"Editor\" role." +msgstr "" + +#: templates/index.html:90 +msgid "Create a JSON key for the service account and download the file." +msgstr "" + +#: templates/index.html:91 +msgid "" +"Share your target Google Sheet with the service account's email address " +"(found in the downloaded JSON file, key `client_email`)." +msgstr "" + +#: templates/index.html:94 +msgid "Service Account Credentials (JSON file):" +msgstr "" + +#: templates/index.html:97 +msgid "Current Service Account Email:" +msgstr "" + +#: templates/index.html:98 +msgid "Upload a new file only if you need to change credentials." +msgstr "" + +#: templates/index.html:100 +msgid "Upload the JSON file downloaded from Google Cloud Console." +msgstr "" + +#: templates/index.html:102 +msgid "Upload Credentials" +msgstr "" + +#: templates/index.html:106 +msgid "" +"Enter the URL of the Google Sheet you want to use. The service account " +"email\n" +" (shown above after uploading credentials) must have edit " +"access to this sheet." +msgstr "" + +#: templates/index.html:110 +msgid "Google Sheet URL:" +msgstr "" + +#: templates/index.html:112 +msgid "Upload Service Account Credentials first" +msgstr "" + +#: templates/index.html:113 +msgid "Connect Google Sheets" +msgstr "" + +#: templates/index.html:116 +#, python-format +msgid "Successfully connected to Google Sheet. Found %(num)s worksheets." +msgstr "" + +#: templates/index.html:118 +msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed." +msgstr "" + +#: templates/index.html:124 +msgid "Configure RMS and Google Sheets first" +msgstr "" + +#: templates/index.html:125 +msgid "Mapping Sheets to OLAP Reports" +msgstr "" + +#: templates/index.html:128 +msgid "Map Worksheets to OLAP Reports" +msgstr "" + +#: templates/index.html:130 +msgid "" +"Select which OLAP report from RMS should be rendered into each specific " +"worksheet\n" +" (tab) in your Google Sheet." +msgstr "" + +#: templates/index.html:138 +msgid "Worksheet (Google Sheets)" +msgstr "" + +#: templates/index.html:139 +msgid "OLAP-report (RMS)" +msgstr "" + +#: templates/index.html:148 +msgid "Not set" +msgstr "" + +#: templates/index.html:160 +msgid "Save Mappings" +msgstr "" + +#: templates/index.html:163 +msgid "" +"Worksheets and OLAP presets are not loaded. Please configure RMS and " +"Google Sheets first." +msgstr "" + +#: templates/index.html:165 +msgid "Worksheets are not loaded. Check Google Sheets configuration." +msgstr "" + +#: templates/index.html:167 +msgid "OLAP presets are not loaded. Check RMS configuration." +msgstr "" + +#: templates/index.html:172 +msgid "Configure Mappings first" +msgstr "" + +#: templates/index.html:173 +msgid "Render Reports to Sheets" +msgstr "" + +#: templates/index.html:176 +msgid "Render Reports" +msgstr "" + +#: templates/index.html:178 +msgid "" +"Select the date range and click \"Render to sheet\" for each mapping you " +"wish to execute.\n" +" The application will retrieve the OLAP data from RMS for the " +"selected report and period,\n" +" clear the corresponding worksheet in Google Sheets, and write " +"the new data." +msgstr "" + +#: templates/index.html:184 +msgid "From Date:" +msgstr "" + +#: templates/index.html:187 +msgid "To Date:" +msgstr "" + +#: templates/index.html:193 +msgid "Worksheet" +msgstr "" + +#: templates/index.html:194 +msgid "Mapped OLAP Report" +msgstr "" + +#: templates/index.html:195 +msgid "Action" +msgstr "" + +#: templates/index.html:203 +msgid "ID: " +msgstr "" + +#: templates/index.html:206 +msgid "Unnamed Preset" +msgstr "" + +#: templates/index.html:214 +msgid "Render to sheet" +msgstr "" + +#: templates/index.html:224 +msgid "No mappings configured yet." +msgstr "" + +#: templates/index.html:225 +msgid "" +"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to " +"set up mappings." +msgstr "" + +#: templates/index.html:248 +msgid "Please," +msgstr "" + +#: templates/index.html:248 +msgid "login" +msgstr "" + +#: templates/index.html:248 +msgid "or" +msgstr "" + +#: templates/index.html:248 +msgid "register" +msgstr "" + +#: templates/login.html:22 templates/register.html:22 +msgid "Username:" +msgstr "" + +#: templates/login.html:24 templates/register.html:24 +msgid "Password:" +msgstr "" + +#: templates/login.html:27 +msgid "Remember Me" +msgstr "" + +#: templates/login.html:31 +msgid "Don't have an account?" +msgstr "" + +#: templates/login.html:31 +msgid "Register here" +msgstr "" + +#: templates/register.html:28 +msgid "Already have an account?" +msgstr "" + +#: templates/register.html:28 +msgid "Login here" +msgstr "" + diff --git a/translations/ru/LC_MESSAGES/messages.mo b/translations/ru/LC_MESSAGES/messages.mo new file mode 100644 index 0000000..965ede3 Binary files /dev/null and b/translations/ru/LC_MESSAGES/messages.mo differ diff --git a/translations/ru/LC_MESSAGES/messages.po b/translations/ru/LC_MESSAGES/messages.po new file mode 100644 index 0000000..e9bd4d0 --- /dev/null +++ b/translations/ru/LC_MESSAGES/messages.po @@ -0,0 +1,556 @@ +# Шаблон перевода для olaper. +# Copyright (C) 2025 SERTY +# Этот файл распространяется на условиях той же лицензии, что и проект PROJECT. +# FIRST AUTHOR , 2025. +# +msgid "" +msgstr "" +"Project-Id-Version: PROJECT VERSION\n" +"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n" +"POT-Creation-Date: 2025-07-26 03:16+0300\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=utf-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Generated-By: Babel 2.17.0\n" + +#: app.py:46 +msgid "Please log in to access this page." +msgstr "Пожалуйста, войдите для доступа к этой странице." + +#: app.py:114 +msgid "Invalid username or password" +msgstr "Неверное имя пользователя или пароль" + +#: app.py:117 +msgid "Login successful!" +msgstr "Вход выполнен успешно!" + +#: app.py:130 +msgid "Username and password are required." +msgstr "Имя пользователя и пароль обязательны." + +#: app.py:133 +msgid "Username already exists." +msgstr "Имя пользователя уже существует." + +#: app.py:142 +msgid "Registration successful! Please log in." +msgstr "Регистрация успешна! Пожалуйста, войдите." + +#: app.py:148 +msgid "An error occurred during registration. Please try again." +msgstr "Произошла ошибка при регистрации. Пожалуйста, попробуйте снова." + +#: app.py:157 +msgid "You have been logged out." +msgstr "Вы вышли из системы." + +#: app.py:189 +msgid "Password is required for the first time." +msgstr "Пароль обязателен при первом подключении." + +#: app.py:193 +msgid "Host and Login fields must be filled." +msgstr "Поля Хост и Логин должны быть заполнены." + +#: app.py:211 +#, python-format +msgid "Successfully authorized on RMS server. Received %(num)s presets." +msgstr "Успешная авторизация на сервере RMS. Получено %(num)s пресетов." + +#: app.py:214 +msgid "Authorization error on RMS server. Check host, login or password." +msgstr "Ошибка авторизации на сервере RMS. Проверьте хост, логин или пароль." + +#: app.py:219 +#, python-format +msgid "Error configuring RMS: %(error)s" +msgstr "Ошибка настройки RMS: %(error)s" + +#: app.py:229 +msgid "No file was selected." +msgstr "Файл не был выбран." + +#: app.py:248 +msgid "Could not find client_email in the credentials file." +msgstr "Не удалось найти client_email в файле учетных данных." + +#: app.py:258 +#, python-format +msgid "Credentials file successfully uploaded. Email: %(email)s" +msgstr "Файл учетных данных успешно загружен. Email: %(email)s" + +#: app.py:262 +msgid "Error: Uploaded file is not a valid JSON." +msgstr "Ошибка: Загруженный файл не является валидным JSON." + +#: app.py:267 +#, python-format +msgid "Error processing credentials: %(error)s" +msgstr "Ошибка обработки учетных данных: %(error)s" + +#: app.py:282 +msgid "Sheet URL must be provided." +msgstr "Необходимо указать URL таблицы." + +#: app.py:289 +msgid "Please upload a valid credentials file first." +msgstr "Пожалуйста, сначала загрузите валидный файл учетных данных." + +#: app.py:300 +#, python-format +msgid "" +"Successfully connected to Google Sheets. Found %(num)s sheets. Settings " +"saved." +msgstr "Успешное подключение к Google Таблицам. Найдено %(num)s листов. Настройки сохранены." + +#: app.py:307 +#, python-format +msgid "" +"Error connecting to Google Sheets: %(error)s. Check the URL and service " +"account permissions." +msgstr "Ошибка подключения к Google Таблицам: %(error)s. Проверьте URL и права сервисного аккаунта." + +#: app.py:333 +msgid "Mappings updated successfully." +msgstr "Привязки успешно обновлены." + +#: app.py:338 +#, python-format +msgid "Error updating mappings: %(error)s" +msgstr "Ошибка обновления привязок: %(error)s" + +#: app.py:354 +msgid "Error: Could not determine which sheet to render the report for." +msgstr "Ошибка: Не удалось определить, на какой лист выводить отчет." + +#: app.py:361 +#, python-format +msgid "Error: No report is assigned to sheet \"%(sheet)s\"." +msgstr "Ошибка: Нет отчета, привязанного к листу \"%(sheet)s\"." + +#: app.py:366 +msgid "Error: RMS or Google Sheets configuration is incomplete." +msgstr "Ошибка: Настройка RMS или Google Таблиц не завершена." + +#: app.py:371 +#, python-format +msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration." +msgstr "Ошибка: Пресет с ID \"%(id)s\" не найден в сохраненной конфигурации." + +#: app.py:387 +#, python-format +msgid "Error: Unexpected response format from RMS for report \"%(name)s\"." +msgstr "Ошибка: Неожиданный формат ответа от RMS для отчета \"%(name)s\"." + +#: app.py:400 +#, python-format +msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"." +msgstr "Данные отчета \"%(name)s\" успешно записаны на лист \"%(sheet)s\"." + +#: app.py:402 +#, python-format +msgid "" +"Report \"%(name)s\" returned no data for the selected period. Sheet " +"\"%(sheet)s\" has been cleared." +msgstr "Отчет \"%(name)s\" не вернул данных за выбранный период. Лист \"%(sheet)s\" был очищен." + +#: app.py:404 +msgid "Error authorizing on RMS server when trying to get a report." +msgstr "Ошибка авторизации на сервере RMS при попытке получить отчет." + +#: app.py:407 +#, python-format +msgid "Data Error: %(error)s" +msgstr "Ошибка данных: %(error)s" + +#: app.py:410 +#, python-format +msgid "" +"Google API Error accessing sheet \"%(sheet)s\". Check service account " +"permissions." +msgstr "Ошибка Google API при доступе к листу \"%(sheet)s\". Проверьте права сервисного аккаунта." + +#: app.py:413 +#, python-format +msgid "An unexpected error occurred: %(error)s" +msgstr "Произошла непредвиденная ошибка: %(error)s" + +#: templates/index.html:6 +msgid "MyHoreca OLAPer" +msgstr "MyHoreca OLAPer" + +#: templates/index.html:11 +msgid "MyHoreca OLAP-to-GoogleSheets" +msgstr "MyHoreca OLAP в Google Таблицы" + +#: templates/index.html:15 +msgid "Logged in as:" +msgstr "Вход выполнен как:" + +#: templates/index.html:16 +msgid "Logout" +msgstr "Выйти" + +#: templates/index.html:18 +msgid "Русский" +msgstr "Русский" + +#: templates/index.html:19 +msgid "English" +msgstr "English" + +#: templates/index.html:24 templates/login.html:4 templates/login.html:13 +#: templates/login.html:29 +msgid "Login" +msgstr "Вход" + +#: templates/index.html:25 templates/register.html:4 templates/register.html:13 +#: templates/register.html:26 +msgid "Register" +msgstr "Регистрация" + +#: templates/index.html:41 +msgid "Connection to RMS-server" +msgstr "Подключение к RMS-серверу" + +#: templates/index.html:43 +msgid "RMS Server Configuration" +msgstr "Настройка RMS сервера" + +#: templates/index.html:45 +msgid "" +"Enter the details for your RMS server API. This information is used to " +"connect,\n" +" authenticate, and retrieve the list of available OLAP report " +"presets." +msgstr "" +"Введите данные для API вашего RMS сервера. Эта информация используется для " +"подключения,\n" +" аутентификации и получения списка доступных пресетов OLAP отчетов." + +#: templates/index.html:49 +msgid "RMS-host (e.g., http://your-rms-api.com/resto):" +msgstr "RMS-хост (например, http://your-rms-api.com/resto):" + +#: templates/index.html:52 +msgid "API Login:" +msgstr "API Логин:" + +#: templates/index.html:55 +msgid "API Password:" +msgstr "API Пароль:" + +#: templates/index.html:58 +msgid "Password is saved. Enter a new one only if you need to change it." +msgstr "Пароль сохранен. Введите новый только если нужно его изменить." + +#: templates/index.html:60 +msgid "Enter the API password for your RMS server." +msgstr "Введите API пароль для вашего RMS сервера." + +#: templates/index.html:63 +msgid "Check and Save RMS-config" +msgstr "Проверить и сохранить RMS-конфиг" + +#: templates/index.html:66 templates/index.html:68 templates/index.html:116 +#: templates/index.html:118 +msgid "Status:" +msgstr "Статус:" + +#: templates/index.html:66 +#, python-format +msgid "Successfully connected to RMS. Found %(num)s OLAP presets." +msgstr "Успешное подключение к RMS. Найдено %(num)s OLAP пресетов." + +#: templates/index.html:68 +msgid "RMS configuration saved. Presets not yet loaded or connection failed." +msgstr "Конфигурация RMS сохранена. Пресеты еще не загружены или подключение не удалось." + +#: templates/index.html:73 +msgid "Configure RMS first" +msgstr "Сначала настройте RMS" + +#: templates/index.html:74 templates/index.html:77 +msgid "Google Sheets Configuration" +msgstr "Настройка Google Таблиц" + +#: templates/index.html:79 +msgid "" +"To allow the application to write to your Google Sheet, you need to " +"provide\n" +" credentials for a Google Service Account. This account will act" +" on behalf\n" +" of the application." +msgstr "" +"Чтобы разрешить приложению запись в вашу Google Таблицу, необходимо " +"предоставить\n" +" учетные данные Google Service Account. Этот аккаунт будет " +"действовать\n" +" от имени приложения." + +#: templates/index.html:84 +msgid "How to get credentials:" +msgstr "Как получить учетные данные:" + +#: templates/index.html:85 +msgid "Go to Google Cloud Console." +msgstr "Перейдите в Google Cloud Console." + +#: templates/index.html:86 +msgid "Create a new project or select an existing one." +msgstr "Создайте новый проект или выберите существующий." + +#: templates/index.html:87 +msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project." +msgstr "Включите \"Google Sheets API\" и \"Google Drive API\" для проекта." + +#: templates/index.html:88 +msgid "" +"Go to \"Credentials\", click \"Create Credentials\", choose \"Service " +"Account\"." +msgstr "" +"Перейдите в \"Credentials\", нажмите \"Create Credentials\", выберите " +"\"Service Account\"." + +#: templates/index.html:89 +msgid "Give it a name and grant it the \"Editor\" role." +msgstr "Дайте ему имя и назначьте роль \"Editor\"." + +#: templates/index.html:90 +msgid "Create a JSON key for the service account and download the file." +msgstr "Создайте JSON ключ для сервисного аккаунта и скачайте файл." + +#: templates/index.html:91 +msgid "" +"Share your target Google Sheet with the service account's email address " +"(found in the downloaded JSON file, key `client_email`)." +msgstr "" +"Откройте доступ к вашей Google Таблице для email сервисного аккаунта " +"(указан в скачанном JSON файле, ключ `client_email`)." + +#: templates/index.html:94 +msgid "Service Account Credentials (JSON file):" +msgstr "Учетные данные сервисного аккаунта (JSON файл):" + +#: templates/index.html:97 +msgid "Current Service Account Email:" +msgstr "Текущий Email сервисного аккаунта:" + +#: templates/index.html:98 +msgid "Upload a new file only if you need to change credentials." +msgstr "Загружайте новый файл только если нужно изменить учетные данные." + +#: templates/index.html:100 +msgid "Upload the JSON file downloaded from Google Cloud Console." +msgstr "Загрузите JSON файл, скачанный из Google Cloud Console." + +#: templates/index.html:102 +msgid "Upload Credentials" +msgstr "Загрузить учетные данные" + +#: templates/index.html:106 +msgid "" +"Enter the URL of the Google Sheet you want to use. The service account " +"email\n" +" (shown above after uploading credentials) must have edit " +"access to this sheet." +msgstr "" +"Введите URL Google Таблицы, которую вы хотите использовать. Email " +"сервисного аккаунта\n" +" (показан выше после загрузки учетных данных) должен иметь " +"права на редактирование этой таблицы." + +#: templates/index.html:110 +msgid "Google Sheet URL:" +msgstr "URL Google Таблицы:" + +#: templates/index.html:112 +msgid "Upload Service Account Credentials first" +msgstr "Сначала загрузите учетные данные сервисного аккаунта" + +#: templates/index.html:113 +msgid "Connect Google Sheets" +msgstr "Подключить Google Таблицы" + +#: templates/index.html:116 +#, python-format +msgid "Successfully connected to Google Sheet. Found %(num)s worksheets." +msgstr "Успешное подключение к Google Таблице. Найдено %(num)s листов." + +#: templates/index.html:118 +msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed." +msgstr "URL Google Таблицы сохранен. Листы еще не загружены или подключение не удалось." + +#: templates/index.html:124 +msgid "Configure RMS and Google Sheets first" +msgstr "Сначала настройте RMS и Google Таблицы" + +#: templates/index.html:125 +msgid "Mapping Sheets to OLAP Reports" +msgstr "Привязка листов к OLAP отчетам" + +#: templates/index.html:128 +msgid "Map Worksheets to OLAP Reports" +msgstr "Сопоставить листы с OLAP отчетами" + +#: templates/index.html:130 +msgid "" +"Select which OLAP report from RMS should be rendered into each specific " +"worksheet\n" +" (tab) in your Google Sheet." +msgstr "" +"Выберите, какой OLAP отчет из RMS должен выводиться на каждый конкретный " +"лист\n" +" (вкладку) в вашей Google Таблице." + +#: templates/index.html:138 +msgid "Worksheet (Google Sheets)" +msgstr "Лист (Google Таблицы)" + +#: templates/index.html:139 +msgid "OLAP-report (RMS)" +msgstr "OLAP-отчет (RMS)" + +#: templates/index.html:148 +msgid "Not set" +msgstr "Не задано" + +#: templates/index.html:160 +msgid "Save Mappings" +msgstr "Сохранить привязки" + +#: templates/index.html:163 +msgid "" +"Worksheets and OLAP presets are not loaded. Please configure RMS and " +"Google Sheets first." +msgstr "" +"Листы и OLAP пресеты не загружены. Пожалуйста, сначала настройте RMS и " +"Google Таблицы." + +#: templates/index.html:165 +msgid "Worksheets are not loaded. Check Google Sheets configuration." +msgstr "Листы не загружены. Проверьте настройку Google Таблиц." + +#: templates/index.html:167 +msgid "OLAP presets are not loaded. Check RMS configuration." +msgstr "OLAP пресеты не загружены. Проверьте настройку RMS." + +#: templates/index.html:172 +msgid "Configure Mappings first" +msgstr "Сначала настройте привязки" + +#: templates/index.html:173 +msgid "Render Reports to Sheets" +msgstr "Вывод отчетов в листы" + +#: templates/index.html:176 +msgid "Render Reports" +msgstr "Сформировать отчеты" + +#: templates/index.html:178 +msgid "" +"Select the date range and click \"Render to sheet\" for each mapping you " +"wish to execute.\n" +" The application will retrieve the OLAP data from RMS for the " +"selected report and period,\n" +" clear the corresponding worksheet in Google Sheets, and write " +"the new data." +msgstr "" +"Выберите диапазон дат и нажмите \"Вывести на лист\" для каждой привязки, " +"которую хотите выполнить.\n" +" Приложение получит OLAP данные из RMS для выбранного отчета и " +"периода,\n" +" очистит соответствующий лист в Google Таблицах и запишет новые " +"данные." + +#: templates/index.html:184 +msgid "From Date:" +msgstr "С даты:" + +#: templates/index.html:187 +msgid "To Date:" +msgstr "По дату:" + +#: templates/index.html:193 +msgid "Worksheet" +msgstr "Лист" + +#: templates/index.html:194 +msgid "Mapped OLAP Report" +msgstr "Привязанный OLAP отчет" + +#: templates/index.html:195 +msgid "Action" +msgstr "Действие" + +#: templates/index.html:203 +msgid "ID: " +msgstr "ID: " + +#: templates/index.html:206 +msgid "Unnamed Preset" +msgstr "Безымянный пресет" + +#: templates/index.html:214 +msgid "Render to sheet" +msgstr "Вывести на лист" + +#: templates/index.html:224 +msgid "No mappings configured yet." +msgstr "Привязки еще не настроены." + +#: templates/index.html:225 +msgid "" +"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to " +"set up mappings." +msgstr "" +"Пожалуйста, перейдите в раздел \"Привязка листов к OLAP отчетам\" (Шаг " +"3) для настройки привязок." + +#: templates/index.html:248 +msgid "Please," +msgstr "Пожалуйста," + +#: templates/index.html:248 +msgid "login" +msgstr "войдите" + +#: templates/index.html:248 +msgid "or" +msgstr "или" + +#: templates/index.html:248 +msgid "register" +msgstr "зарегистрируйтесь" + +#: templates/login.html:22 templates/register.html:22 +msgid "Username:" +msgstr "Имя пользователя:" + +#: templates/login.html:24 templates/register.html:24 +msgid "Password:" +msgstr "Пароль:" + +#: templates/login.html:27 +msgid "Remember Me" +msgstr "Запомнить меня" + +#: templates/login.html:31 +msgid "Don't have an account?" +msgstr "Нет аккаунта?" + +#: templates/login.html:31 +msgid "Register here" +msgstr "Зарегистрируйтесь здесь" + +#: templates/register.html:28 +msgid "Already have an account?" +msgstr "Уже есть аккаунт?" + +#: templates/register.html:28 +msgid "Login here" +msgstr "Войдите здесь" \ No newline at end of file diff --git a/utils.py b/utils.py index f6dfa25..9cfdc6d 100644 --- a/utils.py +++ b/utils.py @@ -5,51 +5,44 @@ from datetime import datetime # Настройка логирования logger = logging.getLogger(__name__) -# Уровень логирования уже должен быть настроен в app.py или основном модуле -# logger.setLevel(logging.DEBUG) # Можно убрать, если настраивается глобально - -# Функция load_temps удалена, так как пресеты загружаются из API RMS - def generate_template_from_preset(preset): """ - Генерирует один шаблон запроса OLAP на основе пресета, - подставляя плейсхолдеры для дат в соответствующий фильтр. + Генерирует один шаблон запроса OLAP на основе пресета из RMS API. + Функция заменяет существующие фильтры по дате на универсальные плейсхолдеры + Jinja2 (`{{ from_date }}` и `{{ to_date }}`). Args: preset (dict): Словарь с пресетом OLAP-отчета из API RMS. Returns: dict: Словарь, представляющий шаблон запроса OLAP, готовый для рендеринга. - Возвращает None, если входной preset некорректен. Raises: - ValueError: Если preset не является словарем или не содержит необходимых ключей. + ValueError: Если пресет некорректен (не словарь или отсутствуют ключи). Exception: Другие непредвиденные ошибки при обработке. """ if not isinstance(preset, dict): logger.error("Ошибка генерации шаблона: входной 'preset' не является словарем.") - raise ValueError("Preset должен быть словарем.") - if not all(k in preset for k in ["reportType", "groupByRowFields", "aggregateFields", "filters"]): - logger.error(f"Ошибка генерации шаблона: пресет {preset.get('id', 'N/A')} не содержит всех необходимых ключей.") - raise ValueError("Пресет не содержит необходимых ключей (reportType, groupByRowFields, aggregateFields, filters).") + raise ValueError("Пресет должен быть словарем.") + + required_keys = ["reportType", "groupByRowFields", "aggregateFields", "filters"] + if not all(k in preset for k in required_keys): + logger.error(f"Ошибка генерации шаблона: пресет {preset.get('id', 'N/A')} не содержит необходимых ключей.") + raise ValueError("Пресет не содержит ключей: reportType, groupByRowFields, aggregateFields, filters.") try: - # Копируем основные поля из пресета - template = { - "reportType": preset["reportType"], - "groupByRowFields": preset.get("groupByRowFields", []), # Используем get для необязательных полей - "aggregateFields": preset.get("aggregateFields", []), - "filters": preset.get("filters", {}) # Работаем с копией фильтров - } + # Создаем глубокую копию, чтобы не изменять оригинальный объект пресета + template = json.loads(json.dumps(preset)) - # --- Обработка фильтров дат --- - # Создаем копию словаря фильтров, чтобы безопасно удалять элементы - current_filters = dict(template.get("filters", {})) # Используем get с default + # Удаляем ненужные для запроса поля, которые приходят из API + template.pop('id', None) + template.pop('name', None) + + current_filters = template.get("filters", {}) filters_to_remove = [] - date_filter_found_and_modified = False - - # Сначала найдем и удалим все существующие фильтры типа DateRange + + # Находим и запоминаем все существующие фильтры типа DateRange для удаления for key, value in current_filters.items(): if isinstance(value, dict) and value.get("filterType") == "DateRange": filters_to_remove.append(key) @@ -58,28 +51,23 @@ def generate_template_from_preset(preset): del current_filters[key] logger.debug(f"Удален существующий DateRange фильтр '{key}' из пресета {preset.get('id', 'N/A')}.") - # Теперь добавляем правильный фильтр дат в зависимости от типа отчета + # Определяем правильный ключ для фильтра по дате на основе типа отчета report_type = template["reportType"] - + date_filter_key = None if report_type in ["SALES", "DELIVERIES"]: - # Для отчетов SALES и DELIVERIES используем "OpenDate.Typed" - # См. https://ru.iiko.help/articles/api-documentations/olap-2/a/h3__951638809 + # Для отчетов по продажам и доставкам используется "OpenDate.Typed" date_filter_key = "OpenDate.Typed" - logger.debug(f"Для отчета {report_type} ({preset.get('id', 'N/A')}) будет использован фильтр '{date_filter_key}'.") - current_filters[date_filter_key] = { - "filterType": "DateRange", - "from": "{{ from_date }}", - "to": "{{ to_date }}", - "includeLow": True, - "includeHigh": True - } - date_filter_found_and_modified = True # Считаем, что мы успешно добавили нужный фильтр - elif report_type == "TRANSACTIONS": - # Для отчетов по проводкам (TRANSACTIONS) используем "DateTime.DateTyped" - # См. комментарий пользователя и общие практики iiko API + # Для отчетов по проводкам используется "DateTime.DateTyped" date_filter_key = "DateTime.DateTyped" - logger.debug(f"Для отчета {report_type} ({preset.get('id', 'N/A')}) будет использован фильтр '{date_filter_key}'.") + else: + logger.warning( + f"Для типа отчета '{report_type}' (пресет {preset.get('id', 'N/A')}) нет стандартного ключа даты. " + f"Фильтр по дате не будет добавлен автоматически." + ) + + if date_filter_key: + logger.debug(f"Для отчета {report_type} будет использован фильтр '{date_filter_key}'.") current_filters[date_filter_key] = { "filterType": "DateRange", "from": "{{ from_date }}", @@ -87,73 +75,46 @@ def generate_template_from_preset(preset): "includeLow": True, "includeHigh": True } - date_filter_found_and_modified = True # Считаем, что мы успешно добавили нужный фильтр - - else: - # Для ВСЕХ ОСТАЛЬНЫХ типов отчетов: - # Пытаемся найти *любой* ключ, который может содержать дату (логика по умолчанию). - # Это менее надежно, чем явное указание ключей для SALES/DELIVERIES/TRANSACTIONS. - # Если в пресете для других типов отчетов нет стандартного поля даты, - # или оно называется иначе, этот блок может не сработать корректно. - # Мы уже удалили все DateRange фильтры. Если для этого типа отчета - # нужен был какой-то специфический DateRange фильтр, он был удален. - # Это потенциальная проблема, если неизвестные типы отчетов полагаются - # на предопределенные DateRange фильтры с другими ключами. - # Пока оставляем так: если тип отчета неизвестен, DateRange фильтр не добавляется. - logger.warning(f"Для неизвестного типа отчета '{report_type}' ({preset.get('id', 'N/A')}) не удалось автоматически определить стандартный ключ фильтра даты. " - f"Фильтр по дате не будет добавлен автоматически. Если он нужен, пресет должен содержать его с другим filterType или его нужно добавить вручную.") - # В этом случае date_filter_found_and_modified останется False - - # Обновляем фильтры в шаблоне + logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.") + template["filters"] = current_filters - - # Логируем результат - if date_filter_found_and_modified: - logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.") - else: - logger.warning(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') сгенерирован, но фильтр даты не был добавлен/модифицирован (тип отчета: {report_type}).") - return template except Exception as e: logger.error(f"Непредвиденная ошибка при генерации шаблона из пресета {preset.get('id', 'N/A')}: {str(e)}", exc_info=True) - raise # Перевыбрасываем ошибку - + raise def render_temp(template_dict, context): """ - Рендерит шаблон (представленный словарем) с использованием Jinja2. + Рендерит шаблон (представленный словарем) с использованием Jinja2, + подставляя значения из контекста (например, даты). Args: - template_dict (dict): Словарь, представляющий шаблон OLAP-запроса. + template_dict (dict): Словарь-шаблон OLAP-запроса. context (dict): Словарь с переменными для рендеринга (например, {'from_date': '...', 'to_date': '...'}). Returns: - dict: Словарь с отрендеренным OLAP-запросом. + dict: Словарь с отрендеренным OLAP-запросом, готовый к отправке. Raises: Exception: Ошибки при рендеринге или парсинге JSON. """ try: - # Преобразуем словарь шаблона в строку JSON для Jinja + # Преобразуем словарь шаблона в строку JSON template_str = json.dumps(template_dict) - - # Рендерим строку с помощью Jinja + # Рендерим строку с помощью Jinja, подставляя переменные из context rendered_str = Template(template_str).render(context) - # Преобразуем отрендеренную строку обратно в словарь Python rendered_dict = json.loads(rendered_str) - - logger.info('Шаблон OLAP-запроса успешно отрендерен.') + logger.info('Шаблон OLAP-запроса успешно отрендерен с датами.') return rendered_dict except Exception as e: logger.error(f"Ошибка рендеринга шаблона: {str(e)}", exc_info=True) raise - def get_dates(start_date, end_date): """ - Проверяет даты на корректность и формат YYYY-MM-DD. + Проверяет и форматирует даты. Args: start_date (str): Дата начала в формате 'YYYY-MM-DD'. @@ -169,8 +130,8 @@ def get_dates(start_date, end_date): try: start = datetime.strptime(start_date, date_format) end = datetime.strptime(end_date, date_format) - except ValueError: - logger.error(f"Некорректный формат дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.") + except (ValueError, TypeError): + logger.error(f"Некорректный формат или тип дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.") raise ValueError("Некорректный формат даты. Используйте YYYY-MM-DD.") if start > end: