Compare commits
6 Commits
5100c5d17c
...
test
| Author | SHA1 | Date | |
|---|---|---|---|
| 4f66edbb21 | |||
| 38f35d1915 | |||
| ca8e70781c | |||
| 4ebe15522f | |||
| 0f1c749b33 | |||
| 8e757afe39 |
@@ -39,7 +39,7 @@ jobs:
|
|||||||
docker run -d \
|
docker run -d \
|
||||||
--name olaper_test \
|
--name olaper_test \
|
||||||
-p 5050:5005 \
|
-p 5050:5005 \
|
||||||
-v /home/master/olaper_debug/data:/opt/olaper/data \
|
-v /home/master/olaper-debug/data:/opt/olaper/data \
|
||||||
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
|
||||||
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
|
||||||
olaper:test
|
olaper:test
|
||||||
|
|||||||
44
app.py
44
app.py
@@ -1,12 +1,13 @@
|
|||||||
import os
|
import os
|
||||||
from flask import Flask, session, request
|
from flask import Flask, session, request
|
||||||
|
from sqlalchemy import inspect
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
# 1. Загрузка переменных окружения - в самом верху
|
# 1. Загрузка переменных окружения - в самом верху
|
||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
# 2. Импорт расширений из центрального файла
|
# 2. Импорт расширений из центрального файла
|
||||||
from extensions import db, migrate, login_manager, babel
|
from extensions import scheduler, db, migrate, login_manager, babel
|
||||||
from models import init_encryption
|
from models import init_encryption
|
||||||
|
|
||||||
# 3. Фабрика приложений
|
# 3. Фабрика приложений
|
||||||
@@ -49,16 +50,45 @@ def create_app():
|
|||||||
migrate.init_app(app, db)
|
migrate.init_app(app, db)
|
||||||
login_manager.init_app(app)
|
login_manager.init_app(app)
|
||||||
babel.init_app(app, locale_selector=get_locale)
|
babel.init_app(app, locale_selector=get_locale)
|
||||||
|
scheduler.init_app(app)
|
||||||
|
|
||||||
init_encryption(app)
|
init_encryption(app)
|
||||||
|
|
||||||
# --- Регистрация блюпринтов ---
|
# --- Регистрация блюпринтов ---
|
||||||
from routes import main_bp
|
from routes import main_bp, execute_olap_export
|
||||||
app.register_blueprint(main_bp)
|
app.register_blueprint(main_bp)
|
||||||
|
|
||||||
login_manager.login_view = 'main.login'
|
login_manager.login_view = 'main.login'
|
||||||
login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице."
|
login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице."
|
||||||
login_manager.login_message_category = "info"
|
login_manager.login_message_category = "info"
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
if inspect(db.engine).has_table('user_config'):
|
||||||
|
from models import User, UserConfig
|
||||||
|
all_configs = UserConfig.query.all()
|
||||||
|
for config in all_configs:
|
||||||
|
user_id = config.user_id
|
||||||
|
mappings = config.mappings
|
||||||
|
for sheer_title, params in mappings.items():
|
||||||
|
if isinstance(params, dict):
|
||||||
|
cron_schedule = params.get('schedule_cron')
|
||||||
|
if cron_schedule:
|
||||||
|
job_id = f"user_{user_id}_sheet_{sheer_title}"
|
||||||
|
try:
|
||||||
|
scheduler.add_job(
|
||||||
|
id=job_id,
|
||||||
|
func=execute_olap_export,
|
||||||
|
trigger='cron',
|
||||||
|
args=[user_id, sheer_title],
|
||||||
|
**_parse_cron_string(cron_schedule)
|
||||||
|
)
|
||||||
|
app.logger.info(f"Job {job_id} loaded on startup.")
|
||||||
|
except Exception as e:
|
||||||
|
app.logger.error(f"Failed to load job {job_id}: {e}")
|
||||||
|
else:
|
||||||
|
app.logger.warning("Database tables not found. Skipping job loading on startup. Run 'flask init-db' to create the tables.")
|
||||||
|
scheduler.start()
|
||||||
|
|
||||||
# --- Регистрация команд CLI ---
|
# --- Регистрация команд CLI ---
|
||||||
from models import User, UserConfig
|
from models import User, UserConfig
|
||||||
@app.cli.command('init-db')
|
@app.cli.command('init-db')
|
||||||
@@ -73,6 +103,16 @@ def create_app():
|
|||||||
# --- Точка входа для запуска ---
|
# --- Точка входа для запуска ---
|
||||||
app = create_app()
|
app = create_app()
|
||||||
|
|
||||||
|
# --- Вспомогательная функция для парсинга cron ---
|
||||||
|
def _parse_cron_string(cron_str):
|
||||||
|
"""Парсит строку cron в словарь для APScheduler."""
|
||||||
|
parts = cron_str.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise ValueError("Invalid cron string format. Expected 5 parts.")
|
||||||
|
|
||||||
|
keys = ['minute', 'hour', 'day', 'month', 'day_of_week']
|
||||||
|
return {keys[i]: part for i, part in enumerate(parts)}
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
# Для прямого запуска через `python app.py` (удобно для отладки)
|
# Для прямого запуска через `python app.py` (удобно для отладки)
|
||||||
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005)))
|
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005)))
|
||||||
@@ -2,6 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
|
|||||||
from flask_migrate import Migrate
|
from flask_migrate import Migrate
|
||||||
from flask_login import LoginManager
|
from flask_login import LoginManager
|
||||||
from flask_babel import Babel
|
from flask_babel import Babel
|
||||||
|
from flask_apscheduler import APScheduler
|
||||||
|
|
||||||
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
# Создаем экземпляры расширений здесь, без привязки к приложению.
|
||||||
# Теперь любой модуль может безопасно импортировать их отсюда.
|
# Теперь любой модуль может безопасно импортировать их отсюда.
|
||||||
@@ -9,3 +10,4 @@ db = SQLAlchemy()
|
|||||||
migrate = Migrate()
|
migrate = Migrate()
|
||||||
login_manager = LoginManager()
|
login_manager = LoginManager()
|
||||||
babel = Babel()
|
babel = Babel()
|
||||||
|
scheduler = APScheduler()
|
||||||
BIN
requirements.txt
BIN
requirements.txt
Binary file not shown.
236
routes.py
236
routes.py
@@ -12,18 +12,17 @@ import gspread
|
|||||||
|
|
||||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||||
# Импортируем экземпляры расширений, созданные в app.py
|
# Импортируем экземпляры расширений, созданные в app.py
|
||||||
from extensions import db, login_manager
|
from extensions import db, login_manager, scheduler
|
||||||
# Импортируем наши классы и утилиты
|
# Импортируем наши классы и утилиты
|
||||||
from models import User, UserConfig
|
from models import User, UserConfig
|
||||||
from google_sheets import GoogleSheets
|
from google_sheets import GoogleSheets
|
||||||
from request_module import ReqModule
|
from request_module import ReqModule
|
||||||
from utils import get_dates, generate_template_from_preset, render_temp
|
from utils import calculate_period_dates, get_dates, generate_template_from_preset, render_temp
|
||||||
|
|
||||||
|
|
||||||
# --- Создание блюпринта ---
|
# --- Создание блюпринта ---
|
||||||
main_bp = Blueprint('main', __name__)
|
main_bp = Blueprint('main', __name__)
|
||||||
|
|
||||||
|
|
||||||
# --- Регистрация обработчиков для расширений ---
|
# --- Регистрация обработчиков для расширений ---
|
||||||
|
|
||||||
@login_manager.user_loader
|
@login_manager.user_loader
|
||||||
@@ -60,6 +59,13 @@ def get_user_upload_path(filename=""):
|
|||||||
os.makedirs(user_dir, exist_ok=True)
|
os.makedirs(user_dir, exist_ok=True)
|
||||||
return os.path.join(user_dir, secure_filename(filename))
|
return os.path.join(user_dir, secure_filename(filename))
|
||||||
|
|
||||||
|
def _parse_cron_string(cron_str):
|
||||||
|
"""Парсит строку cron в словарь для APScheduler. Локальная копия для удобства."""
|
||||||
|
parts = cron_str.split()
|
||||||
|
if len(parts) != 5:
|
||||||
|
raise ValueError("Invalid cron string format. Expected 5 parts.")
|
||||||
|
keys = ['minute', 'hour', 'day', 'month', 'day_of_week']
|
||||||
|
return {keys[i]: part for i, part in enumerate(parts)}
|
||||||
|
|
||||||
# --- Маршруты ---
|
# --- Маршруты ---
|
||||||
|
|
||||||
@@ -125,13 +131,24 @@ def logout():
|
|||||||
@login_required
|
@login_required
|
||||||
def index():
|
def index():
|
||||||
config = g.user_config
|
config = g.user_config
|
||||||
|
clean_mappings = {}
|
||||||
|
if config.mappings:
|
||||||
|
for key, value in config.mappings.items():
|
||||||
|
if isinstance(value, dict):
|
||||||
|
clean_mappings[key] = value
|
||||||
|
else:
|
||||||
|
clean_mappings[key] = {
|
||||||
|
'report_id': value,
|
||||||
|
'schedule_cron': None,
|
||||||
|
'schedule_period': None
|
||||||
|
}
|
||||||
return render_template(
|
return render_template(
|
||||||
'index.html',
|
'index.html',
|
||||||
rms_config=config.get_rms_dict(),
|
rms_config=config.get_rms_dict(),
|
||||||
google_config=config.get_google_dict(),
|
google_config=config.get_google_dict(),
|
||||||
presets=config.presets,
|
presets=config.presets,
|
||||||
sheets=config.sheets,
|
sheets=config.sheets,
|
||||||
mappings=config.mappings,
|
mappings=clean_mappings,
|
||||||
client_email=config.google_client_email
|
client_email=config.google_client_email
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -275,11 +292,29 @@ def mapping_set():
|
|||||||
config = g.user_config
|
config = g.user_config
|
||||||
try:
|
try:
|
||||||
new_mappings = {}
|
new_mappings = {}
|
||||||
|
# Сохраняем существующие настройки расписания при обновлении отчетов
|
||||||
|
current_mappings = config.mappings or {}
|
||||||
|
|
||||||
for sheet in config.sheets:
|
for sheet in config.sheets:
|
||||||
report_key = f"sheet_{sheet['id']}"
|
report_key = f"sheet_{sheet['id']}"
|
||||||
selected_report_id = request.form.get(report_key)
|
selected_report_id = request.form.get(report_key)
|
||||||
|
|
||||||
if selected_report_id:
|
if selected_report_id:
|
||||||
new_mappings[sheet['title']] = selected_report_id
|
# Получаем существующие данные расписания для этого листа
|
||||||
|
existing_schedule = current_mappings.get(sheet['title'], {})
|
||||||
|
schedule_cron = None
|
||||||
|
schedule_period = None
|
||||||
|
|
||||||
|
if isinstance(existing_mapping_value, dict):
|
||||||
|
schedule_cron = existing_mapping_value.get('schedule_cron')
|
||||||
|
schedule_period = existing_mapping_value.get('schedule_period')
|
||||||
|
|
||||||
|
# Сохраняем новые настройки расписания в новом словаре
|
||||||
|
new_mappings[sheet['title']] = {
|
||||||
|
'report_id': selected_report_id,
|
||||||
|
'schedule_cron': schedule_cron,
|
||||||
|
'schedule_period': schedule_period
|
||||||
|
}
|
||||||
|
|
||||||
config.mappings = new_mappings
|
config.mappings = new_mappings
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
@@ -291,33 +326,49 @@ def mapping_set():
|
|||||||
|
|
||||||
return redirect(url_for('.index'))
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
@main_bp.route('/render_olap', methods=['POST'])
|
|
||||||
@login_required
|
def execute_olap_export(user_id, sheet_title, start_date_str=None, end_date_str=None):
|
||||||
def render_olap():
|
"""
|
||||||
config = g.user_config
|
Основная логика выгрузки OLAP-отчета. Может вызываться как из эндпоинта, так и из планировщика.
|
||||||
sheet_title = None
|
Если start_date_str и end_date_str не переданы, вычисляет их на основе расписания.
|
||||||
|
"""
|
||||||
|
app = current_app._get_current_object()
|
||||||
|
with app.app_context():
|
||||||
|
user = db.session.get(User, user_id)
|
||||||
|
if not user:
|
||||||
|
app.logger.error(f"Task failed: User with ID {user_id} not found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
config = user.config
|
||||||
req_module = None
|
req_module = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
|
mappings = config.mappings
|
||||||
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
mapping_info = mappings.get(sheet_title)
|
||||||
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 mapping_info or not mapping_info.get('report_id'):
|
||||||
if not report_id:
|
raise ValueError(f"No report is assigned to sheet '{sheet_title}'.")
|
||||||
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
|
|
||||||
return redirect(url_for('.index'))
|
|
||||||
|
|
||||||
|
report_id = mapping_info['report_id']
|
||||||
|
|
||||||
|
# Если даты не переданы (вызов из планировщика), вычисляем их
|
||||||
|
if not start_date_str or not end_date_str:
|
||||||
|
period_key = mapping_info.get('schedule_period')
|
||||||
|
if not period_key:
|
||||||
|
raise ValueError(f"Scheduled task for sheet '{sheet_title}' is missing a period setting.")
|
||||||
|
from_date, to_date = calculate_period_dates(period_key)
|
||||||
|
app.logger.info(f"Executing scheduled job for user {user_id}, sheet '{sheet_title}', period '{period_key}' ({from_date} to {to_date})")
|
||||||
|
else:
|
||||||
|
from_date, to_date = get_dates(start_date_str, end_date_str)
|
||||||
|
app.logger.info(f"Executing manual job for user {user_id}, sheet '{sheet_title}' ({from_date} to {to_date})")
|
||||||
|
|
||||||
|
# Проверка полноты конфигурации
|
||||||
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
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')
|
raise ValueError('RMS or Google Sheets configuration is incomplete.')
|
||||||
return redirect(url_for('.index'))
|
|
||||||
|
|
||||||
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||||
if not preset:
|
if not preset:
|
||||||
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
|
raise ValueError(f'Preset with ID "{report_id}" not found in saved configuration.')
|
||||||
return redirect(url_for('.index'))
|
|
||||||
|
|
||||||
template = generate_template_from_preset(preset)
|
template = generate_template_from_preset(preset)
|
||||||
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||||
@@ -328,36 +379,26 @@ def render_olap():
|
|||||||
if req_module.login():
|
if req_module.login():
|
||||||
result = req_module.take_olap(json_body)
|
result = req_module.take_olap(json_body)
|
||||||
|
|
||||||
# --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ ---
|
# Код обработки данных (остается без изменений)
|
||||||
|
|
||||||
if 'data' not in result or not isinstance(result['data'], list):
|
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')
|
raise ValueError(f'Unexpected response format from RMS for report "{preset.get("name", report_id)}".')
|
||||||
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']
|
report_data = result['data']
|
||||||
|
|
||||||
# Если отчет пуст, очищаем лист и уведомляем пользователя.
|
|
||||||
if not report_data:
|
if not report_data:
|
||||||
gs_client.clear_and_write_data(sheet_title, [])
|
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')
|
app.logger.warning(f"Report '{preset.get('name', report_id)}' for user {user_id} returned no data. Sheet '{sheet_title}' cleared.")
|
||||||
return redirect(url_for('.index'))
|
return
|
||||||
|
|
||||||
# Здесь будет храниться наш итоговый "плоский" список словарей
|
|
||||||
processed_data = []
|
processed_data = []
|
||||||
|
|
||||||
# Проверяем структуру отчета: сводный (pivoted) или простой (flat)
|
|
||||||
first_item = report_data[0]
|
first_item = report_data[0]
|
||||||
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
||||||
|
|
||||||
if is_pivoted:
|
if is_pivoted:
|
||||||
current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}")
|
|
||||||
# "Разворачиваем" (unpivot) данные в плоский список словарей
|
|
||||||
for row_item in report_data:
|
for row_item in report_data:
|
||||||
row_values = row_item.get('row', {})
|
row_values = row_item.get('row', {})
|
||||||
cells = row_item.get('cells', [])
|
cells = row_item.get('cells', [])
|
||||||
if not cells:
|
if not cells:
|
||||||
# Обрабатываем строки, у которых может не быть данных в ячейках
|
|
||||||
processed_data.append(row_values.copy())
|
processed_data.append(row_values.copy())
|
||||||
else:
|
else:
|
||||||
for cell in cells:
|
for cell in cells:
|
||||||
@@ -366,67 +407,132 @@ def render_olap():
|
|||||||
new_flat_row.update(cell.get('values', {}))
|
new_flat_row.update(cell.get('values', {}))
|
||||||
processed_data.append(new_flat_row)
|
processed_data.append(new_flat_row)
|
||||||
else:
|
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)]
|
processed_data = [item for item in report_data if isinstance(item, dict)]
|
||||||
|
|
||||||
# --- Универсальное формирование заголовков и данных ---
|
|
||||||
|
|
||||||
# 1. Собираем все уникальные ключи из всех строк для гарантии целостности.
|
|
||||||
all_keys = set()
|
all_keys = set()
|
||||||
for row in processed_data:
|
for row in processed_data:
|
||||||
all_keys.update(row.keys())
|
all_keys.update(row.keys())
|
||||||
|
|
||||||
# 2. Создаем упорядоченный список заголовков для лучшей читаемости.
|
|
||||||
# Используем поля из пресета для определения логического порядка.
|
|
||||||
row_group_fields = preset.get('groupByRowFields', [])
|
row_group_fields = preset.get('groupByRowFields', [])
|
||||||
col_group_fields = preset.get('groupByColFields', [])
|
col_group_fields = preset.get('groupByColFields', [])
|
||||||
agg_fields = preset.get('aggregateFields', [])
|
agg_fields = preset.get('aggregateFields', [])
|
||||||
|
|
||||||
ordered_headers = []
|
ordered_headers = []
|
||||||
# Сначала добавляем известные поля из пресета в логической последовательности.
|
|
||||||
for field in row_group_fields + col_group_fields + agg_fields:
|
for field in row_group_fields + col_group_fields + agg_fields:
|
||||||
if field in all_keys:
|
if field in all_keys:
|
||||||
ordered_headers.append(field)
|
ordered_headers.append(field)
|
||||||
all_keys.remove(field)
|
all_keys.remove(field)
|
||||||
# Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту.
|
|
||||||
ordered_headers.extend(sorted(list(all_keys)))
|
ordered_headers.extend(sorted(list(all_keys)))
|
||||||
|
|
||||||
# 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам.
|
|
||||||
data_to_insert = [ordered_headers]
|
data_to_insert = [ordered_headers]
|
||||||
for row in processed_data:
|
for row in processed_data:
|
||||||
row_data = []
|
row_data = []
|
||||||
for header in ordered_headers:
|
for header in ordered_headers:
|
||||||
value_str = str(row.get(header, ''))
|
value = row.get(header, '')
|
||||||
|
value_str = str(value) if value is not None else ''
|
||||||
if value_str.startswith(('=', '+', '-', '@')):
|
if value_str.startswith(('=', '+', '-', '@')):
|
||||||
row_data.append("'" + value_str)
|
row_data.append("'" + value_str)
|
||||||
else:
|
else:
|
||||||
row_data.append(value_str)
|
row_data.append(value_str)
|
||||||
# Преобразуем None в пустую строку, а все остальное в строковое представление.
|
|
||||||
# Это предотвращает потенциальные ошибки типов со стороны Google Sheets API.
|
|
||||||
data_to_insert.append(row_data)
|
data_to_insert.append(row_data)
|
||||||
|
|
||||||
|
|
||||||
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||||
|
app.logger.info(f"Successfully wrote {len(data_to_insert) - 1} rows to sheet '{sheet_title}' for user {user_id}.")
|
||||||
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:
|
else:
|
||||||
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
|
raise Exception('Error authorizing on RMS server when trying to get a report.')
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
app.logger.error(f"Error in execute_olap_export for user {user_id}, sheet '{sheet_title}': {e}", exc_info=True)
|
||||||
current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True)
|
|
||||||
finally:
|
finally:
|
||||||
if req_module and req_module.token:
|
if req_module and req_module.token:
|
||||||
req_module.logout()
|
req_module.logout()
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/render_olap', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def render_olap():
|
||||||
|
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||||
|
start_date = request.form.get('start_date')
|
||||||
|
end_date = request.form.get('end_date')
|
||||||
|
|
||||||
|
if not sheet_title:
|
||||||
|
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
if not start_date or not end_date:
|
||||||
|
flash(_('Error: Start date and end date are required for manual rendering.'), 'error')
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Просто вызываем нашу новую универсальную функцию
|
||||||
|
execute_olap_export(current_user.id, sheet_title, start_date, end_date)
|
||||||
|
flash(_('Report generation task for sheet "%(sheet)s" has been started. The data will appear shortly.', sheet=sheet_title), 'success')
|
||||||
|
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 route: {e}", exc_info=True)
|
||||||
|
|
||||||
|
return redirect(url_for('.index'))
|
||||||
|
|
||||||
|
|
||||||
|
@main_bp.route('/save_schedule', methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
def save_schedule():
|
||||||
|
config = g.user_config
|
||||||
|
try:
|
||||||
|
updated_mappings = config.mappings or {}
|
||||||
|
|
||||||
|
for sheet_title, params in updated_mappings.items():
|
||||||
|
if not isinstance(params, dict):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cron_value = request.form.get(f"cron-{sheet_title}", "").strip()
|
||||||
|
period_value = request.form.get(f"period-{sheet_title}", "").strip()
|
||||||
|
|
||||||
|
# Обработка кастомного периода N дней
|
||||||
|
if period_value == 'last_N_days':
|
||||||
|
try:
|
||||||
|
custom_days = int(request.form.get(f"custom_days-{sheet_title}", 0))
|
||||||
|
if custom_days > 0:
|
||||||
|
period_value = f"last_{custom_days}_days"
|
||||||
|
else:
|
||||||
|
period_value = "" # Сбрасываем, если введено 0 или некорректное значение
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
period_value = ""
|
||||||
|
|
||||||
|
params['schedule_cron'] = cron_value if cron_value else None
|
||||||
|
params['schedule_period'] = period_value if period_value else None
|
||||||
|
|
||||||
|
job_id = f"user_{current_user.id}_sheet_{sheet_title}"
|
||||||
|
|
||||||
|
# Удаляем старую задачу, если она была
|
||||||
|
if scheduler.get_job(job_id):
|
||||||
|
scheduler.remove_job(job_id)
|
||||||
|
current_app.logger.info(f"Removed existing job: {job_id}")
|
||||||
|
|
||||||
|
# Добавляем новую задачу, если есть cron-расписание
|
||||||
|
if cron_value and period_value:
|
||||||
|
try:
|
||||||
|
cron_params = _parse_cron_string(cron_value)
|
||||||
|
scheduler.add_job(
|
||||||
|
id=job_id,
|
||||||
|
func=execute_olap_export,
|
||||||
|
trigger='cron',
|
||||||
|
args=[current_user.id, sheet_title],
|
||||||
|
replace_existing=True,
|
||||||
|
**cron_params
|
||||||
|
)
|
||||||
|
current_app.logger.info(f"Added/updated job: {job_id} with schedule '{cron_value}'")
|
||||||
|
except ValueError as ve:
|
||||||
|
flash(_('Invalid cron format for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=ve), 'error')
|
||||||
|
except Exception as e:
|
||||||
|
flash(_('Error scheduling job for sheet "%(sheet)s": %(error)s', sheet=sheet_title, error=e), 'error')
|
||||||
|
|
||||||
|
config.mappings = updated_mappings
|
||||||
|
db.session.commit()
|
||||||
|
flash(_('Schedule settings saved successfully.'), 'success')
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.session.rollback()
|
||||||
|
flash(_('An error occurred while saving the schedule: %(error)s', error=str(e)), 'error')
|
||||||
|
current_app.logger.error(f"Error in save_schedule: {e}", exc_info=True)
|
||||||
|
|
||||||
return redirect(url_for('.index'))
|
return redirect(url_for('.index'))
|
||||||
@@ -258,3 +258,31 @@ small {
|
|||||||
vertical-align: middle; /* Выравнивание по вертикали */
|
vertical-align: middle; /* Выравнивание по вертикали */
|
||||||
box-shadow: none; /* Убираем тень, если есть */
|
box-shadow: none; /* Убираем тень, если есть */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cron-constructor {
|
||||||
|
border: 1px solid #e0e0e0;
|
||||||
|
padding: 15px;
|
||||||
|
margin-top: 15px;
|
||||||
|
border-radius: 5px;
|
||||||
|
background-color: #fdfdfd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-constructor h4 {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.cron-row select {
|
||||||
|
flex-grow: 1;
|
||||||
|
min-width: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#cron-output {
|
||||||
|
background-color: #e9ecef;
|
||||||
|
font-family: monospace;
|
||||||
|
}
|
||||||
@@ -147,7 +147,7 @@
|
|||||||
<select name="sheet_{{ sheet.id }}">
|
<select name="sheet_{{ sheet.id }}">
|
||||||
<option value="">-- {{ _('Not set') }} --</option>
|
<option value="">-- {{ _('Not set') }} --</option>
|
||||||
{% for preset in presets %}
|
{% for preset in presets %}
|
||||||
<option value="{{ preset['id'] }}" {% if mappings.get(sheet.title) == preset['id'] %}selected{% endif %}>
|
<option value="{{ preset['id'] }}" {% if mappings.get(sheet.title, {}).get('report_id') == preset['id'] %}selected{% endif %}>
|
||||||
{{ preset['name'] }} ({{ preset['id'] }})
|
{{ preset['name'] }} ({{ preset['id'] }})
|
||||||
</option>
|
</option>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
@@ -197,8 +197,9 @@
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{% for sheet in sheets %}
|
{% for sheet in sheets %}
|
||||||
{% set report_id = mappings.get(sheet.title) %}
|
{% set mappings = mappings.get(sheet.title) %}
|
||||||
{% if report_id %}
|
{% if mapping_info and mapping_info.get('report_id') %}
|
||||||
|
{% set report_id = mapping_info.get('report_id') %}
|
||||||
{% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
|
{% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
|
||||||
{% set preset_name = _('ID: ') + report_id %}
|
{% set preset_name = _('ID: ') + report_id %}
|
||||||
{% if matching_presets %}
|
{% if matching_presets %}
|
||||||
@@ -226,6 +227,70 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="{{ _('Configure Mappings first') }}"{% endif %}>
|
||||||
|
5. {{ _('Scheduling Automatic Reports') }}
|
||||||
|
</button>
|
||||||
|
<div class="content">
|
||||||
|
<h3>{{ _('Schedule Settings') }}</h3>
|
||||||
|
<p>
|
||||||
|
{% trans %}Here you can set up a CRON schedule for automatic report generation.
|
||||||
|
The report will be generated for the specified period relative to the execution time.{% endtrans %}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div class="cron-constructor">
|
||||||
|
<h4>{{ _('Cron Schedule Builder') }}</h4>
|
||||||
|
<p><small>{% trans %}Use this tool to build a cron string, then copy it to the desired field below.{% endtrans %}</small></p>
|
||||||
|
<div class="cron-row">
|
||||||
|
<select id="cron-minute"><option value="*">*</option>{% for i in range(60) %}<option value="{{i}}">{{'%02d'|format(i)}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-hour"><option value="*">*</option>{% for i in range(24) %}<option value="{{i}}">{{'%02d'|format(i)}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-day"><option value="*">*</option>{% for i in range(1, 32) %}<option value="{{i}}">{{i}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-month"><option value="*">*</option>{% for i in range(1, 13) %}<option value="{{i}}">{{i}}</option>{% endfor %}</select>
|
||||||
|
<select id="cron-day-of-week"><option value="*">*</option><option value="1">{{_('Mon')}}</option><option value="2">{{_('Tue')}}</option><option value="3">{{_('Wed')}}</option><option value="4">{{_('Thu')}}</option><option value="5">{{_('Fri')}}</option><option value="6">{{_('Sat')}}</option><option value="0">{{_('Sun')}}</option></select>
|
||||||
|
</div>
|
||||||
|
<label for="cron-output">{{ _('Generated Cron String:') }}</label>
|
||||||
|
<input type="text" id="cron-output" readonly>
|
||||||
|
<p id="cron-human-readable"></p>
|
||||||
|
</div>
|
||||||
|
<hr>
|
||||||
|
|
||||||
|
<form action="{{ url_for('.save_schedule') }}" method="post">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>{{ _('Worksheet') }}</th>
|
||||||
|
<th>{{ _('Schedule (Cron)') }}</th>
|
||||||
|
<th>{{ _('Report Period') }}</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{% for sheet_title, params in mappings.items() %}
|
||||||
|
<tr>
|
||||||
|
<td>{{ sheet_title }}</td>
|
||||||
|
<td>
|
||||||
|
<input type="text" name="cron-{{ sheet_title }}" value="{{ params.get('schedule_cron', '') }}" placeholder="e.g., 0 2 * * 1">
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{% set current_period = params.get('schedule_period', '') %}
|
||||||
|
<select name="period-{{ sheet_title }}" class="period-select" data-target="custom-days-{{ sheet_title }}">
|
||||||
|
<option value="">-- {{ _('Not Scheduled') }} --</option>
|
||||||
|
<option value="previous_week" {% if current_period == 'previous_week' %}selected{% endif %}>{{ _('Previous Week') }}</option>
|
||||||
|
<option value="last_7_days" {% if current_period == 'last_7_days' %}selected{% endif %}>{{ _('Last 7 Days') }}</option>
|
||||||
|
<option value="previous_month" {% if current_period == 'previous_month' %}selected{% endif %}>{{ _('Previous Month') }}</option>
|
||||||
|
<option value="current_month" {% if current_period == 'current_month' %}selected{% endif %}>{{ _('Current Month (to yesterday)') }}</option>
|
||||||
|
<option value="last_N_days" {% if current_period and current_period.startswith('last_') and current_period.endswith('_days') %}selected{% endif %}>{{ _('Last N Days') }}</option>
|
||||||
|
</select>
|
||||||
|
<div id="custom-days-{{ sheet_title }}" class="custom-days-input" style="display: none; margin-top: 5px;">
|
||||||
|
<input type="number" name="custom_days-{{ sheet_title }}" min="1" placeholder="N" style="width: 60px;" value="{% if current_period and current_period.startswith('last_') %}{{ current_period.split('_')[1] }}{% endif %}">
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
<button type="submit">{{ _('Save Schedule') }}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div> <!-- End Container -->
|
</div> <!-- End Container -->
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
@@ -251,6 +316,41 @@
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Cron конструктор
|
||||||
|
const cronInputs = ['cron-minute', 'cron-hour', 'cron-day', 'cron-month', 'cron-day-of-week'];
|
||||||
|
const cronOutput = document.getElementById('cron-output');
|
||||||
|
|
||||||
|
function updateCronString() {
|
||||||
|
if (!cronOutput) return;
|
||||||
|
const values = cronInputs.map(id => document.getElementById(id).value);
|
||||||
|
cronOutput.value = values.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
cronInputs.forEach(id => {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if(el) el.addEventListener('change', updateCronString);
|
||||||
|
});
|
||||||
|
updateCronString(); // Initial call
|
||||||
|
|
||||||
|
// Управление видимостью поля "N дней"
|
||||||
|
document.querySelectorAll('.period-select').forEach(select => {
|
||||||
|
const targetId = select.dataset.target;
|
||||||
|
const targetDiv = document.getElementById(targetId);
|
||||||
|
|
||||||
|
function toggleCustomInput() {
|
||||||
|
if (select.value === 'last_N_days') {
|
||||||
|
targetDiv.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
targetDiv.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
select.addEventListener('change', toggleCustomInput);
|
||||||
|
toggleCustomInput(); // Initial call on page load
|
||||||
|
});
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
{% else %}
|
{% 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>
|
<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>
|
||||||
|
|||||||
51
utils.py
51
utils.py
@@ -1,7 +1,8 @@
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from jinja2 import Template
|
from jinja2 import Template
|
||||||
from datetime import datetime
|
from datetime import datetime, date, timedelta
|
||||||
|
from dateutil.relativedelta import relativedelta
|
||||||
|
|
||||||
# Настройка логирования
|
# Настройка логирования
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@@ -139,3 +140,51 @@ def get_dates(start_date, end_date):
|
|||||||
raise ValueError("Дата начала не может быть позже даты окончания.")
|
raise ValueError("Дата начала не может быть позже даты окончания.")
|
||||||
|
|
||||||
return start_date, end_date
|
return start_date, end_date
|
||||||
|
|
||||||
|
def calculate_period_dates(period_key):
|
||||||
|
"""
|
||||||
|
Вычисляет начальную и конечную даты на основе строкового ключа.
|
||||||
|
Возвращает кортеж (start_date_str, end_date_str) в формате YYYY-MM-DD.
|
||||||
|
"""
|
||||||
|
if not period_key:
|
||||||
|
raise ValueError("Period key cannot be empty.")
|
||||||
|
|
||||||
|
today = date.today()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
date_format = "%Y-%m-%d"
|
||||||
|
|
||||||
|
# За прошлую неделю (с пн по вс)
|
||||||
|
if period_key == 'previous_week':
|
||||||
|
start_of_last_week = today - timedelta(days=today.weekday() + 7)
|
||||||
|
end_of_last_week = start_of_last_week + timedelta(days=6)
|
||||||
|
return start_of_last_week.strftime(date_format), end_of_last_week.strftime(date_format)
|
||||||
|
|
||||||
|
# За последние 7 дней (не включая сегодня)
|
||||||
|
if period_key == 'last_7_days':
|
||||||
|
start_date = today - timedelta(days=7)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
|
||||||
|
# За прошлый месяц
|
||||||
|
if period_key == 'previous_month':
|
||||||
|
last_month = today - relativedelta(months=1)
|
||||||
|
start_date = last_month.replace(day=1)
|
||||||
|
end_date = start_date + relativedelta(months=1) - timedelta(days=1)
|
||||||
|
return start_date.strftime(date_format), end_date.strftime(date_format)
|
||||||
|
|
||||||
|
# За текущий месяц (до вчера)
|
||||||
|
if period_key == 'current_month':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
|
||||||
|
# Динамический ключ "За последние N дней"
|
||||||
|
if period_key.startswith('last_') and period_key.endswith('_days'):
|
||||||
|
try:
|
||||||
|
days = int(period_key.split('_')[1])
|
||||||
|
if days <= 0:
|
||||||
|
raise ValueError("Number of days must be positive.")
|
||||||
|
start_date = today - timedelta(days=days)
|
||||||
|
return start_date.strftime(date_format), yesterday.strftime(date_format)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
raise ValueError(f"Invalid dynamic period key: {period_key}")
|
||||||
|
|
||||||
|
raise ValueError(f"Unknown period key: {period_key}")
|
||||||
Reference in New Issue
Block a user