init commit
Some checks failed
Deploy to Production / deploy (push) Has been cancelled

This commit is contained in:
2025-07-25 03:04:51 +03:00
commit 62115fcd36
22 changed files with 2169 additions and 0 deletions

8
.dockerignore Normal file
View File

@@ -0,0 +1,8 @@
.venv/
venv/
__pycache__/
*.pyc
.git/
.gitignore
Dockerfile
generate_keys.py

View File

@@ -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

7
.gitignore vendored Normal file
View File

@@ -0,0 +1,7 @@
**/__pycache__
/.venv
*/*.json
/.env
/.idea
cred.json
*.db

28
Dockerfile Normal file
View File

@@ -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"]

142
README.md Normal file
View File

@@ -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 <URL вашего репозитория>
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` автоматически применит новые миграции при запуске.

533
app.py Normal file
View File

@@ -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

22
generate_keys.py Normal file
View File

@@ -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.")

131
google_sheets.py Normal file
View File

@@ -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

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Single-database configuration for Flask.

50
migrations/alembic.ini Normal file
View File

@@ -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

113
migrations/env.py Normal file
View File

@@ -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()

24
migrations/script.py.mako Normal file
View File

@@ -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"}

View File

@@ -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 ###

127
models.py Normal file
View File

@@ -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'<User {self.username}>'
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'<UserConfig for User ID {self.user_id}>'

87
request_module.py Normal file
View File

@@ -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

BIN
requirements.txt Normal file

Binary file not shown.

25
start.sh Normal file
View File

@@ -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

259
static/style.css Normal file
View File

@@ -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; /* Убираем тень, если есть */
}

290
templates/index.html Normal file
View File

@@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyHoreca OLAPer</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<h1>MyHoreca OLAP-to-GoogleSheets</h1>
{% if current_user.is_authenticated %}
<div class="user-info">
Logged in as: <strong>{{ current_user.username }}</strong> |
<a href="{{ url_for('logout') }}">Logout</a>
</div>
{% else %}
<div class="user-info">
<a href="{{ url_for('login') }}">Login</a> |
<a href="{{ url_for('register') }}">Register</a>
</div>
{% endif %}
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
{% if current_user.is_authenticated %}
<div class="container">
<!-- Секция RMS-сервера -->
<button type="button" class="collapsible">1. Connection to RMS-server</button>
<div class="content">
<h3>RMS Server Configuration</h3>
<p>
Enter the details for your RMS server API. This information is used to connect,
authenticate, and retrieve the list of available OLAP report presets.
</p>
<form action="{{ url_for('configure_rms') }}" method="post">
<label for="host">RMS-host (e.g., http://your-rms-api.com/resto):</label>
<input type="text" id="host" name="host" value="{{ rms_config.get('host', '') }}" required /><br />
<label for="login">API Login:</label>
<input type="text" id="login" name="login" value="{{ rms_config.get('login', '') }}" required /><br />
<label for="password">API Password (enter if you want to change):</label>
<input type="password" id="password" name="password" value="" {% if not rms_config.get('password') %}required{% endif %} /><br />
{% if rms_config.get('password') %}
<small>Password is saved and will be used. Enter only if you need to change it.</small><br/>
{% else %}
<small>Enter the API password for your RMS server.</small><br/>
{% endif %}
<button type="submit">Check and Save RMS-config</button>
</form>
{% if presets %}
<p><strong>Status:</strong> Successfully connected to RMS. Found {{ presets|length }} OLAP presets.</p>
{% elif rms_config.get('host') %}
<p><strong>Status:</strong> RMS configuration saved. Presets not yet loaded or connection failed.</p>
{% endif %}
</div>
<!-- Секция Google-таблиц -->
<button type="button" class="collapsible" {% if not rms_config.get('host') %}disabled title="Configure RMS first"{% endif %}>
2. Google Sheets Configuration
</button>
<div class="content">
<h3>Google Sheets Configuration</h3>
<p>
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.
</p>
<p>
<strong>How to get credentials:</strong>
<br>1. Go to Google Cloud Console.
<br>2. Create a new project or select an existing one.
<br>3. Enable the "Google Sheets API" and "Google Drive API" for the project.
<br>4. Go to "Credentials", click "Create Credentials", choose "Service Account".
<br>5. Give it a name, grant it necessary permissions (e.g., Editor role for simplicity, or more granular roles for Sheets/Drive).
<br>6. Create a JSON key for the service account. Download this file.
<br>7. Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`).
</p>
<form action="{{ url_for('upload_credentials') }}" method="post" enctype="multipart/form-data">
<label for="cred_file">Service Account Credentials (JSON file):</label>
<input type="file" id="cred_file" name="cred_file" accept=".json" {% if not google_config.get('cred_file') %}required{% endif %} /><br />
{% if client_email %}
<p><strong>Current Service Account Email:</strong> <code>{{ client_email }}</code></p>
<small>Upload a new file only if you need to change credentials.</small><br/>
{% else %}
<small>Upload the JSON file downloaded from Google Cloud Console.</small><br/>
{% endif %}
<button type="submit">Upload Credentials</button>
</form>
<hr>
<p>
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.
</p>
<form action="{{ url_for('configure_google') }}" method="post">
<label for="sheet_url">Google Sheet URL:</label>
<input type="text" id="sheet_url" name="sheet_url" value="{{ google_config.get('sheet_url', '') }}" required placeholder="https://docs.google.com/spreadsheets/d/..."/>
<button type="submit" {% if not client_email %}disabled title="Upload Service Account Credentials first"{% endif %}>
Connect Google Sheets
</button>
{% if sheets %}
<p><strong>Status:</strong> Successfully connected to Google Sheet. Found {{ sheets|length }} worksheets.</p>
{% elif google_config.get('sheet_url') %}
<p><strong>Status:</strong> Google Sheet URL saved. Worksheets not yet loaded or connection failed.</p>
{% endif %}
</form>
</div>
<!-- Секция сопоставления листов-отчетов -->
<button type="button" class="collapsible" {% if not sheets or not presets %}disabled title="Configure RMS and Google Sheets first"{% endif %}>
3. Mapping Sheets to OLAP Reports
</button>
<div class="content">
<h3>Map Worksheets to OLAP Reports</h3>
<p>
Select which OLAP report from RMS should be rendered into each specific worksheet
(tab) in your Google Sheet.
</p>
{% if sheets and presets %}
<form action="{{ url_for('mapping_set') }}" method="post">
<table>
<thead>
<tr>
<th>Worksheet (Google Sheets)</th>
<th>OLAP-report (RMS)</th>
</tr>
</thead>
<tbody>
{% for sheet in sheets %}
<tr>
<td>{{ sheet.title }}</td>
<td>
<!-- Use sheet.id for unique name -->
<select name="sheet_{{ sheet.id }}">
<option value="">-- Not set --</option>
{% for preset in presets %}
<option value="{{ preset['id'] }}" {% if mappings.get(sheet.title) == preset['id'] %}selected{% endif %}>
{{ preset['name'] }} ({{ preset['id'] }})
</option>
{% endfor %}
</select>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<button type="submit">Save Mappings</button>
</form>
{% elif not sheets and not presets %}
<p>Worksheets and OLAP presets are not loaded. Please configure RMS and Google Sheets first.</p>
{% elif not sheets %}
<p>Worksheets are not loaded. Check Google Sheets configuration.</p>
{% elif not presets %}
<p>OLAP presets are not loaded. Check RMS configuration.</p>
{% endif %}
</div>
<!-- Секция отрисовки отчетов на листах -->
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="Configure Mappings first"{% endif %}>
4. Render Reports to Sheets
</button>
<div class="content">
<h3>Render Reports</h3>
<p>
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.
</p>
{% if mappings and mappings|length > 0 %}
<form action="{{ url_for('render_olap') }}" method="post">
<label for="start_date">From Date:</label>
<input type="date" id="start_date" name="start_date" required /><br />
<label for="end_date">To Date:</label>
<input type="date" id="end_date" name="end_date" required /><br />
<table>
<thead>
<tr>
<th>Worksheet</th>
<th>Mapped OLAP Report</th>
<th>Action</th>
</tr>
</thead>
<tbody>
{# 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 %}
<tr>
<td>{{ sheet.title }}</td>
<td>{{ preset_name }}</td>
<td>
<button type="submit" name="render_{{ sheet.title }}">
Render to sheet
</button>
</td>
</tr>
{% endif %}
{% endfor %}
</tbody>
</table>
</form>
{% else %}
<p>No mappings configured yet.</p>
<p><small>Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.</small></p>
{% endif %}
</div>
</div> <!-- End Container -->
<script>
// JavaScript для сворачивания/разворачивания секций
var coll = document.getElementsByClassName("collapsible");
for (var i = 0; i < coll.length; i++) {
coll[i].addEventListener("click", function () {
// Не переключать, если кнопка отключена
if (this.disabled) return;
this.classList.toggle("active");
var content = this.nextElementSibling;
if (content.style.display === "block") {
content.style.display = "none";
} else {
content.style.display = "block";
}
});
}
// 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
// });
</script>
{% else %}
<p style="text-align: center; margin-top: 50px;">Please, <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a></p>
{% endif %}
</body>
</html>

31
templates/login.html Normal file
View File

@@ -0,0 +1,31 @@
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
</head>
<body>
<div class="auth-container"> {# <-- Add this wrapper div #}
<h1>Login</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
{# Adjusted label and input for "Remember Me" #}
<label for="remember">
<input type="checkbox" name="remember" id="remember"> Remember Me
</label><br>
<button type="submit">Login</button>
</form>
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
</div> {# <-- Close the wrapper div #}
</body>
</html>

27
templates/register.html Normal file
View File

@@ -0,0 +1,27 @@
<!DOCTYPE html>
<html>
<head>
<title>Register</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
</head>
<body>
<div class="auth-container"> {# <-- Add this wrapper div #}
<h1>Register</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="flash-message flash-{{ category }}">{{ message }}</div>
{% endfor %}
{% endif %}
{% endwith %}
<form method="post">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<input type="password" id="password" name="password" required><br>
<button type="submit">Register</button>
</form>
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div> {# <-- Close the wrapper div #}
</body>
</html>

180
utils.py Normal file
View File

@@ -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