Compare commits

...

11 Commits

Author SHA1 Message Date
4f66edbb21 fix index route
All checks were successful
Test Build / test-build (push) Successful in 3s
2025-07-30 19:22:10 +03:00
38f35d1915 fix old config format
All checks were successful
Test Build / test-build (push) Successful in 2s
2025-07-30 19:17:16 +03:00
ca8e70781c fix empty base on 1st start
All checks were successful
Test Build / test-build (push) Successful in 1s
2025-07-30 18:55:23 +03:00
4ebe15522f fix dotenv
All checks were successful
Test Build / test-build (push) Successful in 2s
2025-07-30 18:32:06 +03:00
0f1c749b33 Scheduler v1
All checks were successful
Test Build / test-build (push) Successful in 23s
2025-07-30 18:28:55 +03:00
8e757afe39 fix testing v3
All checks were successful
Test Build / test-build (push) Successful in 3s
2025-07-29 19:52:18 +03:00
5100c5d17c v1
All checks were successful
Test Build / test-build (push) Successful in 3s
2025-07-29 19:43:11 +03:00
81d33bebef test fix v1 2025-07-29 19:42:55 +03:00
3500d433ea fix reqs
All checks were successful
Test Build / test-build (push) Successful in 21s
2025-07-26 06:00:54 +03:00
c713b47d58 reqs fix 2025-07-26 06:00:09 +03:00
ddd0ffbcb0 vv
All checks were successful
Test Build / test-build (push) Successful in 3s
2025-07-26 05:56:11 +03:00
11 changed files with 852 additions and 516 deletions

View File

@@ -24,10 +24,20 @@ jobs:
- name: Run new container - name: Run new container
run: | run: |
PORT=5005
CONTAINER_ID=$(docker ps --format '{{.ID}} {{.Ports}}' | grep ":$PORT->" | awk '{print $1}')
if [ -n "$CONTAINER_ID" ]; then
echo "Stopping container using port $PORT..."
docker stop "$CONTAINER_ID"
docker rm "$CONTAINER_ID"
fi
docker run -d \ docker run -d \
--name olaper \ --name olaper \
--restart always \ --restart always \
-p 5005:5005 \ -p ${PORT}:5005 \
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \ -e SECRET_KEY=${{ secrets.SECRET_KEY }} \
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \ -e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
-v mholaper_data:/app/data \
olaper:latest olaper:latest

View File

@@ -39,6 +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 \
-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
View File

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

View File

@@ -1,3 +1,3 @@
[python: **.py] [python: **.py]
[jinja2: **/templates/**.html] [jinja2: **/templates/**.html]
extensions=jinja2.ext.i18n extensions=jinja2.ext.i18n

View File

@@ -1,11 +1,13 @@
from flask_sqlalchemy import SQLAlchemy 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
# Создаем экземпляры расширений здесь, без привязки к приложению.
# Теперь любой модуль может безопасно импортировать их отсюда. # Создаем экземпляры расширений здесь, без привязки к приложению.
db = SQLAlchemy() # Теперь любой модуль может безопасно импортировать их отсюда.
migrate = Migrate() db = SQLAlchemy()
login_manager = LoginManager() migrate = Migrate()
babel = Babel() login_manager = LoginManager()
babel = Babel()
scheduler = APScheduler()

98
prompt
View File

@@ -1,49 +1,49 @@
Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets" Тема: Доработка и рефакторинг Flask-приложения "MyHoreca OLAP-to-GoogleSheets"
1. Обзор Проекта 1. Обзор Проекта
Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы. Выступаешь в роли опытного Python/Flask-разработчика. Тебе предоставляется код существующего веб-приложения "MyHoreca OLAP-to-GoogleSheets". Основная задача приложения — предоставить пользователям веб-интерфейс для автоматической выгрузки OLAP-отчетов с сервера RMS (iiko/Syrve) в Google Таблицы.
Стек технологий: Стек технологий:
Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate Backend: Flask, Flask-SQLAlchemy, Flask-Login, Flask-Migrate
Работа с API: requests (для RMS), gspread (для Google Sheets) Работа с API: requests (для RMS), gspread (для Google Sheets)
Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS) Безопасность: werkzeug.security (хэширование паролей), cryptography (шифрование паролей RMS)
База данных: SQLite База данных: SQLite
Frontend: Jinja2, стандартный HTML/CSS/JS. Frontend: Jinja2, стандартный HTML/CSS/JS.
Текущий функционал: Текущий функционал:
Приложение уже реализует полный цикл работы для одного пользователя: Приложение уже реализует полный цикл работы для одного пользователя:
Регистрация и авторизация. Регистрация и авторизация.
Настройка подключения к RMS API (хост, логин, пароль). Настройка подключения к RMS API (хост, логин, пароль).
Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя. Получение и сохранение списка OLAP-отчетов (пресетов) для пользователя.
Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы). Настройка подключения к Google Sheets (загрузка credentials.json, указание URL таблицы).
Получение и сохранение списка листов из Google Таблицы. Получение и сохранение списка листов из Google Таблицы.
Сопоставление (маппинг) отчетов RMS с листами Google Таблицы. Сопоставление (маппинг) отчетов RMS с листами Google Таблицы.
Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные. Отрисовка отчета за выбранный период: приложение получает данные из RMS, очищает соответствующий лист и записывает новые данные.
Предоставленные файлы: Предоставленные файлы:
app.py (основная логика Flask) app.py (основная логика Flask)
models.py (модели SQLAlchemy) models.py (модели SQLAlchemy)
google_sheets.py (модуль для работы с Google Sheets API) google_sheets.py (модуль для работы с Google Sheets API)
request_module.py (модуль для работы с RMS API) request_module.py (модуль для работы с RMS API)
utils.py (вспомогательные функции) utils.py (вспомогательные функции)
README.md (документация) README.md (документация)
HTML-шаблоны (index.html, login.html, register.html) HTML-шаблоны (index.html, login.html, register.html)
2. Ключевые Задачи для Разработки 2. Ключевые Задачи для Разработки
Задача 1: Отладка, Рефакторинг и Русификация Комментариев Задача 1: Отладка, Рефакторинг и Русификация Комментариев
Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя. Отладка отрисовки: Внимательно проанализировать функцию render_olap в app.py и связанные с ней модули (google_sheets.py, utils.py). Выявить и исправить "нюансы" и потенциальные ошибки при обработке данных отчета и записи их в таблицу. Уделить особое внимание обработке пустых отчетов, ошибок API и корректному информированию пользователя.
Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py. Чистка кода: Провести рефакторинг кода. Удалить неиспользуемые переменные, устаревшие комментарии и "мусор". Улучшить читаемость и структуру, особенно в app.py.
Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал. Русификация комментариев: Перевести все комментарии в коде на русский язык для соответствия стандартам проекта. Пояснения должны описывать текущий, работающий функционал.
Задача 2: Интернационализация (i18n) и Перевод Интерфейса Задача 2: Интернационализация (i18n) и Перевод Интерфейса
Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности. Внедрение i18n: Интегрировать Flask-Babel для поддержки многоязычности.
Механизм выбора языка: Механизм выбора языка:
На странице логина (login.html) добавить возможность выбора языка (Русский/Английский). На странице логина (login.html) добавить возможность выбора языка (Русский/Английский).
Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД). Выбор пользователя должен сохраняться (например, в сессии или в профиле пользователя в БД).
В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧). В основном шаблоне (index.html), рядом с кнопкой "Logout", добавить переключатель языка в виде флагов (🇷🇺/🇬🇧).
Перевод интерфейса: Перевод интерфейса:
Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода. Обернуть все текстовые строки в шаблонах Jinja2 и сообщения flash() в app.py в функцию перевода.
Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным. Создать файлы перевода (.po, .mo) и выполнить полный перевод всего видимого пользователю интерфейса на русский язык. Русский язык должен стать основным.
Задача 3: Улучшение Среды Разработки для Windows Задача 3: Улучшение Среды Разработки для Windows
Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения. Поддержка .env: Интегрировать библиотеку python-dotenv для управления переменными окружения.
Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта. Конфигурация: Модифицировать app.py и models.py так, чтобы они могли считывать конфигурационные переменные (SECRET_KEY, ENCRYPTION_KEY, DATABASE_URL и др.) из файла .env в корне проекта.
Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows. Документация: Дополнить README.md инструкциями по созданию и использованию файла .env для локальной разработки, особенно на Windows.
3. Правила Взаимодействия 3. Правила Взаимодействия
Язык общения: Всегда общайся на русском языке. Язык общения: Всегда общайся на русском языке.
Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости. Формат кода: Присылай изменения в коде точечно, указывая файл и участок кода, который нужно изменить. Не присылай полные файлы без необходимости.
Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня. Бизнес-логика: Никогда не придумывай бизнес-логику самостоятельно. Если для реализации функционала требуются данные (например, конкретные ключи API, пути, названия), всегда уточняй их у меня.
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала. Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.

Binary file not shown.

968
routes.py
View File

@@ -1,432 +1,538 @@
# routes.py # routes.py
import os import os
import json import json
import shutil import shutil
from flask import ( from flask import (
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app 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_login import login_user, login_required, logout_user, current_user
from flask_babel import _ from flask_babel import _
from werkzeug.utils import secure_filename from werkzeug.utils import secure_filename
import gspread 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 def load_user(user_id):
def load_user(user_id): """Загружает пользователя из БД для управления сессией."""
"""Загружает пользователя из БД для управления сессией.""" return db.session.get(User, int(user_id))
return db.session.get(User, int(user_id))
@main_bp.before_app_request
@main_bp.before_app_request def load_user_specific_data():
def load_user_specific_data(): """Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса.""" g.user_config = None
g.user_config = None if current_user.is_authenticated:
if current_user.is_authenticated: g.user_config = get_user_config()
g.user_config = get_user_config()
# --- Вспомогательные функции, специфичные для маршрутов ---
# --- Вспомогательные функции, специфичные для маршрутов ---
def get_user_config():
def get_user_config(): """Получает конфиг для текущего пользователя, создавая его при необходимости."""
"""Получает конфиг для текущего пользователя, создавая его при необходимости.""" if not current_user.is_authenticated:
if not current_user.is_authenticated: return None
return None config = UserConfig.query.filter_by(user_id=current_user.id).first()
config = UserConfig.query.filter_by(user_id=current_user.id).first() if not config:
if not config: config = UserConfig(user_id=current_user.id)
config = UserConfig(user_id=current_user.id) db.session.add(config)
db.session.add(config) return config
return config
def get_user_upload_path(filename=""):
def get_user_upload_path(filename=""): """Возвращает путь для загрузки файлов для текущего пользователя."""
"""Возвращает путь для загрузки файлов для текущего пользователя.""" if not current_user.is_authenticated:
if not current_user.is_authenticated: return None
return None user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id)) 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:
@main_bp.route('/language/<language>') raise ValueError("Invalid cron string format. Expected 5 parts.")
def set_language(language=None): keys = ['minute', 'hour', 'day', 'month', 'day_of_week']
session['language'] = language return {keys[i]: part for i, part in enumerate(parts)}
return redirect(request.referrer or url_for('.index'))
# --- Маршруты ---
@main_bp.route('/login', methods=['GET', 'POST'])
def login(): @main_bp.route('/language/<language>')
if current_user.is_authenticated: def set_language(language=None):
return redirect(url_for('.index')) session['language'] = language
if request.method == 'POST': return redirect(request.referrer or url_for('.index'))
username = request.form.get('username')
password = request.form.get('password') @main_bp.route('/login', methods=['GET', 'POST'])
user = User.query.filter_by(username=username).first() def login():
if user is None or not user.check_password(password): if current_user.is_authenticated:
flash(_('Invalid username or password'), 'error') return redirect(url_for('.index'))
return redirect(url_for('.login')) if request.method == 'POST':
login_user(user, remember=request.form.get('remember')) username = request.form.get('username')
flash(_('Login successful!'), 'success') password = request.form.get('password')
next_page = request.args.get('next') user = User.query.filter_by(username=username).first()
return redirect(next_page or url_for('.index')) if user is None or not user.check_password(password):
return render_template('login.html') flash(_('Invalid username or password'), 'error')
return redirect(url_for('.login'))
@main_bp.route('/register', methods=['GET', 'POST']) login_user(user, remember=request.form.get('remember'))
def register(): flash(_('Login successful!'), 'success')
if current_user.is_authenticated: next_page = request.args.get('next')
return redirect(url_for('.index')) return redirect(next_page or url_for('.index'))
if request.method == 'POST': return render_template('login.html')
username = request.form.get('username')
password = request.form.get('password') @main_bp.route('/register', methods=['GET', 'POST'])
if not username or not password: def register():
flash(_('Username and password are required.'), 'error') if current_user.is_authenticated:
return redirect(url_for('.register')) return redirect(url_for('.index'))
if User.query.filter_by(username=username).first(): if request.method == 'POST':
flash(_('Username already exists.'), 'error') username = request.form.get('username')
return redirect(url_for('.register')) password = request.form.get('password')
if not username or not password:
user = User(username=username) flash(_('Username and password are required.'), 'error')
user.set_password(password) return redirect(url_for('.register'))
user.config = UserConfig() if User.query.filter_by(username=username).first():
db.session.add(user) flash(_('Username already exists.'), 'error')
try: return redirect(url_for('.register'))
db.session.commit()
flash(_('Registration successful! Please log in.'), 'success') user = User(username=username)
return redirect(url_for('.login')) user.set_password(password)
except Exception as e: user.config = UserConfig()
db.session.rollback() db.session.add(user)
flash(_('An error occurred during registration. Please try again.'), 'error') try:
return redirect(url_for('.register')) db.session.commit()
flash(_('Registration successful! Please log in.'), 'success')
return render_template('register.html') return redirect(url_for('.login'))
except Exception as e:
@main_bp.route('/logout') db.session.rollback()
@login_required flash(_('An error occurred during registration. Please try again.'), 'error')
def logout(): return redirect(url_for('.register'))
logout_user()
flash(_('You have been logged out.'), 'success') return render_template('register.html')
return redirect(url_for('.index'))
@main_bp.route('/logout')
@main_bp.route('/') @login_required
@login_required def logout():
def index(): logout_user()
config = g.user_config flash(_('You have been logged out.'), 'success')
return render_template( return redirect(url_for('.index'))
'index.html',
rms_config=config.get_rms_dict(), @main_bp.route('/')
google_config=config.get_google_dict(), @login_required
presets=config.presets, def index():
sheets=config.sheets, config = g.user_config
mappings=config.mappings, clean_mappings = {}
client_email=config.google_client_email if config.mappings:
) for key, value in config.mappings.items():
if isinstance(value, dict):
@main_bp.route('/configure_rms', methods=['POST']) clean_mappings[key] = value
@login_required else:
def configure_rms(): clean_mappings[key] = {
config = g.user_config 'report_id': value,
try: 'schedule_cron': None,
host = request.form.get('host', '').strip() 'schedule_period': None
login = request.form.get('login', '').strip() }
password = request.form.get('password', '') return render_template(
'index.html',
if not config.rms_password and not password: rms_config=config.get_rms_dict(),
flash(_('Password is required for the first time.'), 'error') google_config=config.get_google_dict(),
return redirect(url_for('.index')) presets=config.presets,
sheets=config.sheets,
if not host or not login: mappings=clean_mappings,
flash(_('Host and Login fields must be filled.'), 'error') client_email=config.google_client_email
return redirect(url_for('.index')) )
effective_password = password if password else config.rms_password @main_bp.route('/configure_rms', methods=['POST'])
@login_required
req_module = ReqModule(host, login, effective_password) def configure_rms():
if req_module.login(): config = g.user_config
presets_data = req_module.take_presets() try:
req_module.logout() host = request.form.get('host', '').strip()
login = request.form.get('login', '').strip()
config.rms_host = host password = request.form.get('password', '')
config.rms_login = login
if password: if not config.rms_password and not password:
config.rms_password = password flash(_('Password is required for the first time.'), 'error')
config.presets = presets_data return redirect(url_for('.index'))
db.session.commit() if not host or not login:
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success') flash(_('Host and Login fields must be filled.'), 'error')
else: return redirect(url_for('.index'))
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
effective_password = password if password else config.rms_password
except Exception as e:
db.session.rollback() req_module = ReqModule(host, login, effective_password)
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error') if req_module.login():
presets_data = req_module.take_presets()
return redirect(url_for('.index')) req_module.logout()
@main_bp.route('/upload_credentials', methods=['POST']) config.rms_host = host
@login_required config.rms_login = login
def upload_credentials(): if password:
config = g.user_config config.rms_password = password
if 'cred_file' not in request.files or request.files['cred_file'].filename == '': config.presets = presets_data
flash(_('No file was selected.'), 'error')
return redirect(url_for('.index')) db.session.commit()
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
cred_file = request.files['cred_file'] else:
filename = cred_file.filename flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
# Получаем путь для сохранения файла в папке пользователя
user_cred_path = get_user_upload_path(filename) except Exception as e:
temp_path = None db.session.rollback()
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
try:
# Сначала сохраняем файл во временную директорию для проверки return redirect(url_for('.index'))
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
os.makedirs(temp_dir, exist_ok=True) @main_bp.route('/upload_credentials', methods=['POST'])
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}") @login_required
cred_file.save(temp_path) def upload_credentials():
config = g.user_config
with open(temp_path, 'r', encoding='utf-8') as f: if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
cred_data = json.load(f) flash(_('No file was selected.'), 'error')
client_email = cred_data.get('client_email') return redirect(url_for('.index'))
if not client_email: cred_file = request.files['cred_file']
flash(_('Could not find client_email in the credentials file.'), 'error') filename = cred_file.filename
# Не забываем удалить временный файл при ошибке # Получаем путь для сохранения файла в папке пользователя
if os.path.exists(temp_path): user_cred_path = get_user_upload_path(filename)
os.remove(temp_path) temp_path = None
return redirect(url_for('.index'))
try:
# Если все хорошо, перемещаем файл из временной папки в постоянную # Сначала сохраняем файл во временную директорию для проверки
shutil.move(temp_path, user_cred_path) temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
os.makedirs(temp_dir, exist_ok=True)
# Сохраняем путь к файлу и email в базу данных temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
config.google_cred_file_path = user_cred_path cred_file.save(temp_path)
config.google_client_email = client_email
config.sheets = [] # Сбрасываем список листов при смене credentials with open(temp_path, 'r', encoding='utf-8') as f:
cred_data = json.load(f)
db.session.commit() client_email = cred_data.get('client_email')
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
if not client_email:
except json.JSONDecodeError: flash(_('Could not find client_email in the credentials file.'), 'error')
flash(_('Error: Uploaded file is not a valid JSON.'), 'error') # Не забываем удалить временный файл при ошибке
except Exception as e: if os.path.exists(temp_path):
db.session.rollback() os.remove(temp_path)
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error') return redirect(url_for('.index'))
finally:
# Гарантированно удаляем временный файл, если он еще существует # Если все хорошо, перемещаем файл из временной папки в постоянную
if temp_path and os.path.exists(temp_path): shutil.move(temp_path, user_cred_path)
os.remove(temp_path)
# Сохраняем путь к файлу и email в базу данных
return redirect(url_for('.index')) config.google_cred_file_path = user_cred_path
config.google_client_email = client_email
@main_bp.route('/configure_google', methods=['POST']) config.sheets = [] # Сбрасываем список листов при смене credentials
@login_required
def configure_google(): db.session.commit()
config = g.user_config flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
sheet_url = request.form.get('sheet_url', '').strip()
except json.JSONDecodeError:
if not sheet_url: flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
flash(_('Sheet URL must be provided.'), 'error') except Exception as e:
return redirect(url_for('.index')) db.session.rollback()
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
config.google_sheet_url = sheet_url finally:
# Гарантированно удаляем временный файл, если он еще существует
cred_path = config.google_cred_file_path if temp_path and os.path.exists(temp_path):
if not cred_path or not os.path.isfile(cred_path): os.remove(temp_path)
flash(_('Please upload a valid credentials file first.'), 'warning')
config.sheets = [] return redirect(url_for('.index'))
db.session.commit()
return redirect(url_for('.index')) @main_bp.route('/configure_google', methods=['POST'])
@login_required
try: def configure_google():
gs_client = GoogleSheets(cred_path, sheet_url) config = g.user_config
sheets_data = gs_client.get_sheets() sheet_url = request.form.get('sheet_url', '').strip()
config.sheets = sheets_data
if not sheet_url:
db.session.commit() flash(_('Sheet URL must be provided.'), 'error')
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success') return redirect(url_for('.index'))
except Exception as e: config.google_sheet_url = sheet_url
db.session.rollback()
config.sheets = [] cred_path = config.google_cred_file_path
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error') if not cred_path or not os.path.isfile(cred_path):
try: flash(_('Please upload a valid credentials file first.'), 'warning')
db.session.commit() config.sheets = []
except Exception: db.session.commit()
db.session.rollback() return redirect(url_for('.index'))
return redirect(url_for('.index')) try:
gs_client = GoogleSheets(cred_path, sheet_url)
@main_bp.route('/mapping_set', methods=['POST']) sheets_data = gs_client.get_sheets()
@login_required config.sheets = sheets_data
def mapping_set():
config = g.user_config db.session.commit()
try: flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
new_mappings = {}
for sheet in config.sheets: except Exception as e:
report_key = f"sheet_{sheet['id']}" db.session.rollback()
selected_report_id = request.form.get(report_key) config.sheets = []
if selected_report_id: flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
new_mappings[sheet['title']] = selected_report_id try:
db.session.commit()
config.mappings = new_mappings except Exception:
db.session.commit() db.session.rollback()
flash(_('Mappings updated successfully.'), 'success') return redirect(url_for('.index'))
except Exception as e:
db.session.rollback() @main_bp.route('/mapping_set', methods=['POST'])
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error') @login_required
def mapping_set():
return redirect(url_for('.index')) config = g.user_config
try:
@main_bp.route('/render_olap', methods=['POST']) new_mappings = {}
@login_required # Сохраняем существующие настройки расписания при обновлении отчетов
def render_olap(): current_mappings = config.mappings or {}
config = g.user_config
sheet_title = None for sheet in config.sheets:
req_module = None report_key = f"sheet_{sheet['id']}"
selected_report_id = request.form.get(report_key)
try:
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date')) if selected_report_id:
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '') # Получаем существующие данные расписания для этого листа
if not sheet_title: existing_schedule = current_mappings.get(sheet['title'], {})
flash(_('Error: Could not determine which sheet to render the report for.'), 'error') schedule_cron = None
return redirect(url_for('.index')) schedule_period = None
report_id = config.mappings.get(sheet_title) if isinstance(existing_mapping_value, dict):
if not report_id: schedule_cron = existing_mapping_value.get('schedule_cron')
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error') schedule_period = existing_mapping_value.get('schedule_period')
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]): new_mappings[sheet['title']] = {
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error') 'report_id': selected_report_id,
return redirect(url_for('.index')) 'schedule_cron': schedule_cron,
'schedule_period': schedule_period
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') config.mappings = new_mappings
return redirect(url_for('.index')) db.session.commit()
template = generate_template_from_preset(preset) flash(_('Mappings updated successfully.'), 'success')
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date}) except Exception as e:
db.session.rollback()
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password) flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
return redirect(url_for('.index'))
if req_module.login():
result = req_module.take_olap(json_body)
def execute_olap_export(user_id, sheet_title, start_date_str=None, end_date_str=None):
# --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ --- """
Основная логика выгрузки OLAP-отчета. Может вызываться как из эндпоинта, так и из планировщика.
if 'data' not in result or not isinstance(result['data'], list): Если start_date_str и end_date_str не переданы, вычисляет их на основе расписания.
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}") app = current_app._get_current_object()
return redirect(url_for('.index')) with app.app_context():
user = db.session.get(User, user_id)
report_data = result['data'] if not user:
app.logger.error(f"Task failed: User with ID {user_id} not found.")
# Если отчет пуст, очищаем лист и уведомляем пользователя. return
if not report_data:
gs_client.clear_and_write_data(sheet_title, []) config = user.config
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') req_module = None
return redirect(url_for('.index'))
try:
# Здесь будет храниться наш итоговый "плоский" список словарей mappings = config.mappings
processed_data = [] mapping_info = mappings.get(sheet_title)
# Проверяем структуру отчета: сводный (pivoted) или простой (flat) if not mapping_info or not mapping_info.get('report_id'):
first_item = report_data[0] raise ValueError(f"No report is assigned to sheet '{sheet_title}'.")
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
report_id = mapping_info['report_id']
if is_pivoted:
current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}") # Если даты не переданы (вызов из планировщика), вычисляем их
# "Разворачиваем" (unpivot) данные в плоский список словарей if not start_date_str or not end_date_str:
for row_item in report_data: period_key = mapping_info.get('schedule_period')
row_values = row_item.get('row', {}) if not period_key:
cells = row_item.get('cells', []) raise ValueError(f"Scheduled task for sheet '{sheet_title}' is missing a period setting.")
if not cells: 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})")
processed_data.append(row_values.copy()) else:
else: from_date, to_date = get_dates(start_date_str, end_date_str)
for cell in cells: app.logger.info(f"Executing manual job for user {user_id}, sheet '{sheet_title}' ({from_date} to {to_date})")
new_flat_row = row_values.copy()
new_flat_row.update(cell.get('col', {})) # Проверка полноты конфигурации
new_flat_row.update(cell.get('values', {})) if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
processed_data.append(new_flat_row) raise ValueError('RMS or Google Sheets configuration is incomplete.')
else:
current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}") preset = next((p for p in config.presets if p.get('id') == report_id), None)
# Данные уже в виде плоского списка, просто присваиваем if not preset:
processed_data = [item for item in report_data if isinstance(item, dict)] raise ValueError(f'Preset with ID "{report_id}" not found in saved configuration.')
# --- Универсальное формирование заголовков и данных --- template = generate_template_from_preset(preset)
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
# 1. Собираем все уникальные ключи из всех строк для гарантии целостности.
all_keys = set() req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
for row in processed_data: gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
all_keys.update(row.keys())
if req_module.login():
# 2. Создаем упорядоченный список заголовков для лучшей читаемости. result = req_module.take_olap(json_body)
# Используем поля из пресета для определения логического порядка.
row_group_fields = preset.get('groupByRowFields', []) # Код обработки данных (остается без изменений)
col_group_fields = preset.get('groupByColFields', []) if 'data' not in result or not isinstance(result['data'], list):
agg_fields = preset.get('aggregateFields', []) raise ValueError(f'Unexpected response format from RMS for report "{preset.get("name", report_id)}".')
ordered_headers = [] report_data = result['data']
# Сначала добавляем известные поля из пресета в логической последовательности.
for field in row_group_fields + col_group_fields + agg_fields: if not report_data:
if field in all_keys: gs_client.clear_and_write_data(sheet_title, [])
ordered_headers.append(field) app.logger.warning(f"Report '{preset.get('name', report_id)}' for user {user_id} returned no data. Sheet '{sheet_title}' cleared.")
all_keys.remove(field) return
# Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту.
ordered_headers.extend(sorted(list(all_keys))) processed_data = []
first_item = report_data[0]
# 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам. is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
data_to_insert = [ordered_headers]
for row in processed_data: if is_pivoted:
row_data = [] for row_item in report_data:
for header in ordered_headers: row_values = row_item.get('row', {})
value_str = str(row.get(header, '')) cells = row_item.get('cells', [])
if value_str.startswith(('=', '+', '-', '@')): if not cells:
row_data.append("'" + value_str) processed_data.append(row_values.copy())
else: else:
row_data.append(value_str) for cell in cells:
# Преобразуем None в пустую строку, а все остальное в строковое представление. new_flat_row = row_values.copy()
# Это предотвращает потенциальные ошибки типов со стороны Google Sheets API. new_flat_row.update(cell.get('col', {}))
data_to_insert.append(row_data) new_flat_row.update(cell.get('values', {}))
processed_data.append(new_flat_row)
else:
gs_client.clear_and_write_data(sheet_title, data_to_insert) processed_data = [item for item in report_data if isinstance(item, dict)]
rows_count = len(data_to_insert) - 1 all_keys = set()
flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".', for row in processed_data:
name=preset.get('name', report_id), all_keys.update(row.keys())
rows=rows_count,
sheet=sheet_title), 'success') row_group_fields = preset.get('groupByRowFields', [])
else: col_group_fields = preset.get('groupByColFields', [])
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error') agg_fields = preset.get('aggregateFields', [])
except ValueError as ve: ordered_headers = []
flash(_('Data Error: %(error)s', error=str(ve)), 'error') for field in row_group_fields + col_group_fields + agg_fields:
except gspread.exceptions.APIError as api_err: if field in all_keys:
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error') ordered_headers.append(field)
current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True) all_keys.remove(field)
except Exception as e: ordered_headers.extend(sorted(list(all_keys)))
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) data_to_insert = [ordered_headers]
finally: for row in processed_data:
if req_module and req_module.token: row_data = []
req_module.logout() for header in ordered_headers:
value = row.get(header, '')
value_str = str(value) if value is not None else ''
if value_str.startswith(('=', '+', '-', '@')):
row_data.append("'" + value_str)
else:
row_data.append(value_str)
data_to_insert.append(row_data)
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}.")
else:
raise Exception('Error authorizing on RMS server when trying to get a report.')
except Exception as e:
app.logger.error(f"Error in execute_olap_export for user {user_id}, sheet '{sheet_title}': {e}", exc_info=True)
finally:
if req_module and req_module.token:
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'))

View File

@@ -257,4 +257,32 @@ small {
margin-right: 5px; /* Отступ справа от чекбокса до текста */ margin-right: 5px; /* Отступ справа от чекбокса до текста */
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;
} }

View File

@@ -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,24 +197,25 @@
</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 matching_presets = presets | selectattr('id', 'equalto', report_id) | list %} {% set report_id = mapping_info.get('report_id') %}
{% set preset_name = _('ID: ') + report_id %} {% set matching_presets = presets | selectattr('id', 'equalto', report_id) | list %}
{% if matching_presets %} {% set preset_name = _('ID: ') + report_id %}
{% set preset = matching_presets[0] %} {% if matching_presets %}
{% set preset_name = preset.get('name', _('Unnamed Preset')) %} {% set preset = matching_presets[0] %}
{% endif %} {% set preset_name = preset.get('name', _('Unnamed Preset')) %}
{% endif %}
<tr> <tr>
<td>{{ sheet.title }}</td> <td>{{ sheet.title }}</td>
<td>{{ preset_name }}</td> <td>{{ preset_name }}</td>
<td> <td>
<button type="submit" name="render_{{ sheet.title }}"> <button type="submit" name="render_{{ sheet.title }}">
{{ _('Render to sheet') }} {{ _('Render to sheet') }}
</button> </button>
</td> </td>
</tr> </tr>
{% endif %} {% endif %}
{% endfor %} {% endfor %}
</tbody> </tbody>
@@ -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>

View File

@@ -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__)
@@ -138,4 +139,52 @@ def get_dates(start_date, end_date):
logger.error(f"Дата начала '{start_date}' не может быть позже даты окончания '{end_date}'.") logger.error(f"Дата начала '{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}")