Compare commits
19 Commits
36a8548562
...
prod
| Author | SHA1 | Date | |
|---|---|---|---|
| 013c9c5a15 | |||
| b3e1a5c88c | |||
| 8e757afe39 | |||
| 5100c5d17c | |||
| 81d33bebef | |||
| f7ed20de0e | |||
| 3500d433ea | |||
| c713b47d58 | |||
| ddd0ffbcb0 | |||
| 995b539a67 | |||
| 2515294c56 | |||
| 88d43124f7 | |||
| 56db68768b | |||
| b9e248c02e | |||
| a73c714d88 | |||
| 25e53aa84e | |||
| ae0290145a | |||
| d21b3b3214 | |||
| 008b2e57f2 |
@@ -7,19 +7,41 @@ on:
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: [docker:host]
|
||||
runs-on: [docker, host]
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Prepare workspace
|
||||
run: |
|
||||
rm -rf /tmp/olaper || true
|
||||
mkdir -p /tmp/olaper
|
||||
cd /tmp/olaper
|
||||
apk update && apk add openssh docker-cli
|
||||
mkdir -p ~/.ssh
|
||||
chmod 700 ~/.ssh
|
||||
ssh-keyscan -p 2222 10.25.100.250 >> ~/.ssh/known_hosts
|
||||
git clone --branch prod ssh://git@10.25.100.250:2222/serty/olaper.git .
|
||||
|
||||
- name: Build Docker image
|
||||
run: |
|
||||
cd /tmp/olaper
|
||||
docker build -t olaper:latest .
|
||||
|
||||
- name: Stop old container (if running)
|
||||
- name: Create volume (if not exists)
|
||||
run: |
|
||||
if [ "$(docker ps -q -f name=olaper)" ]; then
|
||||
docker stop olaper && docker rm olaper
|
||||
docker volume create olaper_data || true
|
||||
|
||||
- name: Stop and remove old containers
|
||||
run: |
|
||||
# Stop and remove container named olaper if exists
|
||||
docker stop olaper || true
|
||||
docker rm olaper || true
|
||||
|
||||
# Stop and remove any container using port 5005
|
||||
PORT=5005
|
||||
CONTAINER_IDS=$(docker ps -q --filter "publish=$PORT")
|
||||
if [ -n "$CONTAINER_IDS" ]; then
|
||||
echo "Stopping containers using port $PORT..."
|
||||
docker stop $CONTAINER_IDS
|
||||
docker rm $CONTAINER_IDS
|
||||
fi
|
||||
|
||||
- name: Run new container
|
||||
@@ -30,4 +52,8 @@ jobs:
|
||||
-p 5005:5005 \
|
||||
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||
-v olaper_data:/opt/olaper/data \
|
||||
olaper:latest
|
||||
|
||||
- name: Cleanup
|
||||
run: rm -rf /tmp/olaper || true
|
||||
@@ -39,6 +39,7 @@ jobs:
|
||||
docker run -d \
|
||||
--name olaper_test \
|
||||
-p 5050:5005 \
|
||||
-v /home/master/olaper-debug/data:/opt/olaper/data \
|
||||
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||
olaper:test
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.i18n
|
||||
@@ -1,11 +1,11 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_babel import Babel
|
||||
|
||||
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
||||
# Теперь любой модуль может безопасно импортировать их отсюда.
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_babel import Babel
|
||||
|
||||
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
||||
# Теперь любой модуль может безопасно импортировать их отсюда.
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
babel = Babel()
|
||||
98
prompt
98
prompt
@@ -1,49 +1,49 @@
|
||||
Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets"
|
||||
1. Обзор Проекта
|
||||
Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы.
|
||||
Стек технологий:
|
||||
Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate
|
||||
Работа с API: requests (для RMS), gspread (для Google Sheets)
|
||||
Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS)
|
||||
База данных: SQLite
|
||||
Frontend: Jinja2, стандартный HTML/CSS/JS.
|
||||
Текущий функционал:
|
||||
Приложение уже реализует полный цикл работы для одного пользователя:
|
||||
Регистрация и авторизация.
|
||||
Настройка подключения к RMS API (хост, логин, пароль).
|
||||
Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя.
|
||||
Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы).
|
||||
Получение и сохранение списка листов из Google Таблицы.
|
||||
Сопоставление (маппинг) отчетов RMS с листами Google Таблицы.
|
||||
Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные.
|
||||
Предоставленные файлы:
|
||||
app.py (основная логика Flask)
|
||||
models.py (модели SQLAlchemy)
|
||||
google_sheets.py (модуль для работы с Google Sheets API)
|
||||
request_module.py (модуль для работы с RMS API)
|
||||
utils.py (вспомогательные функции)
|
||||
README.md (документация)
|
||||
HTML-шаблоны (index.html, login.html, register.html)
|
||||
2. Ключевые Задачи для Разработки
|
||||
Задача 1: Отладка, Рефакторинг и Русификация Комментариев
|
||||
Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя.
|
||||
Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py.
|
||||
Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал.
|
||||
Задача 2: Интернационализация (i18n) и Перевод Интерфейса
|
||||
Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности.
|
||||
Механизм выбора языка:
|
||||
На странице логина (login.html) добавить возможность выбора языка (Русский/Английский).
|
||||
Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД).
|
||||
В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧).
|
||||
Перевод интерфейса:
|
||||
Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода.
|
||||
Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным.
|
||||
Задача 3: Улучшение Среды Разработки для Windows
|
||||
Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения.
|
||||
Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта.
|
||||
Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows.
|
||||
3. Правила Взаимодействия
|
||||
Язык общения: Всегда общайся на русском языке.
|
||||
Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости.
|
||||
Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня.
|
||||
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.
|
||||
Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets"
|
||||
1. Обзор Проекта
|
||||
Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы.
|
||||
Стек технологий:
|
||||
Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate
|
||||
Работа с API: requests (для RMS), gspread (для Google Sheets)
|
||||
Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS)
|
||||
База данных: SQLite
|
||||
Frontend: Jinja2, стандартный HTML/CSS/JS.
|
||||
Текущий функционал:
|
||||
Приложение уже реализует полный цикл работы для одного пользователя:
|
||||
Регистрация и авторизация.
|
||||
Настройка подключения к RMS API (хост, логин, пароль).
|
||||
Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя.
|
||||
Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы).
|
||||
Получение и сохранение списка листов из Google Таблицы.
|
||||
Сопоставление (маппинг) отчетов RMS с листами Google Таблицы.
|
||||
Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные.
|
||||
Предоставленные файлы:
|
||||
app.py (основная логика Flask)
|
||||
models.py (модели SQLAlchemy)
|
||||
google_sheets.py (модуль для работы с Google Sheets API)
|
||||
request_module.py (модуль для работы с RMS API)
|
||||
utils.py (вспомогательные функции)
|
||||
README.md (документация)
|
||||
HTML-шаблоны (index.html, login.html, register.html)
|
||||
2. Ключевые Задачи для Разработки
|
||||
Задача 1: Отладка, Рефакторинг и Русификация Комментариев
|
||||
Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя.
|
||||
Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py.
|
||||
Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал.
|
||||
Задача 2: Интернационализация (i18n) и Перевод Интерфейса
|
||||
Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности.
|
||||
Механизм выбора языка:
|
||||
На странице логина (login.html) добавить возможность выбора языка (Русский/Английский).
|
||||
Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД).
|
||||
В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧).
|
||||
Перевод интерфейса:
|
||||
Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода.
|
||||
Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным.
|
||||
Задача 3: Улучшение Среды Разработки для Windows
|
||||
Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения.
|
||||
Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта.
|
||||
Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows.
|
||||
3. Правила Взаимодействия
|
||||
Язык общения: Всегда общайся на русском языке.
|
||||
Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости.
|
||||
Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня.
|
||||
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.
|
||||
|
||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
862
routes.py
862
routes.py
@@ -1,432 +1,432 @@
|
||||
# routes.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app
|
||||
)
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from werkzeug.utils import secure_filename
|
||||
import gspread
|
||||
|
||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||
# Импортируем экземпляры расширений, созданные в app.py
|
||||
from extensions import db, login_manager
|
||||
# Импортируем наши классы и утилиты
|
||||
from models import User, UserConfig
|
||||
from google_sheets import GoogleSheets
|
||||
from request_module import ReqModule
|
||||
from utils import get_dates, generate_template_from_preset, render_temp
|
||||
|
||||
|
||||
# --- Создание блюпринта ---
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
# --- Регистрация обработчиков для расширений ---
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Загружает пользователя из БД для управления сессией."""
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
def load_user_specific_data():
|
||||
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
|
||||
g.user_config = None
|
||||
if current_user.is_authenticated:
|
||||
g.user_config = get_user_config()
|
||||
|
||||
|
||||
# --- Вспомогательные функции, специфичные для маршрутов ---
|
||||
|
||||
def get_user_config():
|
||||
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
||||
if not config:
|
||||
config = UserConfig(user_id=current_user.id)
|
||||
db.session.add(config)
|
||||
return config
|
||||
|
||||
def get_user_upload_path(filename=""):
|
||||
"""Возвращает путь для загрузки файлов для текущего пользователя."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
return os.path.join(user_dir, secure_filename(filename))
|
||||
|
||||
|
||||
# --- Маршруты ---
|
||||
|
||||
@main_bp.route('/language/<language>')
|
||||
def set_language(language=None):
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('.index'))
|
||||
|
||||
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not user.check_password(password):
|
||||
flash(_('Invalid username or password'), 'error')
|
||||
return redirect(url_for('.login'))
|
||||
login_user(user, remember=request.form.get('remember'))
|
||||
flash(_('Login successful!'), 'success')
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('.index'))
|
||||
return render_template('login.html')
|
||||
|
||||
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if not username or not password:
|
||||
flash(_('Username and password are required.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.config = UserConfig()
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(_('Registration successful! Please log in.'), 'success')
|
||||
return redirect(url_for('.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@main_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('You have been logged out.'), 'success')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
config = g.user_config
|
||||
return render_template(
|
||||
'index.html',
|
||||
rms_config=config.get_rms_dict(),
|
||||
google_config=config.get_google_dict(),
|
||||
presets=config.presets,
|
||||
sheets=config.sheets,
|
||||
mappings=config.mappings,
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
|
||||
@main_bp.route('/configure_rms', methods=['POST'])
|
||||
@login_required
|
||||
def configure_rms():
|
||||
config = g.user_config
|
||||
try:
|
||||
host = request.form.get('host', '').strip()
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not config.rms_password and not password:
|
||||
flash(_('Password is required for the first time.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not host or not login:
|
||||
flash(_('Host and Login fields must be filled.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
effective_password = password if password else config.rms_password
|
||||
|
||||
req_module = ReqModule(host, login, effective_password)
|
||||
if req_module.login():
|
||||
presets_data = req_module.take_presets()
|
||||
req_module.logout()
|
||||
|
||||
config.rms_host = host
|
||||
config.rms_login = login
|
||||
if password:
|
||||
config.rms_password = password
|
||||
config.presets = presets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
|
||||
else:
|
||||
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/upload_credentials', methods=['POST'])
|
||||
@login_required
|
||||
def upload_credentials():
|
||||
config = g.user_config
|
||||
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
|
||||
flash(_('No file was selected.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
cred_file = request.files['cred_file']
|
||||
filename = cred_file.filename
|
||||
# Получаем путь для сохранения файла в папке пользователя
|
||||
user_cred_path = get_user_upload_path(filename)
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
# Сначала сохраняем файл во временную директорию для проверки
|
||||
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
|
||||
cred_file.save(temp_path)
|
||||
|
||||
with open(temp_path, 'r', encoding='utf-8') as f:
|
||||
cred_data = json.load(f)
|
||||
client_email = cred_data.get('client_email')
|
||||
|
||||
if not client_email:
|
||||
flash(_('Could not find client_email in the credentials file.'), 'error')
|
||||
# Не забываем удалить временный файл при ошибке
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Если все хорошо, перемещаем файл из временной папки в постоянную
|
||||
shutil.move(temp_path, user_cred_path)
|
||||
|
||||
# Сохраняем путь к файлу и email в базу данных
|
||||
config.google_cred_file_path = user_cred_path
|
||||
config.google_client_email = client_email
|
||||
config.sheets = [] # Сбрасываем список листов при смене credentials
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
|
||||
finally:
|
||||
# Гарантированно удаляем временный файл, если он еще существует
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/configure_google', methods=['POST'])
|
||||
@login_required
|
||||
def configure_google():
|
||||
config = g.user_config
|
||||
sheet_url = request.form.get('sheet_url', '').strip()
|
||||
|
||||
if not sheet_url:
|
||||
flash(_('Sheet URL must be provided.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
config.google_sheet_url = sheet_url
|
||||
|
||||
cred_path = config.google_cred_file_path
|
||||
if not cred_path or not os.path.isfile(cred_path):
|
||||
flash(_('Please upload a valid credentials file first.'), 'warning')
|
||||
config.sheets = []
|
||||
db.session.commit()
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
try:
|
||||
gs_client = GoogleSheets(cred_path, sheet_url)
|
||||
sheets_data = gs_client.get_sheets()
|
||||
config.sheets = sheets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
config.sheets = []
|
||||
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/mapping_set', methods=['POST'])
|
||||
@login_required
|
||||
def mapping_set():
|
||||
config = g.user_config
|
||||
try:
|
||||
new_mappings = {}
|
||||
for sheet in config.sheets:
|
||||
report_key = f"sheet_{sheet['id']}"
|
||||
selected_report_id = request.form.get(report_key)
|
||||
if selected_report_id:
|
||||
new_mappings[sheet['title']] = selected_report_id
|
||||
|
||||
config.mappings = new_mappings
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Mappings updated successfully.'), 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/render_olap', methods=['POST'])
|
||||
@login_required
|
||||
def render_olap():
|
||||
config = g.user_config
|
||||
sheet_title = None
|
||||
req_module = None
|
||||
|
||||
try:
|
||||
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
|
||||
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||
if not sheet_title:
|
||||
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_id = config.mappings.get(sheet_title)
|
||||
if not report_id:
|
||||
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
||||
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||
if not preset:
|
||||
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
template = generate_template_from_preset(preset)
|
||||
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||
|
||||
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
|
||||
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
|
||||
|
||||
if req_module.login():
|
||||
result = req_module.take_olap(json_body)
|
||||
|
||||
# --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ ---
|
||||
|
||||
if 'data' not in result or not isinstance(result['data'], list):
|
||||
flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error')
|
||||
current_app.logger.error(f"Unexpected API response for report {report_id} ('{preset.get('name')}'). Response: {result}")
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_data = result['data']
|
||||
|
||||
# Если отчет пуст, очищаем лист и уведомляем пользователя.
|
||||
if not report_data:
|
||||
gs_client.clear_and_write_data(sheet_title, [])
|
||||
flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Здесь будет храниться наш итоговый "плоский" список словарей
|
||||
processed_data = []
|
||||
|
||||
# Проверяем структуру отчета: сводный (pivoted) или простой (flat)
|
||||
first_item = report_data[0]
|
||||
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
||||
|
||||
if is_pivoted:
|
||||
current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}")
|
||||
# "Разворачиваем" (unpivot) данные в плоский список словарей
|
||||
for row_item in report_data:
|
||||
row_values = row_item.get('row', {})
|
||||
cells = row_item.get('cells', [])
|
||||
if not cells:
|
||||
# Обрабатываем строки, у которых может не быть данных в ячейках
|
||||
processed_data.append(row_values.copy())
|
||||
else:
|
||||
for cell in cells:
|
||||
new_flat_row = row_values.copy()
|
||||
new_flat_row.update(cell.get('col', {}))
|
||||
new_flat_row.update(cell.get('values', {}))
|
||||
processed_data.append(new_flat_row)
|
||||
else:
|
||||
current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}")
|
||||
# Данные уже в виде плоского списка, просто присваиваем
|
||||
processed_data = [item for item in report_data if isinstance(item, dict)]
|
||||
|
||||
# --- Универсальное формирование заголовков и данных ---
|
||||
|
||||
# 1. Собираем все уникальные ключи из всех строк для гарантии целостности.
|
||||
all_keys = set()
|
||||
for row in processed_data:
|
||||
all_keys.update(row.keys())
|
||||
|
||||
# 2. Создаем упорядоченный список заголовков для лучшей читаемости.
|
||||
# Используем поля из пресета для определения логического порядка.
|
||||
row_group_fields = preset.get('groupByRowFields', [])
|
||||
col_group_fields = preset.get('groupByColFields', [])
|
||||
agg_fields = preset.get('aggregateFields', [])
|
||||
|
||||
ordered_headers = []
|
||||
# Сначала добавляем известные поля из пресета в логической последовательности.
|
||||
for field in row_group_fields + col_group_fields + agg_fields:
|
||||
if field in all_keys:
|
||||
ordered_headers.append(field)
|
||||
all_keys.remove(field)
|
||||
# Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту.
|
||||
ordered_headers.extend(sorted(list(all_keys)))
|
||||
|
||||
# 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам.
|
||||
data_to_insert = [ordered_headers]
|
||||
for row in processed_data:
|
||||
row_data = []
|
||||
for header in ordered_headers:
|
||||
value_str = str(row.get(header, ''))
|
||||
if value_str.startswith(('=', '+', '-', '@')):
|
||||
row_data.append("'" + value_str)
|
||||
else:
|
||||
row_data.append(value_str)
|
||||
# Преобразуем None в пустую строку, а все остальное в строковое представление.
|
||||
# Это предотвращает потенциальные ошибки типов со стороны Google Sheets API.
|
||||
data_to_insert.append(row_data)
|
||||
|
||||
|
||||
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||
|
||||
rows_count = len(data_to_insert) - 1
|
||||
flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".',
|
||||
name=preset.get('name', report_id),
|
||||
rows=rows_count,
|
||||
sheet=sheet_title), 'success')
|
||||
else:
|
||||
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
|
||||
|
||||
except ValueError as ve:
|
||||
flash(_('Data Error: %(error)s', error=str(ve)), 'error')
|
||||
except gspread.exceptions.APIError as api_err:
|
||||
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error')
|
||||
current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True)
|
||||
except Exception as e:
|
||||
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
||||
current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True)
|
||||
finally:
|
||||
if req_module and req_module.token:
|
||||
req_module.logout()
|
||||
|
||||
# routes.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app
|
||||
)
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from werkzeug.utils import secure_filename
|
||||
import gspread
|
||||
|
||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||
# Импортируем экземпляры расширений, созданные в app.py
|
||||
from extensions import db, login_manager
|
||||
# Импортируем наши классы и утилиты
|
||||
from models import User, UserConfig
|
||||
from google_sheets import GoogleSheets
|
||||
from request_module import ReqModule
|
||||
from utils import get_dates, generate_template_from_preset, render_temp
|
||||
|
||||
|
||||
# --- Создание блюпринта ---
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
# --- Регистрация обработчиков для расширений ---
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Загружает пользователя из БД для управления сессией."""
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
def load_user_specific_data():
|
||||
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
|
||||
g.user_config = None
|
||||
if current_user.is_authenticated:
|
||||
g.user_config = get_user_config()
|
||||
|
||||
|
||||
# --- Вспомогательные функции, специфичные для маршрутов ---
|
||||
|
||||
def get_user_config():
|
||||
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
||||
if not config:
|
||||
config = UserConfig(user_id=current_user.id)
|
||||
db.session.add(config)
|
||||
return config
|
||||
|
||||
def get_user_upload_path(filename=""):
|
||||
"""Возвращает путь для загрузки файлов для текущего пользователя."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
return os.path.join(user_dir, secure_filename(filename))
|
||||
|
||||
|
||||
# --- Маршруты ---
|
||||
|
||||
@main_bp.route('/language/<language>')
|
||||
def set_language(language=None):
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('.index'))
|
||||
|
||||
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not user.check_password(password):
|
||||
flash(_('Invalid username or password'), 'error')
|
||||
return redirect(url_for('.login'))
|
||||
login_user(user, remember=request.form.get('remember'))
|
||||
flash(_('Login successful!'), 'success')
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('.index'))
|
||||
return render_template('login.html')
|
||||
|
||||
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if not username or not password:
|
||||
flash(_('Username and password are required.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.config = UserConfig()
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(_('Registration successful! Please log in.'), 'success')
|
||||
return redirect(url_for('.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@main_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('You have been logged out.'), 'success')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
config = g.user_config
|
||||
return render_template(
|
||||
'index.html',
|
||||
rms_config=config.get_rms_dict(),
|
||||
google_config=config.get_google_dict(),
|
||||
presets=config.presets,
|
||||
sheets=config.sheets,
|
||||
mappings=config.mappings,
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
|
||||
@main_bp.route('/configure_rms', methods=['POST'])
|
||||
@login_required
|
||||
def configure_rms():
|
||||
config = g.user_config
|
||||
try:
|
||||
host = request.form.get('host', '').strip()
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not config.rms_password and not password:
|
||||
flash(_('Password is required for the first time.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not host or not login:
|
||||
flash(_('Host and Login fields must be filled.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
effective_password = password if password else config.rms_password
|
||||
|
||||
req_module = ReqModule(host, login, effective_password)
|
||||
if req_module.login():
|
||||
presets_data = req_module.take_presets()
|
||||
req_module.logout()
|
||||
|
||||
config.rms_host = host
|
||||
config.rms_login = login
|
||||
if password:
|
||||
config.rms_password = password
|
||||
config.presets = presets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
|
||||
else:
|
||||
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/upload_credentials', methods=['POST'])
|
||||
@login_required
|
||||
def upload_credentials():
|
||||
config = g.user_config
|
||||
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
|
||||
flash(_('No file was selected.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
cred_file = request.files['cred_file']
|
||||
filename = cred_file.filename
|
||||
# Получаем путь для сохранения файла в папке пользователя
|
||||
user_cred_path = get_user_upload_path(filename)
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
# Сначала сохраняем файл во временную директорию для проверки
|
||||
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
|
||||
cred_file.save(temp_path)
|
||||
|
||||
with open(temp_path, 'r', encoding='utf-8') as f:
|
||||
cred_data = json.load(f)
|
||||
client_email = cred_data.get('client_email')
|
||||
|
||||
if not client_email:
|
||||
flash(_('Could not find client_email in the credentials file.'), 'error')
|
||||
# Не забываем удалить временный файл при ошибке
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Если все хорошо, перемещаем файл из временной папки в постоянную
|
||||
shutil.move(temp_path, user_cred_path)
|
||||
|
||||
# Сохраняем путь к файлу и email в базу данных
|
||||
config.google_cred_file_path = user_cred_path
|
||||
config.google_client_email = client_email
|
||||
config.sheets = [] # Сбрасываем список листов при смене credentials
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
|
||||
finally:
|
||||
# Гарантированно удаляем временный файл, если он еще существует
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/configure_google', methods=['POST'])
|
||||
@login_required
|
||||
def configure_google():
|
||||
config = g.user_config
|
||||
sheet_url = request.form.get('sheet_url', '').strip()
|
||||
|
||||
if not sheet_url:
|
||||
flash(_('Sheet URL must be provided.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
config.google_sheet_url = sheet_url
|
||||
|
||||
cred_path = config.google_cred_file_path
|
||||
if not cred_path or not os.path.isfile(cred_path):
|
||||
flash(_('Please upload a valid credentials file first.'), 'warning')
|
||||
config.sheets = []
|
||||
db.session.commit()
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
try:
|
||||
gs_client = GoogleSheets(cred_path, sheet_url)
|
||||
sheets_data = gs_client.get_sheets()
|
||||
config.sheets = sheets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
config.sheets = []
|
||||
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/mapping_set', methods=['POST'])
|
||||
@login_required
|
||||
def mapping_set():
|
||||
config = g.user_config
|
||||
try:
|
||||
new_mappings = {}
|
||||
for sheet in config.sheets:
|
||||
report_key = f"sheet_{sheet['id']}"
|
||||
selected_report_id = request.form.get(report_key)
|
||||
if selected_report_id:
|
||||
new_mappings[sheet['title']] = selected_report_id
|
||||
|
||||
config.mappings = new_mappings
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Mappings updated successfully.'), 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/render_olap', methods=['POST'])
|
||||
@login_required
|
||||
def render_olap():
|
||||
config = g.user_config
|
||||
sheet_title = None
|
||||
req_module = None
|
||||
|
||||
try:
|
||||
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
|
||||
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||
if not sheet_title:
|
||||
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_id = config.mappings.get(sheet_title)
|
||||
if not report_id:
|
||||
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
||||
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||
if not preset:
|
||||
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
template = generate_template_from_preset(preset)
|
||||
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||
|
||||
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
|
||||
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
|
||||
|
||||
if req_module.login():
|
||||
result = req_module.take_olap(json_body)
|
||||
|
||||
# --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ ---
|
||||
|
||||
if 'data' not in result or not isinstance(result['data'], list):
|
||||
flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error')
|
||||
current_app.logger.error(f"Unexpected API response for report {report_id} ('{preset.get('name')}'). Response: {result}")
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_data = result['data']
|
||||
|
||||
# Если отчет пуст, очищаем лист и уведомляем пользователя.
|
||||
if not report_data:
|
||||
gs_client.clear_and_write_data(sheet_title, [])
|
||||
flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Здесь будет храниться наш итоговый "плоский" список словарей
|
||||
processed_data = []
|
||||
|
||||
# Проверяем структуру отчета: сводный (pivoted) или простой (flat)
|
||||
first_item = report_data[0]
|
||||
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
||||
|
||||
if is_pivoted:
|
||||
current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}")
|
||||
# "Разворачиваем" (unpivot) данные в плоский список словарей
|
||||
for row_item in report_data:
|
||||
row_values = row_item.get('row', {})
|
||||
cells = row_item.get('cells', [])
|
||||
if not cells:
|
||||
# Обрабатываем строки, у которых может не быть данных в ячейках
|
||||
processed_data.append(row_values.copy())
|
||||
else:
|
||||
for cell in cells:
|
||||
new_flat_row = row_values.copy()
|
||||
new_flat_row.update(cell.get('col', {}))
|
||||
new_flat_row.update(cell.get('values', {}))
|
||||
processed_data.append(new_flat_row)
|
||||
else:
|
||||
current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}")
|
||||
# Данные уже в виде плоского списка, просто присваиваем
|
||||
processed_data = [item for item in report_data if isinstance(item, dict)]
|
||||
|
||||
# --- Универсальное формирование заголовков и данных ---
|
||||
|
||||
# 1. Собираем все уникальные ключи из всех строк для гарантии целостности.
|
||||
all_keys = set()
|
||||
for row in processed_data:
|
||||
all_keys.update(row.keys())
|
||||
|
||||
# 2. Создаем упорядоченный список заголовков для лучшей читаемости.
|
||||
# Используем поля из пресета для определения логического порядка.
|
||||
row_group_fields = preset.get('groupByRowFields', [])
|
||||
col_group_fields = preset.get('groupByColFields', [])
|
||||
agg_fields = preset.get('aggregateFields', [])
|
||||
|
||||
ordered_headers = []
|
||||
# Сначала добавляем известные поля из пресета в логической последовательности.
|
||||
for field in row_group_fields + col_group_fields + agg_fields:
|
||||
if field in all_keys:
|
||||
ordered_headers.append(field)
|
||||
all_keys.remove(field)
|
||||
# Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту.
|
||||
ordered_headers.extend(sorted(list(all_keys)))
|
||||
|
||||
# 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам.
|
||||
data_to_insert = [ordered_headers]
|
||||
for row in processed_data:
|
||||
row_data = []
|
||||
for header in ordered_headers:
|
||||
value_str = str(row.get(header, ''))
|
||||
if value_str.startswith(('=', '+', '-', '@')):
|
||||
row_data.append("'" + value_str)
|
||||
else:
|
||||
row_data.append(value_str)
|
||||
# Преобразуем None в пустую строку, а все остальное в строковое представление.
|
||||
# Это предотвращает потенциальные ошибки типов со стороны Google Sheets API.
|
||||
data_to_insert.append(row_data)
|
||||
|
||||
|
||||
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||
|
||||
rows_count = len(data_to_insert) - 1
|
||||
flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".',
|
||||
name=preset.get('name', report_id),
|
||||
rows=rows_count,
|
||||
sheet=sheet_title), 'success')
|
||||
else:
|
||||
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
|
||||
|
||||
except ValueError as ve:
|
||||
flash(_('Data Error: %(error)s', error=str(ve)), 'error')
|
||||
except gspread.exceptions.APIError as api_err:
|
||||
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error')
|
||||
current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True)
|
||||
except Exception as e:
|
||||
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
||||
current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True)
|
||||
finally:
|
||||
if req_module and req_module.token:
|
||||
req_module.logout()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
Reference in New Issue
Block a user