commit 62115fcd366502c7a0a6492335812e2dc2df2b5c Author: SERTY Date: Fri Jul 25 03:04:51 2025 +0300 init commit diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..3b2a6c9 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.venv/ +venv/ +__pycache__/ +*.pyc +.git/ +.gitignore +Dockerfile +generate_keys.py \ No newline at end of file diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..96df5e2 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,33 @@ +name: Deploy to Production + +on: + push: + branches: + - prod + +jobs: + deploy: + runs-on: [docker:host] + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Build Docker image + run: | + docker build -t olaper:latest . + + - name: Stop old container (if running) + run: | + if [ "$(docker ps -q -f name=olaper)" ]; then + docker stop olaper && docker rm olaper + fi + + - name: Run new container + run: | + docker run -d \ + --name olaper \ + --restart always \ + -p 5005:5005 \ + -e SECRET_KEY=${{ secrets.SECRET_KEY }} \ + -e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \ + olaper:latest diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c545e2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +**/__pycache__ +/.venv +*/*.json +/.env +/.idea +cred.json +*.db \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..58cbbfa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,28 @@ +# Используем официальный образ Python +FROM python:3.9-slim-buster + +# Устанавливаем переменные окружения для нечувствительных настроек +ENV DATA_DIR=/opt/olaper/data +ENV DATABASE_URL="sqlite:///${DATA_DIR}/app.db" +# SECRET_KEY и ENCRYPTION_KEY ДОЛЖНЫ БЫТЬ ПРЕДОСТАВЛЕНЫ ВО ВРЕМЯ ЗАПУСКА! + +# Устанавливаем рабочую директорию в контейнере +WORKDIR /opt/olaper + +# Копируем файл с зависимостями и устанавливаем их +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копируем остальной код приложения +COPY . . + +RUN chmod +x /opt/olaper/start.sh + +# Убеждаемся, что директория для данных существует +RUN mkdir -p ${DATA_DIR} + +# Открываем порт, на котором будет работать Gunicorn +EXPOSE 5005 + +# Запускаем скрипт старта +CMD ["/opt/olaper/start.sh"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..4e724d0 --- /dev/null +++ b/README.md @@ -0,0 +1,142 @@ +# MyHoreca OLAP-to-GoogleSheets + +## Описание Проекта + +MyHoreca OLAP-to-GoogleSheets - это веб-приложение на базе Flask, предназначенное для автоматической выгрузки OLAP-отчетов из сервера RMS (iiko или Syrve) в Google Таблицы. Приложение позволяет пользователям настраивать подключение к RMS, указывать целевую Google Таблицу, сопоставлять листы Google Таблицы с определенными OLAP-отчетами RMS и запускать отрисовку данных за выбранный период. + +Приложение использует: +- Flask и Flask-Login для веб-интерфейса и управления пользователями. +- Flask-SQLAlchemy и Flask-Migrate для работы с базой данных SQLite (по умолчанию) и управления миграциями. +- `requests` для взаимодействия с RMS API. +- `gspread` для взаимодействия с Google Sheets API. +- `cryptography` для безопасного хранения паролей RMS в зашифрованном виде. +- Gunicorn как WSGI-сервер для продакшена. + +## Развертывание с Использованием Docker + +Наиболее рекомендуемый способ развертывания приложения - использование Docker. + +### Предварительные требования + +* Установленный Docker +* Доступ к RMS API с логином и паролем. +* Аккаунт Google Cloud и понимание, как получить учетные данные сервисного аккаунта для Google Sheets API и Google Drive API (см. [Youtube с таймкодом](https://youtu.be/RmEsC2T8dwE?t=509)). + +### Шаги по Настройке и Запуску + +1. **Клонирование Репозитория** + + Если проект находится в Git репозитории, клонируйте его: + ```bash + git clone + cd <папка проекта> + ``` + +2. **Настройка Миграций Базы Данных (Выполнить ОДИН РАЗ локально)** + + Прежде чем собирать Docker образ, необходимо инициализировать репозиторий миграций Alembic. Это нужно сделать **локально** в вашем окружении разработки. + Убедитесь, что у вас установлен Flask-Migrate (`pip install Flask-Migrate`) и активировано виртуальное окружение. + + ```bash + # Убедитесь, что вы находитесь в корневой директории проекта + # Активируйте ваше виртуальное окружение + # source .venv/bin/activate # Linux/macOS + # .venv\Scripts\activate # Windows CMD/PowerShell + + # Установите переменную окружения, чтобы Flask CLI мог найти ваше приложение + export FLASK_APP=app.py # Linux/macOS + # set FLASK_APP=app.py # Windows CMD + # $env:FLASK_APP="app.py" # Windows PowerShell + + # Инициализируйте репозиторий миграций (создаст папку 'migrations') + flask db init + + # Создайте первую миграцию на основе текущих моделей + flask db migrate -m "Initial database schema" + + # Деактивируйте виртуальное окружение, если нужно + # deactivate + ``` + После выполнения этих команд в корне вашего проекта появится папка `migrations`. Убедитесь, что она **не игнорируется** в `.dockerignore` и будет скопирована в Docker образ. + +3. **Генерация Секретных Ключей** + + Приложению требуются два секретных ключа: + * `SECRET_KEY`: Секретный ключ Flask для сессий и безопасности. + * `ENCRYPTION_KEY`: Ключ для шифрования паролей RMS в базе данных. **Этот ключ должен быть постоянным!** + + Вы можете сгенерировать надежные ключи с помощью простого Python скрипта: + ```python + # generate_keys.py + import os + import base64 + from cryptography.fernet import Fernet + + # Generate a strong SECRET_KEY for Flask (e.g., 24 random bytes in hex) + flask_secret_key = os.urandom(24).hex() + + # Generate a strong ENCRYPTION_KEY for Fernet (correct format) + fernet_encryption_key = Fernet.generate_key().decode() + + print("Generated SECRET_KEY (for Flask):") + print(flask_secret_key) + print("\nGenerated ENCRYPTION_KEY (for RMS password encryption):") + print(fernet_encryption_key) + print("\nIMPORTANT: Keep these keys secret and use them as environment variables!") + ``` + Запустите этот скрипт (`python generate_keys.py`), скопируйте сгенерированные ключи и сохраните их в безопасном месте. + +4. **Сборка Docker Образа** + + Убедитесь, что файлы `Dockerfile`, `requirements.txt`, `start.sh` и папка `migrations` находятся в корне проекта. + + ```bash + docker build -t mholaper . + ``` + +5. **Запуск Docker Контейнера** + + При запуске контейнера необходимо передать сгенерированные секретные ключи как переменные окружения (`-e`) и пробросить порт (`-p`). + + **Настоятельно рекомендуется использовать Docker Volume** для сохранения данных (база данных SQLite, загруженные учетные данные Google) между перезапусками или обновлениями контейнера. + + ```bash + docker run -d \ + -p 5005:5005 \ + -e SECRET_KEY="<СЮДА_ВАШ_SECRET_KEY>" \ + -e ENCRYPTION_KEY="<СЮДА_ВАШ_ENCRYPTION_KEY>" \ + -v mholaper_data:/app/data \ + mholaper + ``` + * `-d`: Запуск в фоновом режиме. + * `-p 5005:5005`: Проброс порта 5005 контейнера на порт 5005 хост-машины. + * `-e SECRET_KEY="..."`: Установка переменной окружения `SECRET_KEY`. + * `-e ENCRYPTION_KEY="..."`: Установка переменной окружения `ENCRYPTION_KEY`. **Ключ должен быть тем же, что использовался для шифрования ранее!** + * `-v mholaper_data:/app/data`: Монтирование Docker Volume с именем `mholaper_data` к папке `/app/data` внутри контейнера. Это обеспечит сохранность данных. + + **Внимание:** Если вы *не* используете `-v` для `/app/data`, все пользовательские данные будут храниться внутри файловой системы контейнера и будут потеряны при его удалении. + +### Доступ к Приложению + +После успешного запуска контейнера, приложение будет доступно по адресу `http://localhost:5005` (или IP-адресу вашей хост-машины, если вы разворачиваете на удаленном сервере). + +## Использование Сервиса (Веб-интерфейс) + +После запуска приложения и перехода по его адресу в браузере: + +1. **Регистрация / Вход:** Зарегистрируйте нового пользователя или войдите, если у вас уже есть учетная запись. +2. **1. Connection to RMS-server:** Настройте параметры подключения к вашему RMS API (хост, логин, пароль). При сохранении приложение попытается подключиться и загрузить список доступных OLAP-отчетов (пресетов). +3. **2. Google Sheets Configuration:** + * Загрузите JSON-файл учетных данных сервисного аккаунта Google. Приложение покажет email сервисного аккаунта. + * **Важно:** Предоставьте этому сервисному аккаунту права на редактирование вашей целевой Google Таблицы. + * Укажите URL Google Таблицы и нажмите "Connect Google Sheets". Приложение загрузит список листов (вкладок) из этой таблицы. +4. **3. Mapping Sheets to OLAP Reports:** Сопоставьте листы Google Таблицы с загруженными OLAP-отчетами из RMS. Выберите, какой отчет должен отрисовываться на каком листе. Сохраните сопоставления. +5. **4. Render Reports to Sheets:** Выберите период (даты From/To) и нажмите кнопку "Render to sheet" напротив каждого сопоставления, которое вы хотите выполнить. Приложение получит данные отчета из RMS за указанный период, очистит соответствующий лист в Google Таблице и запишет туда новые данные. + +## Разработка и Вклад + +Если вы хотите внести изменения в код: + +* Разрабатывайте локально в виртуальном окружении. +* При изменении моделей SQLAlchemy используйте Flask-Migrate (`flask db migrate`, `flask db upgrade`) для создания новых миграций. +* После внесения изменений и создания миграций, пересоберите Docker образ и запустите контейнер. Скрипт `start.sh` автоматически применит новые миграции при запуске. diff --git a/app.py b/app.py new file mode 100644 index 0000000..64098ee --- /dev/null +++ b/app.py @@ -0,0 +1,533 @@ +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 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 diff --git a/generate_keys.py b/generate_keys.py new file mode 100644 index 0000000..2c6768a --- /dev/null +++ b/generate_keys.py @@ -0,0 +1,22 @@ +import os +import base64 +from cryptography.fernet import Fernet + +# Generate a strong SECRET_KEY for Flask +# Using 24 random bytes, converted to hex (results in a 48-char string) +flask_secret_key = os.urandom(24).hex() + +# Generate a strong ENCRYPTION_KEY for Fernet +# This generates a key in the correct base64 format (32 bytes -> 44 base64 chars) +fernet_encryption_key = Fernet.generate_key().decode() + +print("Generated SECRET_KEY (for Flask):") +print(flask_secret_key) +print("-" * 30) +print("Generated ENCRYPTION_KEY (for RMS password encryption):") +print(fernet_encryption_key) +print("-" * 30) +print("IMPORTANT:") +print("1. Keep these keys SECRET and do NOT commit them to version control.") +print("2. Set these as environment variables SECRET_KEY and ENCRYPTION_KEY when running your application.") +print("3. The ENCRYPTION_KEY MUST remain CONSTANT for your application to be able to decrypt previously saved RMS passwords.") \ No newline at end of file diff --git a/google_sheets.py b/google_sheets.py new file mode 100644 index 0000000..ff5905a --- /dev/null +++ b/google_sheets.py @@ -0,0 +1,131 @@ +import gspread +import logging +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 мог обработать + except Exception as e: + logger.error(f"Error in {func.__name__}: {e}", exc_info=True) + raise + return wrapper + + +class GoogleSheets: + def __init__(self, cred_file, sheet_url): + """Инициализация клиента Google Sheets.""" + try: + self.client = gspread.service_account(filename=cred_file) + self.sheet_url = sheet_url + self.spreadsheet = self.client.open_by_url(sheet_url) + logger.info(f"Successfully connected to Google Sheet: {self.spreadsheet.title}") + except Exception as e: + logger.error(f"Failed to initialize GoogleSheets client or open sheet {sheet_url}: {e}", exc_info=True) + raise + + @log_exceptions + def get_sheet(self, sheet_name): + """Возвращает объект листа по имени.""" + try: + sheet = self.spreadsheet.worksheet(sheet_name) + logger.debug(f"Retrieved worksheet object for '{sheet_name}'") + return sheet + except gspread.exceptions.WorksheetNotFound: + logger.error(f"Worksheet '{sheet_name}' not found in spreadsheet '{self.spreadsheet.title}'.") + raise + except Exception: + raise + + @log_exceptions + def get_sheets(self): + """Получение списка листов в таблице.""" + sheets = self.spreadsheet.worksheets() + 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]}") + 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"): + """ + Очищает ВЕСЬ указанный лист и записывает новые данные (список списков), + начиная с ячейки start_cell. + """ + if not isinstance(data, list): + raise TypeError("Data must be a list of lists.") + + sheet = self.get_sheet(sheet_name) + + logger.info(f"Clearing entire sheet '{sheet_name}'...") + sheet.clear() # Очищаем весь лист + logger.info(f"Sheet '{sheet_name}' cleared.") + + if not data or not data[0]: # Проверяем, есть ли вообще данные для записи + logger.warning(f"No data provided to write to sheet '{sheet_name}' after clearing.") + return # Ничего не записываем, если данных нет + + num_rows = len(data) + num_cols = len(data[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 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/4f0e54d29f32_initial_migration.py b/migrations/versions/4f0e54d29f32_initial_migration.py new file mode 100644 index 0000000..2f4cdfd --- /dev/null +++ b/migrations/versions/4f0e54d29f32_initial_migration.py @@ -0,0 +1,51 @@ +"""Initial migration + +Revision ID: 4f0e54d29f32 +Revises: +Create Date: 2025-06-28 05:39:12.793761 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4f0e54d29f32' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('user', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('username', sa.String(length=80), nullable=False), + sa.Column('password_hash', sa.String(length=128), nullable=True), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('username') + ) + op.create_table('user_config', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('rms_host', sa.String(length=200), nullable=True), + sa.Column('rms_login', sa.String(length=100), nullable=True), + sa.Column('rms_password_encrypted', sa.LargeBinary(), nullable=True), + sa.Column('google_cred_file_path', sa.String(length=300), nullable=True), + sa.Column('google_sheet_url', sa.String(length=300), nullable=True), + sa.Column('google_client_email', sa.String(length=200), nullable=True), + sa.Column('mappings_json', sa.Text(), nullable=True), + sa.Column('presets_json', sa.Text(), nullable=True), + sa.Column('sheets_json', sa.Text(), nullable=True), + sa.ForeignKeyConstraint(['user_id'], ['user.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('user_id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('user_config') + op.drop_table('user') + # ### end Alembic commands ### diff --git a/models.py b/models.py new file mode 100644 index 0000000..e16651d --- /dev/null +++ b/models.py @@ -0,0 +1,127 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from werkzeug.security import generate_password_hash, check_password_hash +from cryptography.fernet import Fernet +import os +import json +import logging + +logger = logging.getLogger(__name__) + +db = SQLAlchemy() + +# 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 + +fernet = Fernet(ENCRYPTION_KEY) + +class User(db.Model, UserMixin): + 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)) + config = db.relationship('UserConfig', backref='user', uselist=False, cascade="all, delete-orphan") + + def set_password(self, password): + self.password_hash = generate_password_hash(password) + + def check_password(self, password): + return check_password_hash(self.password_hash, password) + + def __repr__(self): + return f'' + +class UserConfig(db.Model): + 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 + 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='{}') + presets_json = db.Column(db.Text, default='[]') + sheets_json = db.Column(db.Text, default='[]') + + # --- Helper properties for easy access --- + + @property + def rms_password(self): + if self.rms_password_encrypted: + try: + return fernet.decrypt(self.rms_password_encrypted).decode('utf-8') + except Exception: # Handle potential decryption errors + return None + return None + + @rms_password.setter + def rms_password(self, value): + 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) + + @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 + 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. + } + + def get_google_dict(self): + return { + 'cred_file': self.google_cred_file_path or '', # Maybe just indicate if file exists? + 'sheet_url': self.google_sheet_url or '' + } + + def __repr__(self): + return f'' \ No newline at end of file diff --git a/request_module.py b/request_module.py new file mode 100644 index 0000000..5f674c6 --- /dev/null +++ b/request_module.py @@ -0,0 +1,87 @@ +import requests +import logging +import hashlib + +# Настройка логирования +logger = logging.getLogger(__name__) +logger.setLevel(logging.DEBUG) + +class ReqModule: + def __init__(self, host, rmsLogin, password): + self.host = host + self.rmsLogin = rmsLogin + self.password = hashlib.sha1(password.encode('utf-8')).hexdigest() + self.token = None + self.session = requests.Session() + + def login(self): + logger.info(f"Вызов метода login с логином: {self.rmsLogin}") + try: + response = self.session.post( + f'{self.host}/api/auth', + 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') + except Exception as e: + logger.error(f'Error in get_token: {str(e)}') + raise + + def logout(self): + """Функция для освобождения токена авторизации.""" + try: + response = self.session.post( + f'{self.host}/api/logout', + data={'key': self.token}, + headers={'Content-Type': 'application/x-www-form-urlencoded'} + ) + if response.status_code == 200: + logger.info(f"{self.token} -- Токен освобожден") + self.token = None + return True + except Exception as e: + logger.error(f'Ошибка освобождения токена. {str(e)}') + raise + + def take_olap(self, params): + """Функция для отправки кастомного OLAP-запроса.""" + try: + cookies = {'key': self.token} + response = self.session.post( + f'{self.host}/api/v2/reports/olap', + 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') + except Exception as e: + logger.error(f'Error in send_olap_request: {str(e)}') + raise + + def take_presets(self): + """Функция генерации шаблонов OLAP-запросов""" + 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') + except Exception as e: + logger.error(f'Ошибка получения пресетов: {str(e)}') + raise \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..5935e5d Binary files /dev/null and b/requirements.txt differ diff --git a/start.sh b/start.sh new file mode 100644 index 0000000..0743523 --- /dev/null +++ b/start.sh @@ -0,0 +1,25 @@ +#!/bin/bash +# Этот скрипт запускается при старте контейнера. +# Он выполняет миграции базы данных перед запуском Gunicorn. + +echo "Running database migrations..." +# Убедимся, что мы находимся в рабочей директории приложения +cd /opt/olaper + +# Выполняем миграции. Команда 'flask db upgrade' создаст таблицы, если их нет, +# и применит любые ожидающие миграции. +# '--app app' указывает Flask CLI использовать экземпляр приложения 'app' из app.py +flask --app app db upgrade + +# Проверяем код выхода предыдущей команды +if [ $? -ne 0 ]; then + echo "Database migration failed!" + exit 1 +fi + +echo "Database migrations applied successfully." +echo "Starting Gunicorn server..." + +# Запускаем Gunicorn. Используем 'exec' чтобы сигналы (например, SIGTERM) +# передавались напрямую процессу Gunicorn. +exec gunicorn --bind 0.0.0.0:5005 app:app \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..78405e3 --- /dev/null +++ b/static/style.css @@ -0,0 +1,259 @@ +body { + font-family: Arial, sans-serif; + margin: 20px; + background-color: #f4f4f4; /* Light grey background */ + color: #333; +} + +h1, h2 { + color: #333; + text-align: center; + margin-bottom: 20px; +} + +.container { + display: flex; + flex-direction: column; + align-items: center; + max-width: 800px; /* Limit container width */ + margin: 0 auto; /* Center container */ + background-color: #fff; /* White background for container */ + padding: 20px; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Subtle shadow */ + border-radius: 8px; /* Rounded corners */ +} + +form { + margin-bottom: 20px; + padding: 15px; + border: 1px solid #eee; + border-radius: 5px; + background-color: #f9f9f9; +} + +label { + display: block; + margin-top: 10px; + font-weight: bold; +} + +input, select, button { + margin-top: 5px; + padding: 10px; + width: calc(100% - 22px); /* Adjust width for padding and border */ + max-width: 400px; + box-sizing: border-box; + border: 1px solid #ccc; + border-radius: 4px; +} + +button { + background-color: #007bff; /* Primary blue button */ + color: white; + border: none; + cursor: pointer; + transition: background-color 0.3s ease; + padding: 10px 15px; /* Add horizontal padding */ + width: auto; /* Auto width for buttons */ + display: inline-block; /* Allow buttons to be side-by-side if needed */ + margin-right: 10px; /* Space between buttons */ +} + +button:hover { + background-color: #0056b3; /* Darker blue on hover */ +} + +button:disabled { + background-color: #cccccc; + cursor: not-allowed; +} + +.flash-message { + padding: 12px; + margin-bottom: 20px; + border: 1px solid transparent; /* Use border-color for specific types */ + border-radius: 4px; + text-align: center; + font-weight: bold; +} + +.flash-success { border-color: #28a745; background-color: #d4edda; color: #155724; } +.flash-error { border-color: #dc3545; background-color: #f8d7da; color: #721c24; } +.flash-warning { border-color: #ffc107; background-color: #fff3cd; color: #856404; } + +.collapsible { + background-color: #e9ecef; /* Light grey button */ + color: #495057; /* Dark text */ + cursor: pointer; + padding: 12px; + width: 100%; + max-width: 500px; + border: none; + text-align: left; /* Align text left */ + outline: none; + font-size: 18px; /* Larger font */ + transition: background-color 0.3s ease; + margin-top: 15px; + border-radius: 5px; + font-weight: bold; +} + +.collapsible:hover { + background-color: #d3d9df; +} + +.collapsible.active { + background-color: #ced4da; +} + +.content { + padding: 15px; + display: none; + overflow: hidden; + background-color: #fefefe; /* Very light background */ + 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 */ +} + +.content h3 { /* Style for internal content headings */ + margin-top: 0; + color: #555; + border-bottom: 1px solid #eee; + padding-bottom: 5px; + margin-bottom: 10px; +} + +.content p { /* Style for instruction paragraphs */ + font-size: 14px; + color: #555; + line-height: 1.5; + margin-bottom: 15px; +} + +table { + width: 100%; + border-collapse: collapse; + margin-top: 15px; + box-shadow: 0 2px 5px rgba(0,0,0,0.05); +} + +th, td { + border: 1px solid #ddd; + padding: 10px; + text-align: left; +} + +th { + background-color: #f2f2f2; + font-weight: bold; +} + +tbody tr:nth-child(even) { /* Zebra striping */ + background-color: #f8f8f8; +} + +.user-info { + text-align: right; + margin-bottom: 15px; + padding-right: 20px; + font-size: 0.9em; + color: #555; +} + +.user-info a { + color: #007bff; + text-decoration: none; +} + +.user-info a:hover { + text-decoration: underline; +} + +hr { + margin: 20px 0; + border: 0; + border-top: 1px solid #ccc; +} + +small { + color: #666; +} + +/* Add this block to your style.css */ +.auth-container { + max-width: 400px; /* Максимальная ширина блока */ + margin: 50px auto; /* Центрирование по горизонтали и отступ сверху */ + padding: 20px; + background-color: #fff; /* Белый фон */ + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); /* Легкая тень */ + border-radius: 8px; /* Скругленные углы */ + text-align: center; /* Выравнивание содержимого по центру */ +} + +.auth-container h1 { + margin-top: 0; + margin-bottom: 20px; + color: #333; +} + +.auth-container form { + padding: 0; /* Убираем внутренние отступы формы, т.к. они есть у контейнера */ + border: none; /* Убираем рамку формы */ + background-color: transparent; /* Прозрачный фон формы */ +} + +.auth-container label { + text-align: left; /* Выравниваем метки по левому краю внутри центрированного блока */ + display: block; /* Метка занимает всю ширину */ + margin-bottom: 5px; /* Отступ снизу метки */ +} + +.auth-container input[type="text"], +.auth-container input[type="password"] { + width: 100%; /* Поля ввода занимают всю ширину контейнера */ + max-width: none; /* Отменяем ограничение ширины из общих правил input */ + margin-bottom: 15px; /* Отступ снизу поля ввода */ + box-sizing: border-box; /* Учитываем padding и border в ширине */ +} + +.auth-container button { + width: 100%; /* Кнопка занимает всю ширину */ + max-width: none; /* Отменяем ограничение ширины */ + margin-top: 10px; +} + +.auth-container p { + margin-top: 20px; + font-size: 0.9em; + color: #555; +} + +/* Дополнительно стилизуем флеш-сообщения внутри контейнера */ +.auth-container .flash-message { + margin-left: auto; + margin-right: auto; + max-width: 350px; /* Ограничиваем ширину флеш-сообщений */ + margin-bottom: 15px; /* Отступ снизу */ +} + +/* Корректировка для чекбокса "Remember Me" */ +.auth-container label[for="remember"] { + display: inline-block; /* Делаем метку строчно-блочным элементом */ + text-align: left; /* Выравнивание текста метки */ + margin-top: 5px; + margin-bottom: 15px; /* Отступ снизу */ + font-weight: normal; /* Убираем жирность метки чекбокса */ + width: auto; /* Ширина по содержимому */ +} + +.auth-container input[type="checkbox"] { + width: auto; /* Чекбокс не должен занимать всю ширину */ + margin-right: 5px; /* Отступ справа от чекбокса до текста */ + vertical-align: middle; /* Выравнивание по вертикали */ + box-shadow: none; /* Убираем тень, если есть */ +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..dc92904 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,290 @@ + + + + + + MyHoreca OLAPer + + + + +

MyHoreca OLAP-to-GoogleSheets

+ + {% if current_user.is_authenticated %} + + {% else %} + + {% endif %} + + + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} + + {% if current_user.is_authenticated %} +
+ + +
+

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. +

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

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

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

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

+ {% endif %} +
+ + + +
+

Google Sheets Configuration

+

+ 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. +

+

+ 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`). +

+
+ +
+ {% if client_email %} +

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.
+ {% 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. +

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

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

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

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

+ {% endif %} +
+
+ + + +
+

Map Worksheets to OLAP Reports

+

+ Select which OLAP report from RMS should be rendered into each specific worksheet + (tab) in your Google Sheet. +

+ {% if sheets and presets %} +
+ + + + + + + + + {% for sheet in sheets %} + + + + + {% endfor %} + +
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.

+ {% elif not sheets %} +

Worksheets are not loaded. Check Google Sheets configuration.

+ {% elif not presets %} +

OLAP presets are not loaded. Check RMS configuration.

+ {% endif %} +
+ + + +
+

Render Reports

+

+ 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. +

+ {% 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 #} + {% 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 #} + {% if matching_presets %} + {% set preset = matching_presets[0] %} + {% set preset_name = preset.get('name', 'Unnamed Preset') %} + {% endif %} + + + + + + + {% endif %} + {% endfor %} + +
WorksheetMapped OLAP ReportAction
{{ sheet.title }}{{ preset_name }} + +
+
+ {% else %} +

No mappings configured yet.

+

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

+ {% endif %} +
+ +
+ + + {% else %} +

Please, login or register

+ {% endif %} + + \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..fcfb7bc --- /dev/null +++ b/templates/login.html @@ -0,0 +1,31 @@ + + + + Login + + + +
{# <-- Add this wrapper div #} +

Login

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

Don't have an account? Register here

+
{# <-- Close the wrapper div #} + + \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..7b8f6d4 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,27 @@ + + + + Register + + + +
{# <-- Add this wrapper div #} +

Register

+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} + {% endwith %} +
+ +
+ +
+ +
+

Already have an account? Login here

+
{# <-- Close the wrapper div #} + + \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..f6dfa25 --- /dev/null +++ b/utils.py @@ -0,0 +1,180 @@ +import json +import logging +from jinja2 import Template +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 на основе пресета, + подставляя плейсхолдеры для дат в соответствующий фильтр. + + Args: + preset (dict): Словарь с пресетом OLAP-отчета из API RMS. + + Returns: + dict: Словарь, представляющий шаблон запроса OLAP, готовый для рендеринга. + Возвращает None, если входной preset некорректен. + + Raises: + ValueError: Если preset не является словарем или не содержит необходимых ключей. + 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).") + + try: + # Копируем основные поля из пресета + template = { + "reportType": preset["reportType"], + "groupByRowFields": preset.get("groupByRowFields", []), # Используем get для необязательных полей + "aggregateFields": preset.get("aggregateFields", []), + "filters": preset.get("filters", {}) # Работаем с копией фильтров + } + + # --- Обработка фильтров дат --- + # Создаем копию словаря фильтров, чтобы безопасно удалять элементы + current_filters = dict(template.get("filters", {})) # Используем get с default + filters_to_remove = [] + date_filter_found_and_modified = False + + # Сначала найдем и удалим все существующие фильтры типа DateRange + for key, value in current_filters.items(): + if isinstance(value, dict) and value.get("filterType") == "DateRange": + filters_to_remove.append(key) + + for key in filters_to_remove: + del current_filters[key] + logger.debug(f"Удален существующий DateRange фильтр '{key}' из пресета {preset.get('id', 'N/A')}.") + + # Теперь добавляем правильный фильтр дат в зависимости от типа отчета + report_type = template["reportType"] + + if report_type in ["SALES", "DELIVERIES"]: + # Для отчетов SALES и DELIVERIES используем "OpenDate.Typed" + # См. https://ru.iiko.help/articles/api-documentations/olap-2/a/h3__951638809 + 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 + date_filter_key = "DateTime.DateTyped" + 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 # Считаем, что мы успешно добавили нужный фильтр + + 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 + + # Обновляем фильтры в шаблоне + 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 # Перевыбрасываем ошибку + + +def render_temp(template_dict, context): + """ + Рендерит шаблон (представленный словарем) с использованием Jinja2. + + Args: + template_dict (dict): Словарь, представляющий шаблон OLAP-запроса. + context (dict): Словарь с переменными для рендеринга (например, {'from_date': '...', 'to_date': '...'}). + + Returns: + dict: Словарь с отрендеренным OLAP-запросом. + + Raises: + Exception: Ошибки при рендеринге или парсинге JSON. + """ + try: + # Преобразуем словарь шаблона в строку JSON для Jinja + template_str = json.dumps(template_dict) + + # Рендерим строку с помощью Jinja + rendered_str = Template(template_str).render(context) + + # Преобразуем отрендеренную строку обратно в словарь Python + rendered_dict = json.loads(rendered_str) + + 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'. + end_date (str): Дата окончания в формате 'YYYY-MM-DD'. + + Returns: + tuple: Кортеж (start_date, end_date), если даты корректны. + + Raises: + ValueError: Если формат дат некорректен или дата начала позже даты окончания. + """ + date_format = "%Y-%m-%d" + 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.") + raise ValueError("Некорректный формат даты. Используйте YYYY-MM-DD.") + + if start > end: + logger.error(f"Дата начала '{start_date}' не может быть позже даты окончания '{end_date}'.") + raise ValueError("Дата начала не может быть позже даты окончания.") + + return start_date, end_date \ No newline at end of file