This commit is contained in:
8
.dockerignore
Normal file
8
.dockerignore
Normal file
@@ -0,0 +1,8 @@
|
||||
.venv/
|
||||
venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.git/
|
||||
.gitignore
|
||||
Dockerfile
|
||||
generate_keys.py
|
||||
33
.gitea/workflows/deploy.yml
Normal file
33
.gitea/workflows/deploy.yml
Normal 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
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
**/__pycache__
|
||||
/.venv
|
||||
*/*.json
|
||||
/.env
|
||||
/.idea
|
||||
cred.json
|
||||
*.db
|
||||
28
Dockerfile
Normal file
28
Dockerfile
Normal 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
142
README.md
Normal 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
533
app.py
Normal 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
22
generate_keys.py
Normal 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
131
google_sheets.py
Normal 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
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Single-database configuration for Flask.
|
||||
50
migrations/alembic.ini
Normal file
50
migrations/alembic.ini
Normal 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
113
migrations/env.py
Normal 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
24
migrations/script.py.mako
Normal 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"}
|
||||
51
migrations/versions/4f0e54d29f32_initial_migration.py
Normal file
51
migrations/versions/4f0e54d29f32_initial_migration.py
Normal 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
127
models.py
Normal 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
87
request_module.py
Normal 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
BIN
requirements.txt
Normal file
Binary file not shown.
25
start.sh
Normal file
25
start.sh
Normal 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
259
static/style.css
Normal 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
290
templates/index.html
Normal 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
31
templates/login.html
Normal 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
27
templates/register.html
Normal 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
180
utils.py
Normal 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
|
||||
Reference in New Issue
Block a user