Compare commits

..

19 Commits

Author SHA1 Message Date
6c11be1460 test
Some checks failed
Test Build / test-build (push) Has been cancelled
2025-08-14 19:22:04 +03:00
0a17b31c06 added scheduler v2
All checks were successful
Test Build / test-build (push) Successful in 25s
2025-07-31 03:50:08 +03:00
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
36a8548562 multiolap fixed 2025-07-26 05:53:45 +03:00
f5cf4c32da v1 2025-07-26 04:41:47 +03:00
019e4f90c7 added translations (poka nekrasivo, but babel ready) 2025-07-26 04:41:10 +03:00
2f2cd7d578 test flow v3
All checks were successful
Test Build / test-build (push) Successful in 2s
2025-07-25 13:50:34 +03:00
0fa431350d test flow v2
All checks were successful
Test Build / test-build (push) Successful in 13s
2025-07-25 13:49:13 +03:00
715f1f992c test flow v1
Some checks failed
Test Build / test-build (push) Failing after 2s
2025-07-25 13:48:08 +03:00
23 changed files with 3243 additions and 967 deletions

View File

@@ -7,30 +7,15 @@ on:
jobs:
deploy:
runs-on: [docker, host]
runs-on: [docker:host]
steps:
- name: Prepare workspace
run: |
rm -rf /tmp/olaper
mkdir -p /tmp/olaper
cd /tmp/olaper
apk update && apk add openssh docker-cli
mkdir -p ~/.ssh
chmod 700 ~/.ssh
ssh-keyscan -p 2222 10.25.100.250 >> ~/.ssh/known_hosts
git clone --branch prod ssh://git@10.25.100.250:2222/serty/olaper.git .
- name: Checkout code
uses: actions/checkout@v3
- name: Build Docker image
run: |
cd /tmp/olaper
docker build -t olaper:latest .
- name: Create volume (if not exists)
run: |
if ! docker volume inspect olaper_data >/dev/null 2>&1; then
docker volume create olaper_data
fi
- name: Stop old container (if running)
run: |
if [ "$(docker ps -q -f name=olaper)" ]; then
@@ -39,14 +24,20 @@ jobs:
- name: Run new container
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 \
--name olaper \
--restart always \
-p 5005:5005 \
-p ${PORT}:5005 \
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
-v olaper_data:/opt/olaper/data \
-v mholaper_data:/app/data \
olaper:latest
- name: Cleanup
run: rm -rf /tmp/olaper

View File

@@ -0,0 +1,48 @@
name: Test Build
on:
push:
branches:
- main
jobs:
test-build:
runs-on: [docker, host]
steps:
- name: Install Docker CLI (for Alpine-based runner)
run: |
if ! command -v docker &> /dev/null; then
apk update && apk add docker-cli
fi
- name: Prepare SSH and clone repo
run: |
apk add --no-cache openssh git
mkdir -p /root/.ssh
chmod 700 /root/.ssh
ssh-keyscan -p 2222 10.25.100.250 >> /root/.ssh/known_hosts
rm -rf /tmp/olaper
git clone --branch main ssh://git@10.25.100.250:2222/serty/olaper.git /tmp/olaper
- name: Build test Docker image
run: |
cd /tmp/olaper
docker build -t olaper:test .
- name: (Optional) Run container for testing
run: |
# Удаляем предыдущий тестовый контейнер
if [ "$(docker ps -q -f name=olaper_test)" ]; then
docker stop olaper_test && docker rm olaper_test
fi
docker run -d \
--name olaper_test \
-p 5050:5005 \
-v /home/master/olaper-debug/data:/opt/olaper/data \
-e SECRET_KEY=${{ secrets.SECRET_KEY }} \
-e ENCRYPTION_KEY=${{ secrets.ENCRYPTION_KEY }} \
olaper:test
- name: Cleanup source
run: rm -rf /tmp/olaper

1
.gitignore vendored
View File

@@ -5,3 +5,4 @@
/.idea
cred.json
*.db
*/*.log

621
app.py
View File

@@ -1,533 +1,138 @@
import json
from flask import Flask, render_template, request, redirect, url_for, flash, g, session
import gspread
from flask_login import LoginManager, UserMixin, login_user, login_required, logout_user, current_user
from flask_migrate import Migrate
import os
import logging
from werkzeug.utils import secure_filename
import shutil
from flask import Flask, session, request
from sqlalchemy import inspect
from dotenv import load_dotenv
from google_sheets import GoogleSheets
from request_module import ReqModule
from utils import *
from models import db, User, UserConfig
# 1. Загрузка переменных окружения - в самом верху
load_dotenv()
# 2. Импорт расширений из центрального файла
from extensions import scheduler, db, migrate, login_manager, babel
from models import init_encryption
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', '994525')
DATA_DIR = os.environ.get('DATA_DIR', '/app/data')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', f'sqlite:///{DATA_DIR}/app.db')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# 3. Фабрика приложений
def create_app():
"""
Создает и конфигурирует экземпляр Flask приложения.
"""
app = Flask(__name__)
db.init_app(app)
migrate = Migrate(app, db)
if not app.debug:
import logging
from logging.handlers import RotatingFileHandler
os.makedirs(DATA_DIR, exist_ok=True)
# Создаем папку для логов, если ее нет
log_dir = os.path.join(os.path.abspath(os.path.dirname(__file__)), 'data')
os.makedirs(log_dir, exist_ok=True)
# --- Flask-Login Configuration ---
login_manager = LoginManager()
login_manager.init_app(app)
login_manager.login_view = 'login' # Redirect to 'login' view if user tries to access protected page
log_file = os.path.join(log_dir, 'olaper.log')
@login_manager.user_loader
def load_user(user_id):
"""Loads user from DB for session management."""
return db.session.get(User, int(user_id))
# Создаем обработчик, который пишет логи в файл с ротацией
# 10 МБ на файл, храним 5 старых файлов
file_handler = RotatingFileHandler(log_file, maxBytes=1024*1024*10, backupCount=5, encoding='utf-8')
# --- Logging Configuration ---
logger = logging.getLogger()
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Устанавливаем формат сообщений
file_handler.setFormatter(logging.Formatter(
'%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
))
# Устанавливаем уровень логирования
file_handler.setLevel(logging.INFO)
# --- Helper Functions ---
def get_user_config():
"""Gets the config for the currently logged-in user, creating if it doesn't exist."""
if not current_user.is_authenticated:
return None # Or return a default empty config object if preferred for anonymous users
config = UserConfig.query.filter_by(user_id=current_user.id).first()
if not config:
config = UserConfig(user_id=current_user.id)
db.session.add(config)
# Commit immediately or defer, depending on workflow
# db.session.commit() # Let's commit when saving changes
logger.info(f"Created new UserConfig for user {current_user.id}")
return config
# Добавляем обработчик к логгеру приложения
app.logger.addHandler(file_handler)
def get_user_upload_path(filename=""):
"""Gets the upload path for the current user."""
if not current_user.is_authenticated:
return None # Or raise an error
user_dir = os.path.join(DATA_DIR, str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
return os.path.join(user_dir, secure_filename(filename))
app.logger.setLevel(logging.INFO)
app.logger.info('Application startup')
# --- Конфигурация приложения ---
app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'default-super-secret-key-for-dev')
rms_config = {}
google_config = {}
presets = []
sheets = []
mappings = []
# --- НАДЕЖНАЯ НАСТРОЙКА ПУТЕЙ ---
# Получаем абсолютный путь к директории, где находится app.py
basedir = os.path.abspath(os.path.dirname(__file__))
# Устанавливаем путь к папке data
data_dir = os.path.join(basedir, os.environ.get('DATA_DIR', 'data'))
# Создаем эту директорию, если ее не существует. Это ключевой момент.
os.makedirs(data_dir, exist_ok=True)
@app.before_request
def load_user_specific_data():
"""Load user-specific data into Flask's 'g' object for the current request context."""
g.user_config = None
if current_user.is_authenticated:
g.user_config = get_user_config()
# You could preload other user-specific things here if needed
# g.presets = g.user_config.presets # Example
# g.sheets = g.user_config.sheets # Example
# g.mappings = g.user_config.mappings # Example
else:
# Define defaults for anonymous users if necessary
# g.presets = []
# g.sheets = []
# g.mappings = {}
pass
app.config['DATA_DIR'] = data_dir
# --- Authentication Routes ---
# Устанавливаем путь к БД
db_path = os.path.join(data_dir, 'app.db')
app.config['SQLALCHEMY_DATABASE_URI'] = os.environ.get('DATABASE_URL', f"sqlite:///{db_path}")
@app.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user is None or not user.check_password(password):
flash('Invalid username or password', 'error')
return redirect(url_for('login'))
login_user(user, remember=request.form.get('remember'))
flash('Login successful!', 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('index'))
return render_template('login.html')
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
app.config['BABEL_DEFAULT_LOCALE'] = 'ru'
app.config['ENCRYPTION_KEY'] = os.environ.get('ENCRYPTION_KEY')
@app.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash('Username and password are required.', 'error')
return redirect(url_for('register'))
if User.query.filter_by(username=username).first():
flash('Username already exists.', 'error')
return redirect(url_for('register'))
user = User(username=username)
user.set_password(password)
user_config = UserConfig()
user.config = user_config
db.session.add(user)
# Create associated config immediately
try:
db.session.commit()
flash('Registration successful! Please log in.', 'success')
logger.info(f"User '{username}' registered successfully.")
return redirect(url_for('login'))
except Exception as e:
db.session.rollback()
logger.error(f"Error during registration for {username}: {e}")
flash('An error occurred during registration. Please try again.', 'error')
return redirect(url_for('register'))
# --- Определяем селектор языка ---
def get_locale():
if 'language' in session:
return session['language']
return request.accept_languages.best_match(['ru', 'en'])
return render_template('register.html')
# --- Инициализация расширений с приложением ---
db.init_app(app)
migrate.init_app(app, db)
login_manager.init_app(app)
babel.init_app(app, locale_selector=get_locale)
scheduler.init_app(app)
@app.route('/logout')
@login_required
def logout():
logout_user()
flash('You have been logged out.', 'success')
return redirect(url_for('index'))
init_encryption(app)
# --- Регистрация блюпринтов ---
from routes import main_bp, execute_olap_export
app.register_blueprint(main_bp)
@app.route('/')
@login_required
def index():
"""Главная страница."""
config = g.user_config
return render_template(
'index.html',
rms_config=config.get_rms_dict(),
google_config=config.get_google_dict(),
presets=config.presets,
sheets=config.sheets,
mappings=config.mappings,
client_email=config.google_client_email
)
login_manager.login_view = 'main.login'
login_manager.login_message = "Пожалуйста, войдите, чтобы получить доступ к этой странице."
login_manager.login_message_category = "info"
@app.route('/configure_rms', methods=['POST'])
@login_required
def configure_rms():
"""Настройка параметров RMS-сервера."""
config = g.user_config
try:
# Логируем вызов функции и параметры
logger.info(f"User {current_user.id}: Вызов configure_rms с параметрами: {request.form}")
host = request.form.get('host', '').strip()
login = request.form.get('login', '').strip()
password = request.form.get('password', '').strip()
# Проверяем, что все поля заполнены
if not host or not login or not password:
flash('All RMS fields must be filled.', 'error')
return redirect(url_for('index'))
# Авторизация на RMS-сервере
req_module = ReqModule(host, login, password)
if req_module.login():
presets_data = req_module.take_presets() # Сохраняем пресеты в g
req_module.logout()
# Обновляем конфигурацию RMS-сервера
config.rms_host = host
config.rms_login = login
config.rms_password = password
config.presets = presets_data
db.session.commit()
flash(f"Successfully authorized on RMS server. Received {len(presets_data)} presets.", 'success')
logger.info(f"User {current_user.id}: RMS config updated successfully.")
else:
flash('Authorization error on RMS server.', 'error')
except Exception as e:
db.session.rollback()
logger.error(f"User {current_user.id}: Ошибка при настройке RMS: {str(e)}")
flash(f'Error configuring RMS: {str(e)}', 'error')
return redirect(url_for('index'))
@app.route('/upload_credentials', methods=['POST'])
@login_required
def upload_credentials():
"""Обработчик для загрузки файла credentials для текущего пользователя."""
config = g.user_config
if 'cred_file' in request.files:
cred_file = request.files['cred_file']
if cred_file.filename != '':
filename = secure_filename(cred_file.filename)
user_cred_path = get_user_upload_path(filename)
try:
# Save the file temporarily first to read it
temp_path = os.path.join("data", f"temp_{current_user.id}_{filename}") # Temp generic uploads dir
cred_file.save(temp_path)
# Извлекаем client_email из JSON-файла
client_email = None
with open(temp_path, 'r', encoding='utf-8') as temp_cred_file:
cred_data = json.load(temp_cred_file)
client_email = cred_data.get('client_email')
if not client_email:
flash('Could not find client_email in the credentials file.', 'error')
os.remove(temp_path) # Clean up temp file
return redirect(url_for('index'))
# Move the validated file to the user's persistent directory
shutil.move(temp_path, user_cred_path)
# Update config object in DB
config.google_cred_file_path = user_cred_path
config.google_client_email = client_email
# Clear existing sheets list if creds change
config.sheets = []
# Optionally clear mappings too?
# config.mappings = {}
db.session.commit()
flash(f'Credentials file successfully uploaded and saved. Email: {client_email}', 'success')
logger.info(f"User {current_user.id}: Credentials file uploaded to {user_cred_path}")
except json.JSONDecodeError:
flash('Error: Uploaded file is not a valid JSON.', 'error')
if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file
logger.warning(f"User {current_user.id}: Uploaded invalid JSON credentials file.")
except Exception as e:
db.session.rollback()
logger.error(f"User {current_user.id}: Ошибка при загрузке credentials: {str(e)}")
flash(f'Error processing credentials: {str(e)}', 'error')
if os.path.exists(temp_path): os.remove(temp_path) # Clean up temp file
else:
flash('No file was selected.', 'error')
else:
flash('Error: Credentials file not found in request.', 'error')
return redirect(url_for('index'))
@app.route('/configure_google', methods=['POST'])
@login_required
def configure_google():
"""Настройка параметров Google Sheets для текущего пользователя."""
config = g.user_config
try:
logger.info(f"User {current_user.id}: Вызов configure_google с параметрами: {request.form}")
sheet_url = request.form.get('sheet_url', '').strip()
if not sheet_url:
flash('Sheet URL must be provided.', 'error')
return redirect(url_for('index'))
# Check if credentials file path exists in config and on disk
cred_path = config.google_cred_file_path
if not cred_path or not os.path.isfile(cred_path):
flash('Please upload a valid credentials file first.', 'warning')
# Save the URL anyway? Or require creds first? Let's save URL.
config.google_sheet_url = sheet_url
config.sheets = [] # Clear sheets if creds are missing/invalid
# Optionally clear mappings
# config.mappings = {}
db.session.commit()
return redirect(url_for('index'))
# Update sheet URL in config
config.google_sheet_url = sheet_url
# Подключение к Google Sheets
gs_client = GoogleSheets(cred_path, sheet_url) # Use path from user config
sheets_data = gs_client.get_sheets()
# Update sheets list in config
config.sheets = sheets_data
# Optionally clear mappings when sheet URL or creds change?
# config.mappings = {}
db.session.commit()
flash(f'Successfully connected to Google Sheets. Found {len(sheets_data)} sheets. Settings saved.', 'success')
logger.info(f"User {current_user.id}: Google Sheets config updated. URL: {sheet_url}")
except Exception as e:
db.session.rollback()
# Don't clear sheets list on temporary connection error
logger.error(f"User {current_user.id}: Ошибка при настройке Google Sheets: {str(e)}")
flash(f'Error connecting to Google Sheets: {str(e)}. Check the URL and service account permissions.', 'error')
# Still save the URL entered by the user
config.google_sheet_url = sheet_url
try:
db.session.commit()
except Exception as commit_err:
logger.error(f"User {current_user.id}: Error committing Google Sheet URL after connection error: {commit_err}")
db.session.rollback()
return redirect(url_for('index'))
@app.route('/mapping_set', methods=['POST'])
@login_required
def mapping_set():
"""Обновление сопоставлений листов и отчетов для текущего пользователя."""
config = g.user_config
try:
logger.info(f"User {current_user.id}: Вызов mapping_set с параметрами: {request.form}")
new_mappings = {}
# Use sheets stored in the user's config for iteration
for sheet in config.sheets:
report_key = f"sheet_{sheet['id']}"
selected_report_id = request.form.get(report_key)
if selected_report_id: # Only store non-empty selections
# Store mapping using sheet title as key, report ID as value
new_mappings[sheet['title']] = selected_report_id
# else: # Handle case where user unselects a mapping
# If sheet title existed in old mappings, remove it? Or keep structure?
# Keeping it simple: only store active mappings from the form.
config.mappings = new_mappings # Use the setter
db.session.commit()
flash('Mappings updated successfully.', 'success')
logger.info(f"User {current_user.id}: Mappings updated: {new_mappings}")
except Exception as e:
db.session.rollback()
logger.error(f"User {current_user.id}: Ошибка при обновлении сопоставлений: {str(e)}")
flash(f'Error updating mappings: {str(e)}', 'error')
return redirect(url_for('index'))
@app.route('/render_olap', methods=['POST'])
@login_required
def render_olap():
"""Отрисовка данных отчета на листе для текущего пользователя."""
config = g.user_config
sheet_title = None
report_id = None
preset = None
req_module = None
gs_client = None # Инициализируем здесь для finally
try:
# Валидация дат
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
# Получаем имя листа из кнопки
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
if not sheet_title:
flash('Ошибка: Не удалось определить лист для отрисовки отчета.', 'error')
return redirect(url_for('index'))
logger.info(f"User {current_user.id}: Попытка отрисовки OLAP для листа '{sheet_title}'")
# --- Получаем данные из конфига пользователя ---
report_id = config.mappings.get(sheet_title)
rms_host = config.rms_host
rms_login = config.rms_login
rms_password = config.rms_password # Decrypted via property getter
cred_path = config.google_cred_file_path
sheet_url = config.google_sheet_url
all_presets = config.presets
# --- Проверки ---
if not report_id:
flash(f"Ошибка: Для листа '{sheet_title}' не назначен отчет.", 'error')
return redirect(url_for('index'))
if not all([rms_host, rms_login, rms_password]):
flash('Ошибка: Конфигурация RMS не завершена.', 'error')
return redirect(url_for('index'))
if not cred_path or not sheet_url or not os.path.isfile(cred_path):
flash('Ошибка: Конфигурация Google Sheets не завершена или файл credentials недоступен.', 'error')
return redirect(url_for('index'))
preset = next((p for p in all_presets if p.get('id') == report_id), None) # Безопасное получение id
if not preset:
flash(f"Ошибка: Пресет с ID '{report_id}' не найден в сохраненной конфигурации.", 'error')
logger.warning(f"User {current_user.id}: Пресет ID '{report_id}' не найден в сохраненных пресетах для листа '{sheet_title}'")
return redirect(url_for('index'))
# --- Генерируем шаблон из одного пресета ---
try:
# Передаем сам словарь пресета
template = generate_template_from_preset(preset)
except ValueError as e:
flash(f"Ошибка генерации шаблона для отчета '{preset.get('name', report_id)}': {e}", 'error')
return redirect(url_for('index'))
except Exception as e:
flash(f"Непредвиденная ошибка при генерации шаблона для отчета '{preset.get('name', report_id)}': {e}", 'error')
logger.error(f"User {current_user.id}: Ошибка generate_template_from_preset: {e}", exc_info=True)
return redirect(url_for('index'))
if not template: # Дополнительная проверка, хотя функция теперь вызывает exception
flash(f"Ошибка: Не удалось сгенерировать шаблон для отчета '{preset.get('name', report_id)}'.", 'error')
return redirect(url_for('index'))
# --- Рендерим шаблон ---
context = {"from_date": from_date, "to_date": to_date}
try:
# Используем переименованную функцию
json_body = render_temp(template, context)
except Exception as e:
flash(f"Ошибка подготовки запроса для отчета '{preset.get('name', report_id)}': {e}", 'error')
logger.error(f"User {current_user.id}: Ошибка render_temp: {e}", exc_info=True)
return redirect(url_for('index'))
# --- Инициализация модулей ---
req_module = ReqModule(rms_host, rms_login, rms_password)
gs_client = GoogleSheets(cred_path, sheet_url) # Обработка ошибок инициализации уже внутри __init__
# --- Выполняем запросы ---
if req_module.login():
try:
logger.info(f"User {current_user.id}: Отправка OLAP-запроса для отчета {report_id} ('{preset.get('name', '')}')")
result = req_module.take_olap(json_body)
# Уменьшим логирование полного результата, если он большой
logger.debug(f"User {current_user.id}: Получен OLAP-результат (наличие ключа data: {'data' in result}, тип: {type(result.get('data'))})")
# Обрабатываем данные
if 'data' in result and isinstance(result['data'], list):
headers = []
data_to_insert = []
if result['data']:
# Получаем заголовки из первого элемента
headers = list(result['data'][0].keys())
data_to_insert.append(headers) # Добавляем строку заголовков
for item in result['data']:
row = [item.get(h, '') for h in headers]
data_to_insert.append(row)
logger.info(f"User {current_user.id}: Подготовлено {len(data_to_insert) - 1} строк данных для записи в '{sheet_title}'.")
else:
logger.warning(f"User {current_user.id}: OLAP-отчет {report_id} ('{preset.get('name', '')}') не вернул данных за период {from_date} - {to_date}.")
# Если данных нет, data_to_insert будет содержать только заголовки (если они были) или будет пуст
# --- Запись в Google Sheets ---
try:
# Если данных нет (только заголовки или пустой список), метод очистит лист
gs_client.clear_and_write_data(sheet_title, data_to_insert, start_cell="A1")
if len(data_to_insert) > 1 : # Были записаны строки данных
flash(f"Данные отчета '{preset.get('name', report_id)}' успешно записаны в лист '{sheet_title}'.", 'success')
elif len(data_to_insert) == 1: # Был записан только заголовок
flash(f"Отчет '{preset.get('name', report_id)}' не вернул данных за указанный период. Лист '{sheet_title}' очищен и записан заголовок.", 'warning')
else: # Не было ни данных, ни заголовков (пустой result['data'])
flash(f"Отчет '{preset.get('name', report_id)}' не вернул данных за указанный период. Лист '{sheet_title}' очищен.", 'warning')
except Exception as gs_error:
logger.error(f"User {current_user.id}: Не удалось записать данные в Google Sheet '{sheet_title}'. Ошибка: {gs_error}", exc_info=True)
# Не используем f-string в flash для потенциально длинных ошибок
flash(f"Не удалось записать данные в Google Sheet '{sheet_title}'. Детали в логах.", 'error')
else:
logger.error(f"User {current_user.id}: Неожиданный формат ответа OLAP: ключи={list(result.keys()) if isinstance(result, dict) else 'Не словарь'}")
flash(f"Ошибка: Неожиданный формат ответа от RMS для отчета '{preset.get('name', report_id)}'.", 'error')
except Exception as report_err:
logger.error(f"User {current_user.id}: Ошибка при получении/записи отчета {report_id}: {report_err}", exc_info=True)
flash(f"Ошибка при получении/записи отчета '{preset.get('name', report_id)}'. Детали в логах.", 'error')
finally:
if req_module and req_module.token:
try:
req_module.logout()
except Exception as logout_err:
logger.warning(f"User {current_user.id}: Ошибка при logout из RMS: {logout_err}")
else:
# Ошибка req_module.login() была залогирована внутри метода
flash('Ошибка авторизации на сервере RMS при попытке получить отчет.', 'error')
except ValueError as ve: # Ошибка валидации дат или генерации шаблона
flash(f'Ошибка данных: {str(ve)}', 'error')
logger.warning(f"User {current_user.id}: Ошибка ValueError в render_olap: {ve}")
except gspread.exceptions.APIError as api_err: # Ловим ошибки Google API отдельно
logger.error(f"User {current_user.id}: Ошибка Google API: {api_err}", exc_info=True)
flash(f"Ошибка Google API при доступе к таблице/листу '{sheet_title}'. Проверьте права доступа сервисного аккаунта.", 'error')
except Exception as e:
logger.error(f"User {current_user.id}: Общая ошибка в render_olap для листа '{sheet_title}': {str(e)}", exc_info=True)
flash(f"Произошла непредвиденная ошибка: {str(e)}", 'error')
finally:
# Дополнительная проверка logout, если ошибка произошла до блока finally внутри 'if req_module.login()'
if req_module and req_module.token:
try:
req_module.logout()
except Exception as logout_err:
logger.warning(f"User {current_user.id}: Ошибка при финальной попытке logout из RMS: {logout_err}")
return redirect(url_for('index'))
# --- Command Line Interface for DB Management ---
# Run 'flask db init' first time
# Run 'flask db migrate -m "Some description"' after changing models
# Run 'flask db upgrade' to apply migrations
@app.cli.command('init-db')
def init_db_command():
"""Creates the database tables."""
db.create_all()
print('Initialized the database.')
# --- Main Execution ---
if __name__ == '__main__':
# Ensure the database exists before running
with app.app_context():
db.create_all() # Create tables if they don't exist
# Run Flask app
# Set debug=False for production!
app.run(debug=False, host='0.0.0.0', port=int(os.environ.get("PORT", 5005))) # Listen on all interfaces if needed
if inspect(db.engine).has_table('user_config'):
from models import User, UserConfig
from utils import _parse_cron_string
all_configs = UserConfig.query.all()
for config in all_configs:
user_id = config.user_id
mappings = config.mappings
for sheet_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_{sheet_title}"
try:
if not scheduler.get_job(job_id):
scheduler.add_job(
id=job_id,
func=execute_olap_export,
trigger='cron',
args=[app, user_id, sheet_title],
**_parse_cron_string(cron_schedule)
)
app.logger.info(f"Scheduled job loaded on startup: {job_id} with schedule '{cron_schedule}'")
except Exception as e:
app.logger.error(f"Failed to load job {job_id} with schedule '{cron_schedule}': {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 ---
from models import User, UserConfig
@app.cli.command('init-db')
def init_db_command():
"""Создает или пересоздает таблицы в базе данных."""
print("Creating database tables...")
db.create_all()
print("Database tables created successfully.")
return app
# --- Точка входа для запуска ---
app = create_app()
if __name__ == '__main__':
# Для прямого запуска через `python app.py` (удобно для отладки)
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005)))

3
babel.cfg Normal file
View File

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

224
data/olaper.log Normal file
View File

@@ -0,0 +1,224 @@
2025-07-31 02:19:43,501 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:19:48,374 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
Traceback (most recent call last):
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
response = self.full_dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
rv = self.handle_user_exception(e)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
rv = self.dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
return current_app.ensure_sync(func)(*args, **kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
File "c:\safe\repos\olaper\routes.py", line 145, in index
return render_template(
'index.html',
...<5 lines>...
client_email=config.google_client_email
)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 149, in render_template
template = app.jinja_env.get_or_select_template(template_name_or_list)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1087, in get_or_select_template
return self.get_template(template_name_or_list, parent, globals)
~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1016, in get_template
return self._load_template(name, globals)
~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 975, in _load_template
template = self.loader.load(self, name, self.make_globals(globals))
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\loaders.py", line 138, in load
code = environment.compile(source, name, filename)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 771, in compile
self.handle_exception(source=source_hint)
~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
raise rewrite_traceback_stack(source=source)
File "c:\safe\repos\olaper\templates\index.html", line 289, in template
<a href="{{ url_for('.run_job_now', sheet_title=sheet_title) }}" class="button-link" onclick="return confirm('{{ _('Run the scheduled task for sheet \\%(sheet)s\\' now?', sheet=sheet_title) }}')">
jinja2.exceptions.TemplateSyntaxError: expected token ',', got 'now'
2025-07-31 02:21:56,465 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:23:21,790 INFO: Added/updated job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
2025-07-31 02:23:37,761 INFO: Removed existing job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 [in c:\safe\repos\olaper\routes.py:517]
2025-07-31 02:23:37,761 INFO: Added/updated job: user_1_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
2025-07-31 02:25:41,679 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '26 2 * * *' [in c:\safe\repos\olaper\routes.py:531]
2025-07-31 02:36:53,978 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:36:54,452 ERROR: Failed to load job user_1_sheet_<74><5F><EFBFBD><EFBFBD>1: name '_parse_cron_string' is not defined [in c:\safe\repos\olaper\app.py:116]
2025-07-31 02:36:54,453 ERROR: Failed to load job user_2_sheet_<74><5F><EFBFBD><EFBFBD>1: name '_parse_cron_string' is not defined [in c:\safe\repos\olaper\app.py:116]
2025-07-31 02:37:28,388 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '26 2 * * *' [in c:\safe\repos\olaper\routes.py:530]
2025-07-31 02:37:37,377 INFO: Removed existing job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 [in c:\safe\repos\olaper\routes.py:516]
2025-07-31 02:37:37,377 INFO: Added/updated job: user_2_sheet_<74><5F><EFBFBD><EFBFBD>1 with schedule '38 2 * * *' [in c:\safe\repos\olaper\routes.py:530]
2025-07-31 02:38:00,003 INFO: Executing scheduled job for user 2, sheet '<27><><EFBFBD><EFBFBD>1', period 'last_10_days' (2025-07-21 to 2025-07-30) [in c:\safe\repos\olaper\routes.py:367]
2025-07-31 02:38:03,135 INFO: Successfully wrote 713 rows to sheet '<27><><EFBFBD><EFBFBD>1' for user 2. [in c:\safe\repos\olaper\routes.py:447]
2025-07-31 02:46:17,382 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:46:17,849 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 02:46:17,850 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '38 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 02:46:47,525 INFO: Removed existing job: user_2_sheet_Лист1 [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 02:46:47,526 INFO: Added/updated job: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\routes.py:521]
2025-07-31 02:50:00,006 INFO: Executing scheduled job for user 2, sheet 'Лист1', period 'last_15_days' (2025-07-16 to 2025-07-30) [in c:\safe\repos\olaper\routes.py:358]
2025-07-31 02:50:02,254 INFO: Successfully wrote 805 rows to sheet 'Лист1' for user 2. [in c:\safe\repos\olaper\routes.py:438]
2025-07-31 02:53:15,576 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:53:21,166 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:55:25,567 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:55:26,057 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 02:55:26,058 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 02:55:30,020 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
Traceback (most recent call last):
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
response = self.full_dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
rv = self.handle_user_exception(e)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
rv = self.dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
return current_app.ensure_sync(func)(*args, **kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
File "c:\safe\repos\olaper\routes.py", line 137, in index
return render_template(
'index.html',
...<5 lines>...
client_email=config.google_client_email
)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 150, in render_template
return _render(app, template, context)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 131, in _render
rv = template.render(context)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1295, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
raise rewrite_traceback_stack(source=source)
File "c:\safe\repos\olaper\templates\index.html", line 337, in top-level template code
// 'X-CSRFToken': '{{ csrf_token() }}'
^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\utils.py", line 92, in from_obj
if hasattr(obj, "jinja_pass_arg"):
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'csrf_token' is undefined
2025-07-31 02:57:14,133 ERROR: Exception on / [GET] [in C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py:875]
Traceback (most recent call last):
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 1511, in wsgi_app
response = self.full_dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 919, in full_dispatch_request
rv = self.handle_user_exception(e)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 917, in full_dispatch_request
rv = self.dispatch_request()
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\app.py", line 902, in dispatch_request
return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args) # type: ignore[no-any-return]
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask_login\utils.py", line 290, in decorated_view
return current_app.ensure_sync(func)(*args, **kwargs)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^^^^^^^^^
File "c:\safe\repos\olaper\routes.py", line 137, in index
return render_template(
'index.html',
...<5 lines>...
client_email=config.google_client_email
)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 150, in render_template
return _render(app, template, context)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\flask\templating.py", line 131, in _render
rv = template.render(context)
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 1295, in render
self.environment.handle_exception()
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\environment.py", line 942, in handle_exception
raise rewrite_traceback_stack(source=source)
File "c:\safe\repos\olaper\templates\index.html", line 337, in top-level template code
body: JSON.stringify({ cron_string: cronStr })
^^^^^^^
File "C:\safe\repos\olaper\.venv\Lib\site-packages\jinja2\utils.py", line 92, in from_obj
if hasattr(obj, "jinja_pass_arg"):
~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^
jinja2.exceptions.UndefinedError: 'csrf_token' is undefined
2025-07-31 02:57:19,964 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 02:57:20,425 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 02:57:20,426 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:03:30,227 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:03:31,054 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:03:31,055 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:09:12,340 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:09:12,803 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:09:12,803 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:09:24,741 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:09:25,200 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:09:25,201 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:10:47,743 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:10:48,576 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:10:48,576 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:14:10,324 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:14:11,169 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:14:11,170 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:14:17,931 WARNING: Cron descriptor failed for string '20 18 * * *': get_description() got an unexpected keyword argument 'casing_type' [in c:\safe\repos\olaper\routes.py:506]
2025-07-31 03:16:10,376 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:16:10,839 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:16:10,840 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:16:16,952 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:16:17,401 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:16:17,402 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:16:27,363 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:17:27,908 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:17:28,360 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:17:28,361 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:17:31,939 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:18:08,943 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:18:09,771 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:18:09,772 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:18:13,243 WARNING: Cron descriptor failed for string '20 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:18:23,676 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:20:00,630 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:20:01,474 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:20:01,474 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:23:38,184 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:23:39,029 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:23:39,029 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:23:45,347 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown Locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:24:01,010 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:24:01,847 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:24:01,848 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:24:08,733 WARNING: Cron descriptor failed for string '23 18 * * *': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:505]
2025-07-31 03:26:26,201 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:26:27,048 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:26:27,049 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:27:34,340 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:27:34,799 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:27:34,799 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:27:50,717 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:27:51,179 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:27:51,179 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:29:27,053 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:29:27,894 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:29:27,894 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:32:46,325 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:32:46,788 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:32:46,788 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:32:54,423 WARNING: Cron descriptor failed for string '4 18 3 * *' with locale 'ru_RU': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:33:12,448 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:33:12,894 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:33:12,894 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:33:29,971 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:33:30,418 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:33:30,418 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:33:34,105 WARNING: Cron descriptor failed for string '4 18 3 * *' with locale 'ru_RU': Unknown locale configuration argument [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:34:54,314 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:34:54,783 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:34:54,784 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:45:44,279 INFO: Application startup [in c:\safe\repos\olaper\app.py:46]
2025-07-31 03:45:44,776 INFO: Scheduled job loaded on startup: user_1_sheet_Лист1 with schedule '25 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:45:44,776 INFO: Scheduled job loaded on startup: user_2_sheet_Лист1 with schedule '50 2 * * *' [in c:\safe\repos\olaper\app.py:115]
2025-07-31 03:45:57,114 WARNING: Cron descriptor failed for string '4 -18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:45:59,074 WARNING: Cron descriptor failed for string '4 \18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:46:00,538 WARNING: Cron descriptor failed for string '4 /18 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:46:08,186 WARNING: Cron descriptor failed for string '4 2/? 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:46:09,962 WARNING: Cron descriptor failed for string '4 2/ 3 7 5' with locale 'ru_RU': [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:46:18,499 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:46:22,058 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:48:10,579 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:48:14,027 WARNING: Cron descriptor failed for string '4 2/18 7 5' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]
2025-07-31 03:48:16,964 WARNING: Cron descriptor failed for string '4 2/18 7 5 ' with locale 'ru_RU': Error: Expression only has 4 parts. At least 5 part are required. [in c:\safe\repos\olaper\routes.py:507]

13
extensions.py Normal file
View File

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

View File

@@ -5,127 +5,99 @@ from gspread.utils import rowcol_to_a1
# Настройка логирования
logger = logging.getLogger(__name__)
def log_exceptions(func):
"""Декоратор для логирования исключений."""
"""Декоратор для стандартизированного логирования исключений в методах класса."""
def wrapper(self, *args, **kwargs):
try:
return func(self, *args, **kwargs)
except gspread.exceptions.APIError as e:
# Логируем специфичные ошибки API Google
logger.error(f"Google API Error in {func.__name__}: {e.response.status_code} - {e.response.text}")
raise # Перевыбрасываем, чтобы app.py мог обработать
# Логируем специфичные ошибки API Google с деталями
error_details = e.response.json().get('error', {})
status = error_details.get('status')
message = error_details.get('message')
logger.error(
f"Ошибка Google API в методе {func.__name__}: {status} - {message}. "
f"Проверьте права доступа сервисного аккаунта ({self.client_email}) к таблице."
)
# Перевыбрасываем, чтобы вызывающий код мог ее обработать
raise
except Exception as e:
logger.error(f"Error in {func.__name__}: {e}", exc_info=True)
logger.error(f"Непредвиденная ошибка в {func.__name__}: {e}", exc_info=True)
raise
return wrapper
class GoogleSheets:
def __init__(self, cred_file, sheet_url):
"""Инициализация клиента Google Sheets."""
"""
Инициализация клиента для работы с Google Sheets.
Args:
cred_file (str): Путь к JSON-файлу с учетными данными сервисного аккаунта.
sheet_url (str): URL Google Таблицы.
"""
try:
# Используем service_account для аутентификации
self.client = gspread.service_account(filename=cred_file)
self.sheet_url = sheet_url
# Открываем таблицу по URL
self.spreadsheet = self.client.open_by_url(sheet_url)
logger.info(f"Successfully connected to Google Sheet: {self.spreadsheet.title}")
logger.info(f"Успешное подключение к Google Sheet: '{self.spreadsheet.title}'")
except gspread.exceptions.SpreadsheetNotFound:
logger.error(f"Таблица по URL '{sheet_url}' не найдена. Проверьте URL и права доступа.")
raise
except FileNotFoundError:
logger.error(f"Файл с учетными данными не найден по пути: {cred_file}")
raise
except Exception as e:
logger.error(f"Failed to initialize GoogleSheets client or open sheet {sheet_url}: {e}", exc_info=True)
logger.error(f"Ошибка инициализации клиента GoogleSheets или открытия таблицы {sheet_url}: {e}", exc_info=True)
raise
@log_exceptions
def get_sheet(self, sheet_name):
"""Возвращает объект листа по имени."""
"""Возвращает объект листа (worksheet) по его имени."""
try:
sheet = self.spreadsheet.worksheet(sheet_name)
logger.debug(f"Retrieved worksheet object for '{sheet_name}'")
logger.debug(f"Получен объект листа '{sheet_name}'")
return sheet
except gspread.exceptions.WorksheetNotFound:
logger.error(f"Worksheet '{sheet_name}' not found in spreadsheet '{self.spreadsheet.title}'.")
raise
except Exception:
logger.error(f"Лист '{sheet_name}' не найден в таблице '{self.spreadsheet.title}'.")
raise
@log_exceptions
def get_sheets(self):
"""Получение списка листов в таблице."""
"""Получает список всех листов в таблице."""
sheets = self.spreadsheet.worksheets()
# Собираем информацию о листах: название и ID
sheet_data = [{"title": sheet.title, "id": sheet.id} for sheet in sheets]
logger.debug(f"Retrieved {len(sheet_data)} sheets: {[s['title'] for s in sheet_data]}")
logger.debug(f"Получено {len(sheet_data)} листов: {[s['title'] for s in sheet_data]}")
return sheet_data
@log_exceptions
def update_cell(self, sheet_name, cell, new_value):
"""Обновляет значение ячейки с логированием старого значения."""
sheet = self.get_sheet(sheet_name)
# Используем try-except для получения старого значения, т.к. ячейка может быть пустой
old_value = None
try:
old_value = sheet.acell(cell).value
except Exception as e:
logger.warning(f"Could not get old value for cell {cell} in sheet {sheet_name}: {e}")
# gspread рекомендует использовать update для одиночных ячеек тоже
sheet.update(cell, new_value, value_input_option='USER_ENTERED')
# Логируем новое значение, т.к. оно могло быть преобразовано Google Sheets
try:
logged_new_value = sheet.acell(cell).value
except Exception:
logged_new_value = new_value # Fallback if reading back fails
logger.info(f"Cell {cell} in sheet '{sheet_name}' updated. Old: '{old_value}', New: '{logged_new_value}'")
@log_exceptions
def clear_and_write_data(self, sheet_name, data, start_cell="A1"):
def clear_and_write_data(self, sheet_name, data):
"""
Очищает ВЕСЬ указанный лист и записывает новые данные (список списков),
начиная с ячейки start_cell.
Очищает указанный лист и записывает на него новые данные.
Args:
sheet_name (str): Имя листа для записи.
data (list): Список списков с данными для записи (первый список - заголовки).
"""
if not isinstance(data, list):
raise TypeError("Data must be a list of lists.")
raise TypeError("Данные для записи должны быть списком списков.")
sheet = self.get_sheet(sheet_name)
logger.info(f"Clearing entire sheet '{sheet_name}'...")
sheet.clear() # Очищаем весь лист
logger.info(f"Sheet '{sheet_name}' cleared.")
logger.info(f"Очистка листа '{sheet_name}'...")
sheet.clear()
logger.info(f"Лист '{sheet_name}' очищен.")
if not data or not data[0]: # Проверяем, есть ли вообще данные для записи
logger.warning(f"No data provided to write to sheet '{sheet_name}' after clearing.")
return # Ничего не записываем, если данных нет
# Проверяем, есть ли данные для записи
if not data:
logger.warning(f"Нет данных для записи на лист '{sheet_name}' после очистки.")
return # Завершаем, если список данных пуст
num_rows = len(data)
num_cols = len(data[0]) # Предполагаем, что все строки имеют одинаковую длину
num_cols = len(data[0]) if data and data[0] else 0
# Рассчитываем конечную ячейку на основе начальной и размеров данных
try:
start_row, start_col = gspread.utils.a1_to_rowcol(start_cell)
end_row = start_row + num_rows - 1
end_col = start_col + num_cols - 1
end_cell = rowcol_to_a1(end_row, end_col)
range_to_write = f"{start_cell}:{end_cell}"
except Exception as e:
logger.error(f"Failed to calculate range from start_cell '{start_cell}' and data dimensions ({num_rows}x{num_cols}): {e}. Defaulting to A1 notation if possible.")
# Фоллбэк на стандартный A1 диапазон, если расчет сломался
end_cell_simple = rowcol_to_a1(num_rows, num_cols)
range_to_write = f"A1:{end_cell_simple}"
if start_cell != "A1":
logger.warning(f"Using default range {range_to_write} as calculation from start_cell failed.")
logger.info(f"Writing {num_rows} rows and {num_cols} columns to sheet '{sheet_name}' in range {range_to_write}...")
# Используем update для записи всего диапазона одним запросом
sheet.update(range_to_write, data, value_input_option='USER_ENTERED')
logger.info(f"Successfully wrote data to sheet '{sheet_name}', range {range_to_write}.")
@log_exceptions
def read_range(self, sheet_name, range_a1):
"""Чтение значений из диапазона."""
sheet = self.get_sheet(sheet_name)
# batch_get возвращает список списков значений [[...], [...]]
# Используем get() для более простого чтения диапазона
values = sheet.get(range_a1)
# values = sheet.batch_get([range_a1])[0] # batch_get возвращает [[values_for_range1], [values_for_range2], ...]
logger.debug(f"Read {len(values)} rows from sheet '{sheet_name}', range {range_a1}.")
# logger.debug(f"Значения из диапазона {range_a1}: {values}") # Может быть слишком много данных для лога
return values
logger.info(f"Запись {num_rows} строк и {num_cols} колонок на лист '{sheet_name}'...")
# Используем метод update для записи всего диапазона одним API-вызовом
sheet.update(data, value_input_option='USER_ENTERED')
logger.info(f"Данные успешно записаны на лист '{sheet_name}'.")

529
messages.pot Normal file
View File

@@ -0,0 +1,529 @@
# Translations template for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-26 03:16+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:46
msgid "Please log in to access this page."
msgstr ""
#: app.py:114
msgid "Invalid username or password"
msgstr ""
#: app.py:117
msgid "Login successful!"
msgstr ""
#: app.py:130
msgid "Username and password are required."
msgstr ""
#: app.py:133
msgid "Username already exists."
msgstr ""
#: app.py:142
msgid "Registration successful! Please log in."
msgstr ""
#: app.py:148
msgid "An error occurred during registration. Please try again."
msgstr ""
#: app.py:157
msgid "You have been logged out."
msgstr ""
#: app.py:189
msgid "Password is required for the first time."
msgstr ""
#: app.py:193
msgid "Host and Login fields must be filled."
msgstr ""
#: app.py:211
#, python-format
msgid "Successfully authorized on RMS server. Received %(num)s presets."
msgstr ""
#: app.py:214
msgid "Authorization error on RMS server. Check host, login or password."
msgstr ""
#: app.py:219
#, python-format
msgid "Error configuring RMS: %(error)s"
msgstr ""
#: app.py:229
msgid "No file was selected."
msgstr ""
#: app.py:248
msgid "Could not find client_email in the credentials file."
msgstr ""
#: app.py:258
#, python-format
msgid "Credentials file successfully uploaded. Email: %(email)s"
msgstr ""
#: app.py:262
msgid "Error: Uploaded file is not a valid JSON."
msgstr ""
#: app.py:267
#, python-format
msgid "Error processing credentials: %(error)s"
msgstr ""
#: app.py:282
msgid "Sheet URL must be provided."
msgstr ""
#: app.py:289
msgid "Please upload a valid credentials file first."
msgstr ""
#: app.py:300
#, python-format
msgid ""
"Successfully connected to Google Sheets. Found %(num)s sheets. Settings "
"saved."
msgstr ""
#: app.py:307
#, python-format
msgid ""
"Error connecting to Google Sheets: %(error)s. Check the URL and service "
"account permissions."
msgstr ""
#: app.py:333
msgid "Mappings updated successfully."
msgstr ""
#: app.py:338
#, python-format
msgid "Error updating mappings: %(error)s"
msgstr ""
#: app.py:354
msgid "Error: Could not determine which sheet to render the report for."
msgstr ""
#: app.py:361
#, python-format
msgid "Error: No report is assigned to sheet \"%(sheet)s\"."
msgstr ""
#: app.py:366
msgid "Error: RMS or Google Sheets configuration is incomplete."
msgstr ""
#: app.py:371
#, python-format
msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration."
msgstr ""
#: app.py:387
#, python-format
msgid "Error: Unexpected response format from RMS for report \"%(name)s\"."
msgstr ""
#: app.py:400
#, python-format
msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"."
msgstr ""
#: app.py:402
#, python-format
msgid ""
"Report \"%(name)s\" returned no data for the selected period. Sheet "
"\"%(sheet)s\" has been cleared."
msgstr ""
#: app.py:404
msgid "Error authorizing on RMS server when trying to get a report."
msgstr ""
#: app.py:407
#, python-format
msgid "Data Error: %(error)s"
msgstr ""
#: app.py:410
#, python-format
msgid ""
"Google API Error accessing sheet \"%(sheet)s\". Check service account "
"permissions."
msgstr ""
#: app.py:413
#, python-format
msgid "An unexpected error occurred: %(error)s"
msgstr ""
#: templates/index.html:6
msgid "MyHoreca OLAPer"
msgstr ""
#: templates/index.html:11
msgid "MyHoreca OLAP-to-GoogleSheets"
msgstr ""
#: templates/index.html:15
msgid "Logged in as:"
msgstr ""
#: templates/index.html:16
msgid "Logout"
msgstr ""
#: templates/index.html:18
msgid "Русский"
msgstr ""
#: templates/index.html:19
msgid "English"
msgstr ""
#: templates/index.html:24 templates/login.html:4 templates/login.html:13
#: templates/login.html:29
msgid "Login"
msgstr ""
#: templates/index.html:25 templates/register.html:4 templates/register.html:13
#: templates/register.html:26
msgid "Register"
msgstr ""
#: templates/index.html:41
msgid "Connection to RMS-server"
msgstr ""
#: templates/index.html:43
msgid "RMS Server Configuration"
msgstr ""
#: templates/index.html:45
msgid ""
"Enter the details for your RMS server API. This information is used to "
"connect,\n"
" authenticate, and retrieve the list of available OLAP report "
"presets."
msgstr ""
#: templates/index.html:49
msgid "RMS-host (e.g., http://your-rms-api.com/resto):"
msgstr ""
#: templates/index.html:52
msgid "API Login:"
msgstr ""
#: templates/index.html:55
msgid "API Password:"
msgstr ""
#: templates/index.html:58
msgid "Password is saved. Enter a new one only if you need to change it."
msgstr ""
#: templates/index.html:60
msgid "Enter the API password for your RMS server."
msgstr ""
#: templates/index.html:63
msgid "Check and Save RMS-config"
msgstr ""
#: templates/index.html:66 templates/index.html:68 templates/index.html:116
#: templates/index.html:118
msgid "Status:"
msgstr ""
#: templates/index.html:66
#, python-format
msgid "Successfully connected to RMS. Found %(num)s OLAP presets."
msgstr ""
#: templates/index.html:68
msgid "RMS configuration saved. Presets not yet loaded or connection failed."
msgstr ""
#: templates/index.html:73
msgid "Configure RMS first"
msgstr ""
#: templates/index.html:74 templates/index.html:77
msgid "Google Sheets Configuration"
msgstr ""
#: templates/index.html:79
msgid ""
"To allow the application to write to your Google Sheet, you need to "
"provide\n"
" credentials for a Google Service Account. This account will act"
" on behalf\n"
" of the application."
msgstr ""
#: templates/index.html:84
msgid "How to get credentials:"
msgstr ""
#: templates/index.html:85
msgid "Go to Google Cloud Console."
msgstr ""
#: templates/index.html:86
msgid "Create a new project or select an existing one."
msgstr ""
#: templates/index.html:87
msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project."
msgstr ""
#: templates/index.html:88
msgid ""
"Go to \"Credentials\", click \"Create Credentials\", choose \"Service "
"Account\"."
msgstr ""
#: templates/index.html:89
msgid "Give it a name and grant it the \"Editor\" role."
msgstr ""
#: templates/index.html:90
msgid "Create a JSON key for the service account and download the file."
msgstr ""
#: templates/index.html:91
msgid ""
"Share your target Google Sheet with the service account's email address "
"(found in the downloaded JSON file, key `client_email`)."
msgstr ""
#: templates/index.html:94
msgid "Service Account Credentials (JSON file):"
msgstr ""
#: templates/index.html:97
msgid "Current Service Account Email:"
msgstr ""
#: templates/index.html:98
msgid "Upload a new file only if you need to change credentials."
msgstr ""
#: templates/index.html:100
msgid "Upload the JSON file downloaded from Google Cloud Console."
msgstr ""
#: templates/index.html:102
msgid "Upload Credentials"
msgstr ""
#: templates/index.html:106
msgid ""
"Enter the URL of the Google Sheet you want to use. The service account "
"email\n"
" (shown above after uploading credentials) must have edit "
"access to this sheet."
msgstr ""
#: templates/index.html:110
msgid "Google Sheet URL:"
msgstr ""
#: templates/index.html:112
msgid "Upload Service Account Credentials first"
msgstr ""
#: templates/index.html:113
msgid "Connect Google Sheets"
msgstr ""
#: templates/index.html:116
#, python-format
msgid "Successfully connected to Google Sheet. Found %(num)s worksheets."
msgstr ""
#: templates/index.html:118
msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed."
msgstr ""
#: templates/index.html:124
msgid "Configure RMS and Google Sheets first"
msgstr ""
#: templates/index.html:125
msgid "Mapping Sheets to OLAP Reports"
msgstr ""
#: templates/index.html:128
msgid "Map Worksheets to OLAP Reports"
msgstr ""
#: templates/index.html:130
msgid ""
"Select which OLAP report from RMS should be rendered into each specific "
"worksheet\n"
" (tab) in your Google Sheet."
msgstr ""
#: templates/index.html:138
msgid "Worksheet (Google Sheets)"
msgstr ""
#: templates/index.html:139
msgid "OLAP-report (RMS)"
msgstr ""
#: templates/index.html:148
msgid "Not set"
msgstr ""
#: templates/index.html:160
msgid "Save Mappings"
msgstr ""
#: templates/index.html:163
msgid ""
"Worksheets and OLAP presets are not loaded. Please configure RMS and "
"Google Sheets first."
msgstr ""
#: templates/index.html:165
msgid "Worksheets are not loaded. Check Google Sheets configuration."
msgstr ""
#: templates/index.html:167
msgid "OLAP presets are not loaded. Check RMS configuration."
msgstr ""
#: templates/index.html:172
msgid "Configure Mappings first"
msgstr ""
#: templates/index.html:173
msgid "Render Reports to Sheets"
msgstr ""
#: templates/index.html:176
msgid "Render Reports"
msgstr ""
#: templates/index.html:178
msgid ""
"Select the date range and click \"Render to sheet\" for each mapping you "
"wish to execute.\n"
" The application will retrieve the OLAP data from RMS for the "
"selected report and period,\n"
" clear the corresponding worksheet in Google Sheets, and write "
"the new data."
msgstr ""
#: templates/index.html:184
msgid "From Date:"
msgstr ""
#: templates/index.html:187
msgid "To Date:"
msgstr ""
#: templates/index.html:193
msgid "Worksheet"
msgstr ""
#: templates/index.html:194
msgid "Mapped OLAP Report"
msgstr ""
#: templates/index.html:195
msgid "Action"
msgstr ""
#: templates/index.html:203
msgid "ID: "
msgstr ""
#: templates/index.html:206
msgid "Unnamed Preset"
msgstr ""
#: templates/index.html:214
msgid "Render to sheet"
msgstr ""
#: templates/index.html:224
msgid "No mappings configured yet."
msgstr ""
#: templates/index.html:225
msgid ""
"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to "
"set up mappings."
msgstr ""
#: templates/index.html:248
msgid "Please,"
msgstr ""
#: templates/index.html:248
msgid "login"
msgstr ""
#: templates/index.html:248
msgid "or"
msgstr ""
#: templates/index.html:248
msgid "register"
msgstr ""
#: templates/login.html:22 templates/register.html:22
msgid "Username:"
msgstr ""
#: templates/login.html:24 templates/register.html:24
msgid "Password:"
msgstr ""
#: templates/login.html:27
msgid "Remember Me"
msgstr ""
#: templates/login.html:31
msgid "Don't have an account?"
msgstr ""
#: templates/login.html:31
msgid "Register here"
msgstr ""
#: templates/register.html:28
msgid "Already have an account?"
msgstr ""
#: templates/register.html:28
msgid "Login here"
msgstr ""

View File

@@ -8,34 +8,36 @@ import logging
logger = logging.getLogger(__name__)
db = SQLAlchemy()
# 1. Инициализируем расширение без привязки к app
from extensions import db
# Generate a key for encryption. STORE THIS SECURELY in production (e.g., env variable)
# For development, we can generate/load it from a file.
encryption_key_str = os.environ.get('ENCRYPTION_KEY')
if not encryption_key_str:
logger.error("ENCRYPTION_KEY environment variable not set! RMS password encryption will fail.")
# Можно либо упасть с ошибкой, либо использовать временный ключ (НЕ РЕКОМЕНДУЕТСЯ для продакшена)
# raise ValueError("ENCRYPTION_KEY environment variable is required.")
# Для локального запуска без установки переменной, можно временно сгенерировать:
logger.warning("Generating temporary encryption key. SET ENCRYPTION_KEY ENV VAR FOR PRODUCTION!")
ENCRYPTION_KEY = Fernet.generate_key()
else:
# 2. Инициализация Fernet вынесена в функцию, чтобы она вызывалась ПОСЛЕ загрузки .env
fernet = None
def init_encryption(app):
"""Инициализирует Fernet после того, как конфигурация загружена."""
global fernet
encryption_key_str = app.config.get('ENCRYPTION_KEY')
if not encryption_key_str:
logger.error("Переменная окружения ENCRYPTION_KEY не установлена! Шифрование паролей RMS не будет работать.")
logger.warning("Генерируется временный ключ шифрования. Для продакшена ОБЯЗАТЕЛЬНО установите ENCRYPTION_KEY!")
encryption_key = Fernet.generate_key()
else:
try:
ENCRYPTION_KEY = encryption_key_str.encode('utf-8')
# Простая проверка, что ключ валидный для Fernet
Fernet(ENCRYPTION_KEY)
logger.info("Successfully loaded ENCRYPTION_KEY from environment variable.")
encryption_key = encryption_key_str.encode('utf-8')
Fernet(encryption_key)
logger.info("Ключ шифрования ENCRYPTION_KEY успешно загружен.")
except Exception as e:
logger.error(f"Invalid ENCRYPTION_KEY format in environment variable: {e}")
raise ValueError("Invalid ENCRYPTION_KEY format.") from e
logger.critical(f"Недопустимый формат ключа ENCRYPTION_KEY: {e}")
raise ValueError("Недопустимый формат ключа ENCRYPTION_KEY.") from e
fernet = Fernet(encryption_key)
fernet = Fernet(ENCRYPTION_KEY)
class User(db.Model, UserMixin):
# ... код класса User остается без изменений ...
id = db.Column(db.Integer, primary_key=True)
username = db.Column(db.String(80), unique=True, nullable=False)
password_hash = db.Column(db.String(128))
password_hash = db.Column(db.String(256))
config = db.relationship('UserConfig', backref='user', uselist=False, cascade="all, delete-orphan")
def set_password(self, password):
@@ -47,81 +49,73 @@ class User(db.Model, UserMixin):
def __repr__(self):
return f'<User {self.username}>'
class UserConfig(db.Model):
# ... код класса UserConfig почти без изменений ...
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False, unique=True)
# RMS Config
rms_host = db.Column(db.String(200))
rms_login = db.Column(db.String(100))
rms_password_encrypted = db.Column(db.LargeBinary) # Store encrypted password
# Google Config
google_cred_file_path = db.Column(db.String(300)) # Store path, not content
rms_password_encrypted = db.Column(db.LargeBinary)
google_cred_file_path = db.Column(db.String(300))
google_sheet_url = db.Column(db.String(300))
google_client_email = db.Column(db.String(200)) # Store for display
# Mappings, Presets, Sheets (Stored as JSON strings)
mappings_json = db.Column(db.Text, default='{}')
google_client_email = db.Column(db.String(200))
presets_json = db.Column(db.Text, default='[]')
sheets_json = db.Column(db.Text, default='[]')
# --- Helper properties for easy access ---
mappings_json = db.Column(db.Text, default='{}')
@property
def rms_password(self):
"""Дешифрует пароль RMS при доступе."""
if not fernet:
raise RuntimeError("Fernet encryption is not initialized. Call init_encryption(app) first.")
if self.rms_password_encrypted:
try:
return fernet.decrypt(self.rms_password_encrypted).decode('utf-8')
except Exception: # Handle potential decryption errors
except Exception as e:
logger.error(f"Ошибка дешифрования пароля для user_id {self.user_id}: {e}")
return None
return None
@rms_password.setter
def rms_password(self, value):
"""Шифрует пароль RMS при установке."""
if not fernet:
raise RuntimeError("Fernet encryption is not initialized. Call init_encryption(app) first.")
if value:
self.rms_password_encrypted = fernet.encrypt(value.encode('utf-8'))
else:
self.rms_password_encrypted = None # Or handle as needed
@property
def mappings(self):
return json.loads(self.mappings_json or '{}')
@mappings.setter
def mappings(self, value):
self.mappings_json = json.dumps(value or {}, ensure_ascii=False)
self.rms_password_encrypted = None
# ... остальные properties (presets, sheets, mappings) и методы (get_rms_dict, get_google_dict) остаются без изменений ...
@property
def presets(self):
return json.loads(self.presets_json or '[]')
@presets.setter
def presets(self, value):
self.presets_json = json.dumps(value or [], ensure_ascii=False)
@property
def sheets(self):
return json.loads(self.sheets_json or '[]')
@sheets.setter
def sheets(self, value):
self.sheets_json = json.dumps(value or [], ensure_ascii=False)
# Convenience getter for template display
@property
def mappings(self):
return json.loads(self.mappings_json or '{}')
@mappings.setter
def mappings(self, value):
self.mappings_json = json.dumps(value or {}, ensure_ascii=False)
def get_rms_dict(self):
return {
'host': self.rms_host or '',
'login': self.rms_login or '',
'password': self.rms_password or '' # Use decrypted password here if needed for display/form population (be cautious!)
# Usually, password fields are left blank in forms for security.
'password_is_set': bool(self.rms_password_encrypted)
}
def get_google_dict(self):
return {
'cred_file': self.google_cred_file_path or '', # Maybe just indicate if file exists?
'cred_file_is_set': bool(self.google_cred_file_path and os.path.exists(self.google_cred_file_path)),
'sheet_url': self.google_sheet_url or ''
}
def __repr__(self):
return f'<UserConfig for User ID {self.user_id}>'

49
prompt Normal file
View File

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

View File

@@ -4,17 +4,20 @@ import hashlib
# Настройка логирования
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
# Уровень логирования настраивается в основном модуле app.py
# logger.setLevel(logging.DEBUG)
class ReqModule:
def __init__(self, host, rmsLogin, password):
self.host = host
self.rmsLogin = rmsLogin
# Пароль для API iiko/Syrve должен передаваться в виде SHA1-хэша
self.password = hashlib.sha1(password.encode('utf-8')).hexdigest()
self.token = None
self.session = requests.Session()
def login(self):
"""Выполняет авторизацию на сервере RMS и получает токен."""
logger.info(f"Вызов метода login с логином: {self.rmsLogin}")
try:
response = self.session.post(
@@ -22,19 +25,25 @@ class ReqModule:
data={'login': self.rmsLogin, 'pass': self.password},
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
if response.status_code == 200:
response.raise_for_status() # Вызовет исключение для статусов 4xx/5xx
self.token = response.text
logger.info(f'Получен токен: {self.token}')
logger.info(f'Успешно получен токен: {self.token[:8]}...') # Логируем только часть токена
return True
elif response.status_code == 401:
logger.error(f'Ошибка авторизации. {response.text}')
raise Exception('Unauthorized')
except requests.exceptions.HTTPError as e:
if e.response.status_code == 401:
logger.error(f'Ошибка авторизации (401). Неверный логин или пароль. Ответ сервера: {e.response.text}')
else:
logger.error(f'HTTP ошибка при авторизации: {e}')
return False # Возвращаем False вместо выброса исключения для удобства обработки в app.py
except Exception as e:
logger.error(f'Error in get_token: {str(e)}')
raise
logger.error(f'Непредвиденная ошибка в login: {str(e)}')
raise # Выбрасываем исключение для критических ошибок (например, недоступность хоста)
def logout(self):
"""Функция для освобождения токена авторизации."""
"""Освобождает токен авторизации на сервере RMS."""
if not self.token:
logger.warning("Попытка вызова logout без активного токена.")
return False
try:
response = self.session.post(
f'{self.host}/api/logout',
@@ -42,15 +51,20 @@ class ReqModule:
headers={'Content-Type': 'application/x-www-form-urlencoded'}
)
if response.status_code == 200:
logger.info(f"{self.token} -- Токен освобожден")
logger.info(f"Токен {self.token[:8]}... успешно освобожден.")
self.token = None
return True
else:
logger.warning(f"Не удалось освободить токен. Статус: {response.status_code}, Ответ: {response.text}")
return False
except Exception as e:
logger.error(f'Ошибка освобождения токена. {str(e)}')
logger.error(f'Ошибка при освобождении токена: {str(e)}')
raise
def take_olap(self, params):
"""Функция для отправки кастомного OLAP-запроса."""
"""Отправляет кастомный OLAP-запрос на сервер RMS."""
if not self.token:
raise Exception("Невозможно выполнить запрос take_olap: отсутствует токен авторизации.")
try:
cookies = {'key': self.token}
response = self.session.post(
@@ -58,30 +72,32 @@ class ReqModule:
json=params,
cookies=cookies
)
if response.status_code == 200:
response.raise_for_status() # Проверка на HTTP ошибки
return response.json()
else:
logger.error(f'Не удалось получить кастомный OLAP. Status code: {response.status_code} \nText: {response.text}')
raise Exception('Request failed')
except requests.exceptions.RequestException as e:
logger.error(f'Ошибка при выполнении OLAP-запроса: {e}. URL: {e.request.url if e.request else "N/A"}')
raise Exception(f'Ошибка сети при запросе OLAP: {e}')
except Exception as e:
logger.error(f'Error in send_olap_request: {str(e)}')
logger.error(f'Непредвиденная ошибка в take_olap: {str(e)}')
raise
def take_presets(self):
"""Функция генерации шаблонов OLAP-запросов"""
"""Получает список доступных OLAP-отчетов (пресетов) с сервера RMS."""
if not self.token:
raise Exception("Невозможно выполнить запрос take_presets: отсутствует токен авторизации.")
try:
cookies = {'key': self.token}
response = self.session.get(
f'{self.host}/api/v2/reports/olap/presets',
cookies=cookies
)
if response.status_code == 200:
response.raise_for_status() # Проверка на HTTP ошибки
presets = response.json()
logger.info('Пресеты переданы в генератор шаблонов')
logger.info(f"Успешно получено {len(presets)} пресетов OLAP-отчетов.")
return presets
else:
logger.error(f"Не удалось получить пресеты. {response.text}")
raise Exception('Take presets failed')
except requests.exceptions.RequestException as e:
logger.error(f"Сетевая ошибка при получении пресетов: {e}")
raise Exception(f'Ошибка сети при получении пресетов: {e}')
except Exception as e:
logger.error(f'Ошибка получения пресетов: {str(e)}')
logger.error(f'Непредвиденная ошибка в take_presets: {str(e)}')
raise

Binary file not shown.

573
routes.py Normal file
View File

@@ -0,0 +1,573 @@
# routes.py
import os
import json
import shutil
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app, jsonify
)
from flask_login import login_user, login_required, logout_user, current_user
from flask_babel import _
from cron_descriptor import ExpressionDescriptor, CasingTypeEnum, Options
from werkzeug.utils import secure_filename
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
# Импортируем экземпляры расширений, созданные в app.py
from extensions import db, login_manager, scheduler
# Импортируем наши классы и утилиты
from models import User, UserConfig
from google_sheets import GoogleSheets
from request_module import ReqModule
from utils import calculate_period_dates, get_dates, generate_template_from_preset, render_temp, _parse_cron_string
# --- Создание блюпринта ---
main_bp = Blueprint('main', __name__)
# --- Регистрация обработчиков для расширений ---
@login_manager.user_loader
def load_user(user_id):
"""Загружает пользователя из БД для управления сессией."""
return db.session.get(User, int(user_id))
@main_bp.before_app_request
def load_user_specific_data():
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
g.user_config = None
if current_user.is_authenticated:
g.user_config = get_user_config()
# --- Вспомогательные функции, специфичные для маршрутов ---
def get_user_config():
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
if not current_user.is_authenticated:
return None
config = UserConfig.query.filter_by(user_id=current_user.id).first()
if not config:
config = UserConfig(user_id=current_user.id)
db.session.add(config)
return config
def get_user_upload_path(filename=""):
"""Возвращает путь для загрузки файлов для текущего пользователя."""
if not current_user.is_authenticated:
return None
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
return os.path.join(user_dir, secure_filename(filename))
# --- Маршруты ---
@main_bp.route('/language/<language>')
def set_language(language=None):
session['language'] = language
return redirect(request.referrer or url_for('.index'))
@main_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user is None or not user.check_password(password):
flash(_('Invalid username or password'), 'error')
return redirect(url_for('.login'))
login_user(user, remember=request.form.get('remember'))
flash(_('Login successful!'), 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('.index'))
return render_template('login.html')
@main_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash(_('Username and password are required.'), 'error')
return redirect(url_for('.register'))
if User.query.filter_by(username=username).first():
flash(_('Username already exists.'), 'error')
return redirect(url_for('.register'))
user = User(username=username)
user.set_password(password)
user.config = UserConfig()
db.session.add(user)
try:
db.session.commit()
flash(_('Registration successful! Please log in.'), 'success')
return redirect(url_for('.login'))
except Exception as e:
db.session.rollback()
flash(_('An error occurred during registration. Please try again.'), 'error')
return redirect(url_for('.register'))
return render_template('register.html')
@main_bp.route('/logout')
@login_required
def logout():
logout_user()
flash(_('You have been logged out.'), 'success')
return redirect(url_for('.index'))
@main_bp.route('/')
@login_required
def index():
config = g.user_config
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(
'index.html',
rms_config=config.get_rms_dict(),
google_config=config.get_google_dict(),
presets=config.presets,
sheets=config.sheets,
mappings=clean_mappings,
client_email=config.google_client_email
)
@main_bp.route('/configure_rms', methods=['POST'])
@login_required
def configure_rms():
config = g.user_config
try:
host = request.form.get('host', '').strip()
login = request.form.get('login', '').strip()
password = request.form.get('password', '')
if not config.rms_password and not password:
flash(_('Password is required for the first time.'), 'error')
return redirect(url_for('.index'))
if not host or not login:
flash(_('Host and Login fields must be filled.'), 'error')
return redirect(url_for('.index'))
effective_password = password if password else config.rms_password
req_module = ReqModule(host, login, effective_password)
if req_module.login():
presets_data = req_module.take_presets()
req_module.logout()
config.rms_host = host
config.rms_login = login
if password:
config.rms_password = password
config.presets = presets_data
db.session.commit()
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
else:
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
except Exception as e:
db.session.rollback()
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
return redirect(url_for('.index'))
@main_bp.route('/upload_credentials', methods=['POST'])
@login_required
def upload_credentials():
config = g.user_config
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
flash(_('No file was selected.'), 'error')
return redirect(url_for('.index'))
cred_file = request.files['cred_file']
filename = cred_file.filename
# Получаем путь для сохранения файла в папке пользователя
user_cred_path = get_user_upload_path(filename)
temp_path = None
try:
# Сначала сохраняем файл во временную директорию для проверки
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
cred_file.save(temp_path)
with open(temp_path, 'r', encoding='utf-8') as f:
cred_data = json.load(f)
client_email = cred_data.get('client_email')
if not client_email:
flash(_('Could not find client_email in the credentials file.'), 'error')
# Не забываем удалить временный файл при ошибке
if os.path.exists(temp_path):
os.remove(temp_path)
return redirect(url_for('.index'))
# Если все хорошо, перемещаем файл из временной папки в постоянную
shutil.move(temp_path, user_cred_path)
# Сохраняем путь к файлу и email в базу данных
config.google_cred_file_path = user_cred_path
config.google_client_email = client_email
config.sheets = [] # Сбрасываем список листов при смене credentials
db.session.commit()
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
except json.JSONDecodeError:
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
except Exception as e:
db.session.rollback()
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
finally:
# Гарантированно удаляем временный файл, если он еще существует
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
return redirect(url_for('.index'))
@main_bp.route('/configure_google', methods=['POST'])
@login_required
def configure_google():
config = g.user_config
sheet_url = request.form.get('sheet_url', '').strip()
if not sheet_url:
flash(_('Sheet URL must be provided.'), 'error')
return redirect(url_for('.index'))
config.google_sheet_url = sheet_url
cred_path = config.google_cred_file_path
if not cred_path or not os.path.isfile(cred_path):
flash(_('Please upload a valid credentials file first.'), 'warning')
config.sheets = []
db.session.commit()
return redirect(url_for('.index'))
try:
gs_client = GoogleSheets(cred_path, sheet_url)
sheets_data = gs_client.get_sheets()
config.sheets = sheets_data
db.session.commit()
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
except Exception as e:
db.session.rollback()
config.sheets = []
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
try:
db.session.commit()
except Exception:
db.session.rollback()
return redirect(url_for('.index'))
@main_bp.route('/mapping_set', methods=['POST'])
@login_required
def mapping_set():
config = g.user_config
try:
new_mappings = {}
current_mappings = config.mappings or {}
# Итерируемся по всем листам, доступным пользователю в Google Sheets
for sheet in config.sheets:
# Получаем ID отчета, который пользователь выбрал для этого листа в форме
report_key = f"sheet_{sheet['id']}"
selected_report_id = request.form.get(report_key)
# Если пользователь выбрал отчет (а не оставил поле пустым)
if selected_report_id:
# Инициализируем переменные для расписания
schedule_cron = None
schedule_period = None
# Ищем текущие настройки для этого конкретного листа в базе данных
current_sheet_mapping = current_mappings.get(sheet.get('title'))
# Если для этого листа уже есть настройки, и они в новом формате (словарь),
# то мы сохраняем его параметры расписания.
if isinstance(current_sheet_mapping, dict):
schedule_cron = current_sheet_mapping.get('schedule_cron')
schedule_period = current_sheet_mapping.get('schedule_period')
# Создаем новую запись сопоставления.
# Она будет содержать НОВЫЙ ID отчета и СТАРЫЕ (сохраненные) настройки расписания.
new_mappings[sheet.get('title')] = {
'report_id': selected_report_id,
'schedule_cron': schedule_cron,
'schedule_period': schedule_period
}
# Полностью заменяем старые сопоставления на новые
config.mappings = new_mappings
db.session.commit()
flash(_('Mappings updated successfully.'), 'success')
except Exception as e:
db.session.rollback()
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
return redirect(url_for('.index'))
def execute_olap_export(app, user_id, sheet_title, start_date_str=None, end_date_str=None):
"""
Основная логика выгрузки OLAP-отчета. Может вызываться как из эндпоинта, так и из планировщика.
Если start_date_str и end_date_str не переданы, вычисляет их на основе расписания.
"""
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
try:
mappings = config.mappings
mapping_info = mappings.get(sheet_title)
if not mapping_info or not mapping_info.get('report_id'):
raise ValueError(f"No report is assigned to sheet '{sheet_title}'.")
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]):
raise ValueError('RMS or Google Sheets configuration is incomplete.')
preset = next((p for p in config.presets if p.get('id') == report_id), None)
if not preset:
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})
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
if req_module.login():
result = req_module.take_olap(json_body)
# Код обработки данных (остается без изменений)
if 'data' not in result or not isinstance(result['data'], list):
raise ValueError(f'Unexpected response format from RMS for report "{preset.get("name", report_id)}".')
report_data = result['data']
if not report_data:
gs_client.clear_and_write_data(sheet_title, [])
app.logger.warning(f"Report '{preset.get('name', report_id)}' for user {user_id} returned no data. Sheet '{sheet_title}' cleared.")
return
processed_data = []
first_item = report_data[0]
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
if is_pivoted:
for row_item in report_data:
row_values = row_item.get('row', {})
cells = row_item.get('cells', [])
if not cells:
processed_data.append(row_values.copy())
else:
for cell in cells:
new_flat_row = row_values.copy()
new_flat_row.update(cell.get('col', {}))
new_flat_row.update(cell.get('values', {}))
processed_data.append(new_flat_row)
else:
processed_data = [item for item in report_data if isinstance(item, dict)]
all_keys = set()
for row in processed_data:
all_keys.update(row.keys())
row_group_fields = preset.get('groupByRowFields', [])
col_group_fields = preset.get('groupByColFields', [])
agg_fields = preset.get('aggregateFields', [])
ordered_headers = []
for field in row_group_fields + col_group_fields + agg_fields:
if field in all_keys:
ordered_headers.append(field)
all_keys.remove(field)
ordered_headers.extend(sorted(list(all_keys)))
data_to_insert = [ordered_headers]
for row in processed_data:
row_data = []
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_app._get_current_object(), 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('/translate_cron', methods=['POST'])
@login_required
def translate_cron():
"""
Принимает cron-строку и возвращает ее человекочитаемое описание.
"""
cron_string = request.json.get('cron_string')
if not cron_string:
return jsonify({'description': ''})
try:
# 1. Получаем и преобразуем код локали
app_locale = session.get('language', 'ru')
locale_map = { 'ru': 'ru_RU', 'en': 'en_US' }
cron_locale = locale_map.get(app_locale, 'en_US')
# 2. Создаем объект Options ТОЛЬКО для форматирования
options = Options()
options.locale_code = cron_locale
options.casing_type = CasingTypeEnum.Sentence
options.use_24hour_time_format = True
# 3. Создаем ExpressionDescriptor, передавая ему ЯВНО и опции, и локаль
descriptor = ExpressionDescriptor(
expression=cron_string,
options=options
)
# 4. Получаем описание
description = descriptor.get_description()
return jsonify({'description': description})
except Exception as e:
current_app.logger.warning(f"Cron descriptor failed for string '{cron_string}' with locale '{cron_locale}': {e}")
return jsonify({'description': str(_('Invalid cron format'))})
@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_app._get_current_object(), 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'))

View File

@@ -107,17 +107,18 @@ button:disabled {
}
.content {
padding: 15px;
display: none;
padding: 0 18px; /* Добавляем горизонтальный паддинг, но убираем вертикальный */
max-height: 0; /* Изначально контент сжат по высоте */
overflow: hidden;
background-color: #fefefe; /* Very light background */
transition: max-height 0.3s ease-out; /* Плавный переход для высоты */
background-color: #fefefe;
width: 100%;
max-width: 500px;
margin: 0 auto;
box-sizing: border-box;
border: 1px solid #eee;
border-top: none; /* No top border to connect visually with collapsible */
border-radius: 0 0 8px 8px; /* Rounded corners only at the bottom */
border-top: none;
border-radius: 0 0 8px 8px;
}
.content h3 { /* Style for internal content headings */
@@ -257,3 +258,31 @@ small {
vertical-align: middle; /* Выравнивание по вертикали */
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

@@ -1,24 +1,28 @@
<!DOCTYPE html>
<html lang="ru">
<html lang="{{ session.get('language', 'ru') }}">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>MyHoreca OLAPer</title>
<title>{{ _('MyHoreca OLAPer') }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<h1>MyHoreca OLAP-to-GoogleSheets</h1>
<h1>{{ _('MyHoreca OLAP-to-GoogleSheets') }}</h1>
{% if current_user.is_authenticated %}
<div class="user-info">
Logged in as: <strong>{{ current_user.username }}</strong> |
<a href="{{ url_for('logout') }}">Logout</a>
{{ _('Logged in as:') }} <strong>{{ current_user.username }}</strong> |
<a href="{{ url_for('.logout') }}">{{ _('Logout') }}</a>
<span class="lang-switcher">
<a href="{{ url_for('.set_language', language='ru') }}" title="{{ _('Русский') }}">🇷🇺</a> /
<a href="{{ url_for('.set_language', language='en') }}" title="{{ _('English') }}">🇬🇧</a>
</span>
</div>
{% else %}
<div class="user-info">
<a href="{{ url_for('login') }}">Login</a> |
<a href="{{ url_for('register') }}">Register</a>
<a href="{{ url_for('.login') }}">{{ _('Login') }}</a> |
<a href="{{ url_for('.register') }}">{{ _('Register') }}</a>
</div>
{% endif %}
@@ -34,105 +38,105 @@
{% if current_user.is_authenticated %}
<div class="container">
<!-- Секция RMS-сервера -->
<button type="button" class="collapsible">1. Connection to RMS-server</button>
<button type="button" class="collapsible">1. {{ _('Connection to RMS-server') }}</button>
<div class="content">
<h3>RMS Server Configuration</h3>
<h3>{{ _('RMS Server Configuration') }}</h3>
<p>
Enter the details for your RMS server API. This information is used to connect,
authenticate, and retrieve the list of available OLAP report presets.
{% trans %}Enter the details for your RMS server API. This information is used to connect,
authenticate, and retrieve the list of available OLAP report presets.{% endtrans %}
</p>
<form action="{{ url_for('configure_rms') }}" method="post">
<label for="host">RMS-host (e.g., http://your-rms-api.com/resto):</label>
<form action="{{ url_for('.configure_rms') }}" method="post">
<label for="host">{{ _('RMS-host (e.g., http://your-rms-api.com/resto):') }}</label>
<input type="text" id="host" name="host" value="{{ rms_config.get('host', '') }}" required /><br />
<label for="login">API Login:</label>
<label for="login">{{ _('API Login:') }}</label>
<input type="text" id="login" name="login" value="{{ rms_config.get('login', '') }}" required /><br />
<label for="password">API Password (enter if you want to change):</label>
<input type="password" id="password" name="password" value="" {% if not rms_config.get('password') %}required{% endif %} /><br />
{% if rms_config.get('password') %}
<small>Password is saved and will be used. Enter only if you need to change it.</small><br/>
<label for="password">{{ _('API Password:') }}</label>
<input type="password" id="password" name="password" value="" {% if not rms_config.password_is_set %}required{% endif %} /><br />
{% if rms_config.password_is_set %}
<small>{{ _('Password is saved. Enter a new one only if you need to change it.') }}</small><br/>
{% else %}
<small>Enter the API password for your RMS server.</small><br/>
<small>{{ _('Enter the API password for your RMS server.') }}</small><br/>
{% endif %}
<button type="submit">Check and Save RMS-config</button>
<button type="submit">{{ _('Check and Save RMS-config') }}</button>
</form>
{% if presets %}
<p><strong>Status:</strong> Successfully connected to RMS. Found {{ presets|length }} OLAP presets.</p>
<p><strong>{{ _('Status:') }}</strong> {% trans num=presets|length %}Successfully connected to RMS. Found %(num)s OLAP presets.{% endtrans %}</p>
{% elif rms_config.get('host') %}
<p><strong>Status:</strong> RMS configuration saved. Presets not yet loaded or connection failed.</p>
<p><strong>{{ _('Status:') }}</strong> {{ _('RMS configuration saved. Presets not yet loaded or connection failed.') }}</p>
{% endif %}
</div>
<!-- Секция Google-таблиц -->
<button type="button" class="collapsible" {% if not rms_config.get('host') %}disabled title="Configure RMS first"{% endif %}>
2. Google Sheets Configuration
<button type="button" class="collapsible" {% if not rms_config.get('host') %}disabled title="{{ _('Configure RMS first') }}"{% endif %}>
2. {{ _('Google Sheets Configuration') }}
</button>
<div class="content">
<h3>Google Sheets Configuration</h3>
<h3>{{ _('Google Sheets Configuration') }}</h3>
<p>
To allow the application to write to your Google Sheet, you need to provide
{% trans %}To allow the application to write to your Google Sheet, you need to provide
credentials for a Google Service Account. This account will act on behalf
of the application.
of the application.{% endtrans %}
</p>
<p>
<strong>How to get credentials:</strong>
<br>1. Go to Google Cloud Console.
<br>2. Create a new project or select an existing one.
<br>3. Enable the "Google Sheets API" and "Google Drive API" for the project.
<br>4. Go to "Credentials", click "Create Credentials", choose "Service Account".
<br>5. Give it a name, grant it necessary permissions (e.g., Editor role for simplicity, or more granular roles for Sheets/Drive).
<br>6. Create a JSON key for the service account. Download this file.
<br>7. Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`).
<strong>{{ _('How to get credentials:') }}</strong>
<br>1. {{ _('Go to Google Cloud Console.') }}
<br>2. {{ _('Create a new project or select an existing one.') }}
<br>3. {{ _('Enable the "Google Sheets API" and "Google Drive API" for the project.') }}
<br>4. {{ _('Go to "Credentials", click "Create Credentials", choose "Service Account".') }}
<br>5. {{ _('Give it a name and grant it the "Editor" role.') }}
<br>6. {{ _('Create a JSON key for the service account and download the file.') }}
<br>7. {% trans %}Share your target Google Sheet with the service account's email address (found in the downloaded JSON file, key `client_email`).{% endtrans %}
</p>
<form action="{{ url_for('upload_credentials') }}" method="post" enctype="multipart/form-data">
<label for="cred_file">Service Account Credentials (JSON file):</label>
<input type="file" id="cred_file" name="cred_file" accept=".json" {% if not google_config.get('cred_file') %}required{% endif %} /><br />
<form action="{{ url_for('.upload_credentials') }}" method="post" enctype="multipart/form-data">
<label for="cred_file">{{ _('Service Account Credentials (JSON file):') }}</label>
<input type="file" id="cred_file" name="cred_file" accept=".json" /><br />
{% if client_email %}
<p><strong>Current Service Account Email:</strong> <code>{{ client_email }}</code></p>
<small>Upload a new file only if you need to change credentials.</small><br/>
<p><strong>{{ _('Current Service Account Email:') }}</strong> <code>{{ client_email }}</code></p>
<small>{{ _('Upload a new file only if you need to change credentials.') }}</small><br/>
{% else %}
<small>Upload the JSON file downloaded from Google Cloud Console.</small><br/>
<small>{{ _('Upload the JSON file downloaded from Google Cloud Console.') }}</small><br/>
{% endif %}
<button type="submit">Upload Credentials</button>
<button type="submit">{{ _('Upload Credentials') }}</button>
</form>
<hr>
<p>
Enter the URL of the Google Sheet you want to use. The service account email
(shown above after uploading credentials) must have edit access to this sheet.
{% trans %}Enter the URL of the Google Sheet you want to use. The service account email
(shown above after uploading credentials) must have edit access to this sheet.{% endtrans %}
</p>
<form action="{{ url_for('configure_google') }}" method="post">
<label for="sheet_url">Google Sheet URL:</label>
<form action="{{ url_for('.configure_google') }}" method="post">
<label for="sheet_url">{{ _('Google Sheet URL:') }}</label>
<input type="text" id="sheet_url" name="sheet_url" value="{{ google_config.get('sheet_url', '') }}" required placeholder="https://docs.google.com/spreadsheets/d/..."/>
<button type="submit" {% if not client_email %}disabled title="Upload Service Account Credentials first"{% endif %}>
Connect Google Sheets
<button type="submit" {% if not client_email %}disabled title="{{ _('Upload Service Account Credentials first') }}"{% endif %}>
{{ _('Connect Google Sheets') }}
</button>
{% if sheets %}
<p><strong>Status:</strong> Successfully connected to Google Sheet. Found {{ sheets|length }} worksheets.</p>
<p><strong>{{ _('Status:') }}</strong> {% trans num=sheets|length %}Successfully connected to Google Sheet. Found %(num)s worksheets.{% endtrans %}</p>
{% elif google_config.get('sheet_url') %}
<p><strong>Status:</strong> Google Sheet URL saved. Worksheets not yet loaded or connection failed.</p>
<p><strong>{{ _('Status:') }}</strong> {{ _('Google Sheet URL saved. Worksheets not yet loaded or connection failed.') }}</p>
{% endif %}
</form>
</div>
<!-- Секция сопоставления листов-отчетов -->
<button type="button" class="collapsible" {% if not sheets or not presets %}disabled title="Configure RMS and Google Sheets first"{% endif %}>
3. Mapping Sheets to OLAP Reports
<button type="button" class="collapsible" {% if not sheets or not presets %}disabled title="{{ _('Configure RMS and Google Sheets first') }}"{% endif %}>
3. {{ _('Mapping Sheets to OLAP Reports') }}
</button>
<div class="content">
<h3>Map Worksheets to OLAP Reports</h3>
<h3>{{ _('Map Worksheets to OLAP Reports') }}</h3>
<p>
Select which OLAP report from RMS should be rendered into each specific worksheet
(tab) in your Google Sheet.
{% trans %}Select which OLAP report from RMS should be rendered into each specific worksheet
(tab) in your Google Sheet.{% endtrans %}
</p>
{% if sheets and presets %}
<form action="{{ url_for('mapping_set') }}" method="post">
<form action="{{ url_for('.mapping_set') }}" method="post">
<table>
<thead>
<tr>
<th>Worksheet (Google Sheets)</th>
<th>OLAP-report (RMS)</th>
<th>{{ _('Worksheet (Google Sheets)') }}</th>
<th>{{ _('OLAP-report (RMS)') }}</th>
</tr>
</thead>
<tbody>
@@ -140,11 +144,10 @@
<tr>
<td>{{ sheet.title }}</td>
<td>
<!-- Use sheet.id for unique name -->
<select name="sheet_{{ sheet.id }}">
<option value="">-- Not set --</option>
<option value="">-- {{ _('Not set') }} --</option>
{% 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'] }})
</option>
{% endfor %}
@@ -154,80 +157,141 @@
{% endfor %}
</tbody>
</table>
<button type="submit">Save Mappings</button>
<button type="submit">{{ _('Save Mappings') }}</button>
</form>
{% elif not sheets and not presets %}
<p>Worksheets and OLAP presets are not loaded. Please configure RMS and Google Sheets first.</p>
<p>{{ _('Worksheets and OLAP presets are not loaded. Please configure RMS and Google Sheets first.') }}</p>
{% elif not sheets %}
<p>Worksheets are not loaded. Check Google Sheets configuration.</p>
<p>{{ _('Worksheets are not loaded. Check Google Sheets configuration.') }}</p>
{% elif not presets %}
<p>OLAP presets are not loaded. Check RMS configuration.</p>
<p>{{ _('OLAP presets are not loaded. Check RMS configuration.') }}</p>
{% endif %}
</div>
<!-- Секция отрисовки отчетов на листах -->
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="Configure Mappings first"{% endif %}>
4. Render Reports to Sheets
<button type="button" class="collapsible" {% if not mappings or mappings|length == 0 %}disabled title="{{ _('Configure Mappings first') }}"{% endif %}>
4. {{ _('Render Reports to Sheets') }}
</button>
<div class="content">
<h3>Render Reports</h3>
<h3>{{ _('Render Reports') }}</h3>
<p>
Select the date range and click "Render to sheet" for each mapping you wish to execute.
{% trans %}Select the date range and click "Render to sheet" for each mapping you wish to execute.
The application will retrieve the OLAP data from RMS for the selected report and period,
clear the corresponding worksheet in Google Sheets, and write the new data.
clear the corresponding worksheet in Google Sheets, and write the new data.{% endtrans %}
</p>
{% if mappings and mappings|length > 0 %}
<form action="{{ url_for('render_olap') }}" method="post">
<label for="start_date">From Date:</label>
<form action="{{ url_for('.render_olap') }}" method="post">
<label for="start_date">{{ _('From Date:') }}</label>
<input type="date" id="start_date" name="start_date" required /><br />
<label for="end_date">To Date:</label>
<label for="end_date">{{ _('To Date:') }}</label>
<input type="date" id="end_date" name="end_date" required /><br />
<table>
<thead>
<tr>
<th>Worksheet</th>
<th>Mapped OLAP Report</th>
<th>Action</th>
<th>{{ _('Worksheet') }}</th>
<th>{{ _('Mapped OLAP Report') }}</th>
<th>{{ _('Action') }}</th>
</tr>
</thead>
<tbody>
{# Iterate through sheets loaded from Google, check for mapping #}
{% for sheet in sheets %}
{% set report_id = mappings.get(sheet.title) %}
{% if report_id %} {# Only display rows with a valid mapping #}
{# Find the preset name by ID using Jinja filters #}
{# Find the preset dictionary where 'id' attribute equals report_id #}
{% for sheet_title, mapping_info in mappings.items() %}
{% 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 preset_name = 'ID: ' + report_id %} {# Default display if preset not found or unnamed #}
{# If a matching preset was found, get its name #}
<!-- Отображаем строку только если для этого report_id найден соответствующий пресет -->
{% if matching_presets %}
{% set preset = matching_presets[0] %}
{% set preset_name = preset.get('name', 'Unnamed Preset') %}
{% endif %}
{% set preset_name = preset.get('name', _('Unnamed Preset')) %}
<tr>
<td>{{ sheet.title }}</td>
<td>{{ sheet_title }}</td>
<td>{{ preset_name }}</td>
<td>
<button type="submit" name="render_{{ sheet.title }}">
Render to sheet
<button type="submit" name="render_{{ sheet_title }}">
{{ _('Render to sheet') }}
</button>
</td>
</tr>
{% endif %}
{% endif %}
{% endfor %}
</tbody>
</table>
</form>
{% else %}
<p>No mappings configured yet.</p>
<p><small>Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.</small></p>
<p>{{ _('No mappings configured yet.') }}</p>
<p><small>{{ _('Please go to the "Mapping Sheets to OLAP Reports" section (Step 3) to set up mappings.') }}</small></p>
{% endif %}
</div>
<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">
<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>
<th>{{ _('Action') }}</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 -->
<script>
@@ -235,56 +299,106 @@
var coll = document.getElementsByClassName("collapsible");
for (var i = 0; i < coll.length; i++) {
coll[i].addEventListener("click", function () {
// Не переключать, если кнопка отключена
if (this.disabled) return;
// Не выполнять действие, если кнопка отключена
if (this.disabled) {
return;
}
this.classList.toggle("active");
var content = this.nextElementSibling;
if (content.style.display === "block") {
content.style.display = "none";
// Если max-height установлен (т.е. секция открыта), то скрыть ее
if (content.style.maxHeight) {
content.style.maxHeight = null;
} else {
content.style.display = "block";
// Иначе (секция закрыта), установить max-height равным высоте контента
// Это "раскроет" секцию с плавной анимацией
content.style.maxHeight = content.scrollHeight + "px";
}
});
}
// Optional: Auto-expand sections based on config state?
// This requires passing more state from the Flask app to the template.
// For now, keep it simple with manual expansion.
// window.addEventListener('load', () => {
// // Example logic: if RMS configured but Google not, open Google section
// const rmsConfigured = '{{ rms_config.get("host") }}' !== '';
// const googleCredsExist = '{{ client_email }}' !== '';
// const googleSheetUrlSet = '{{ google_config.get("sheet_url") }}' !== '';
// // Corrected lines:
// const presetsLoaded = {{ (presets|length > 0) | lower }};
// const sheetsLoaded = {{ (sheets|length > 0) | lower }};
// const mappingsExist = {{ (mappings|length > 0) | lower }};
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');
const humanReadableOutput = document.getElementById('cron-human-readable');
// const collapsibles = document.getElementsByClassName("collapsible");
// Функция для перевода cron-строки
async function translateCronString(cronStr) {
if (!humanReadableOutput) return;
// if (rmsConfigured && !googleCredsExist) {
// // Find and click Google Sheets collapsible
// for (let i = 0; i < collapsibles.length; i++) {
// if (collapsibles[i].innerText.includes("Google Sheets Configuration")) {
// collapsibles[i].click();
// break;
// }
// }
// } else if (rmsConfigured && googleCredsExist && googleSheetUrlSet && presetsLoaded && sheetsLoaded && !mappingsExist) {
// // Find and click Mapping collapsible
// for (let i = 0; i in collapsibles.length; i++) { // <-- Potential typo here, should be <
// if (collapsibles[i].innerText.includes("Mapping Sheets to OLAP Reports")) {
// collapsibles[i].click();
// break;
// }
// }
// }
// // Add more conditions as needed
// });
try {
const response = await fetch("{{ url_for('.translate_cron') }}", {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ cron_string: cronStr })
});
if (!response.ok) {
humanReadableOutput.textContent = 'Error communicating with server.';
return;
}
const data = await response.json();
humanReadableOutput.textContent = data.description;
humanReadableOutput.style.color = data.description.startsWith('Invalid') ? 'red' : '#555';
} catch (error) {
console.error('Error translating cron string:', error);
humanReadableOutput.textContent = 'Translation error.';
}
}
// Функция для обновления строки (из селектов) и запуска перевода
function updateCronStringFromSelects() {
if (!cronOutput) return;
const values = cronInputs.map(id => document.getElementById(id).value);
const newCronString = values.join(' ');
cronOutput.value = newCronString;
// Вызываем перевод
translateCronString(newCronString);
}
// Слушаем изменения в выпадающих списках конструктора
cronInputs.forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', updateCronStringFromSelects);
});
if (cronOutput) {
cronOutput.addEventListener('input', function() {
translateCronString(this.value); // Переводим то, что введено вручную
});
}
// Первоначальный вызов при загрузке страницы (для начальной строки)
updateCronStringFromSelects();
// --- Управление видимостью поля "N дней" ---
document.querySelectorAll('.period-select').forEach(select => {
const targetId = select.dataset.target;
const targetDiv = document.getElementById(targetId);
function toggleCustomInput() {
if (!targetDiv) return;
if (select.value === 'last_N_days') {
targetDiv.style.display = 'block';
} else {
targetDiv.style.display = 'none';
}
}
select.addEventListener('change', toggleCustomInput);
toggleCustomInput();
});
});
</script>
{% else %}
<p style="text-align: center; margin-top: 50px;">Please, <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a></p>
<p style="text-align: center; margin-top: 50px;">{{ _('Please,') }} <a href="{{ url_for('.login') }}">{{ _('login') }}</a> {{ _('or') }} <a href="{{ url_for('.register') }}">{{ _('register') }}</a></p>
{% endif %}
</body>
</html>

View File

@@ -1,12 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>Login</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
<title>{{ _('Login') }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body>
<div class="auth-container"> {# <-- Add this wrapper div #}
<h1>Login</h1>
<div class="auth-container">
<div class="lang-switcher-auth">
<a href="{{ url_for('.set_language', language='ru') }}">Русский</a> |
<a href="{{ url_for('.set_language', language='en') }}">English</a>
</div>
<h1>{{ _('Login') }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
@@ -15,17 +19,16 @@
{% endif %}
{% endwith %}
<form method="post">
<label for="username">Username:</label>
<label for="username">{{ _('Username:') }}</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<label for="password">{{ _('Password:') }}</label>
<input type="password" id="password" name="password" required><br>
{# Adjusted label and input for "Remember Me" #}
<label for="remember">
<input type="checkbox" name="remember" id="remember"> Remember Me
<input type="checkbox" name="remember" id="remember"> {{ _('Remember Me') }}
</label><br>
<button type="submit">Login</button>
<button type="submit">{{ _('Login') }}</button>
</form>
<p>Don't have an account? <a href="{{ url_for('register') }}">Register here</a></p>
</div> {# <-- Close the wrapper div #}
<p>{{ _("Don't have an account?") }} <a href="{{ url_for('.register') }}">{{ _('Register here') }}</a></p>
</div>
</body>
</html>

View File

@@ -1,12 +1,16 @@
<!DOCTYPE html>
<html>
<head>
<title>Register</title>
<title>{{ _('Register') }}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}"> <!-- Link to your CSS -->
</head>
<body>
<div class="auth-container"> {# <-- Add this wrapper div #}
<h1>Register</h1>
<div class="auth-container">
<div class="lang-switcher-auth">
<a href="{{ url_for('.set_language', language='ru') }}">Русский</a> |
<a href="{{ url_for('.set_language', language='en') }}">English</a>
</div>
<h1>{{ _('Register') }}</h1>
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
@@ -15,13 +19,13 @@
{% endif %}
{% endwith %}
<form method="post">
<label for="username">Username:</label>
<label for="username">{{ _('Username:') }}</label>
<input type="text" id="username" name="username" required><br>
<label for="password">Password:</label>
<label for="password">{{ _('Password:') }}</label>
<input type="password" id="password" name="password" required><br>
<button type="submit">Register</button>
<button type="submit">{{ _('Register') }}</button>
</form>
<p>Already have an account? <a href="{{ url_for('login') }}">Login here</a></p>
</div> {# <-- Close the wrapper div #}
<p>{{ _("Already have an account?") }} <a href="{{ url_for('.login') }}">{{ _('Login here') }}</a></p>
</div>
</body>
</html>

Binary file not shown.

View File

@@ -0,0 +1,530 @@
# English translations for PROJECT.
# Copyright (C) 2025 ORGANIZATION
# This file is distributed under the same license as the PROJECT project.
# FIRST AUTHOR <EMAIL@ADDRESS>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-26 03:16+0300\n"
"PO-Revision-Date: 2025-07-26 03:24+0300\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language: en\n"
"Language-Team: en <LL@li.org>\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:46
msgid "Please log in to access this page."
msgstr ""
#: app.py:114
msgid "Invalid username or password"
msgstr ""
#: app.py:117
msgid "Login successful!"
msgstr ""
#: app.py:130
msgid "Username and password are required."
msgstr ""
#: app.py:133
msgid "Username already exists."
msgstr ""
#: app.py:142
msgid "Registration successful! Please log in."
msgstr ""
#: app.py:148
msgid "An error occurred during registration. Please try again."
msgstr ""
#: app.py:157
msgid "You have been logged out."
msgstr ""
#: app.py:189
msgid "Password is required for the first time."
msgstr ""
#: app.py:193
msgid "Host and Login fields must be filled."
msgstr ""
#: app.py:211
#, python-format
msgid "Successfully authorized on RMS server. Received %(num)s presets."
msgstr ""
#: app.py:214
msgid "Authorization error on RMS server. Check host, login or password."
msgstr ""
#: app.py:219
#, python-format
msgid "Error configuring RMS: %(error)s"
msgstr ""
#: app.py:229
msgid "No file was selected."
msgstr ""
#: app.py:248
msgid "Could not find client_email in the credentials file."
msgstr ""
#: app.py:258
#, python-format
msgid "Credentials file successfully uploaded. Email: %(email)s"
msgstr ""
#: app.py:262
msgid "Error: Uploaded file is not a valid JSON."
msgstr ""
#: app.py:267
#, python-format
msgid "Error processing credentials: %(error)s"
msgstr ""
#: app.py:282
msgid "Sheet URL must be provided."
msgstr ""
#: app.py:289
msgid "Please upload a valid credentials file first."
msgstr ""
#: app.py:300
#, python-format
msgid ""
"Successfully connected to Google Sheets. Found %(num)s sheets. Settings "
"saved."
msgstr ""
#: app.py:307
#, python-format
msgid ""
"Error connecting to Google Sheets: %(error)s. Check the URL and service "
"account permissions."
msgstr ""
#: app.py:333
msgid "Mappings updated successfully."
msgstr ""
#: app.py:338
#, python-format
msgid "Error updating mappings: %(error)s"
msgstr ""
#: app.py:354
msgid "Error: Could not determine which sheet to render the report for."
msgstr ""
#: app.py:361
#, python-format
msgid "Error: No report is assigned to sheet \"%(sheet)s\"."
msgstr ""
#: app.py:366
msgid "Error: RMS or Google Sheets configuration is incomplete."
msgstr ""
#: app.py:371
#, python-format
msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration."
msgstr ""
#: app.py:387
#, python-format
msgid "Error: Unexpected response format from RMS for report \"%(name)s\"."
msgstr ""
#: app.py:400
#, python-format
msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"."
msgstr ""
#: app.py:402
#, python-format
msgid ""
"Report \"%(name)s\" returned no data for the selected period. Sheet "
"\"%(sheet)s\" has been cleared."
msgstr ""
#: app.py:404
msgid "Error authorizing on RMS server when trying to get a report."
msgstr ""
#: app.py:407
#, python-format
msgid "Data Error: %(error)s"
msgstr ""
#: app.py:410
#, python-format
msgid ""
"Google API Error accessing sheet \"%(sheet)s\". Check service account "
"permissions."
msgstr ""
#: app.py:413
#, python-format
msgid "An unexpected error occurred: %(error)s"
msgstr ""
#: templates/index.html:6
msgid "MyHoreca OLAPer"
msgstr ""
#: templates/index.html:11
msgid "MyHoreca OLAP-to-GoogleSheets"
msgstr ""
#: templates/index.html:15
msgid "Logged in as:"
msgstr ""
#: templates/index.html:16
msgid "Logout"
msgstr ""
#: templates/index.html:18
msgid "Русский"
msgstr ""
#: templates/index.html:19
msgid "English"
msgstr ""
#: templates/index.html:24 templates/login.html:4 templates/login.html:13
#: templates/login.html:29
msgid "Login"
msgstr ""
#: templates/index.html:25 templates/register.html:4 templates/register.html:13
#: templates/register.html:26
msgid "Register"
msgstr ""
#: templates/index.html:41
msgid "Connection to RMS-server"
msgstr ""
#: templates/index.html:43
msgid "RMS Server Configuration"
msgstr ""
#: templates/index.html:45
msgid ""
"Enter the details for your RMS server API. This information is used to "
"connect,\n"
" authenticate, and retrieve the list of available OLAP report "
"presets."
msgstr ""
#: templates/index.html:49
msgid "RMS-host (e.g., http://your-rms-api.com/resto):"
msgstr ""
#: templates/index.html:52
msgid "API Login:"
msgstr ""
#: templates/index.html:55
msgid "API Password:"
msgstr ""
#: templates/index.html:58
msgid "Password is saved. Enter a new one only if you need to change it."
msgstr ""
#: templates/index.html:60
msgid "Enter the API password for your RMS server."
msgstr ""
#: templates/index.html:63
msgid "Check and Save RMS-config"
msgstr ""
#: templates/index.html:66 templates/index.html:68 templates/index.html:116
#: templates/index.html:118
msgid "Status:"
msgstr ""
#: templates/index.html:66
#, python-format
msgid "Successfully connected to RMS. Found %(num)s OLAP presets."
msgstr ""
#: templates/index.html:68
msgid "RMS configuration saved. Presets not yet loaded or connection failed."
msgstr ""
#: templates/index.html:73
msgid "Configure RMS first"
msgstr ""
#: templates/index.html:74 templates/index.html:77
msgid "Google Sheets Configuration"
msgstr ""
#: templates/index.html:79
msgid ""
"To allow the application to write to your Google Sheet, you need to "
"provide\n"
" credentials for a Google Service Account. This account will act"
" on behalf\n"
" of the application."
msgstr ""
#: templates/index.html:84
msgid "How to get credentials:"
msgstr ""
#: templates/index.html:85
msgid "Go to Google Cloud Console."
msgstr ""
#: templates/index.html:86
msgid "Create a new project or select an existing one."
msgstr ""
#: templates/index.html:87
msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project."
msgstr ""
#: templates/index.html:88
msgid ""
"Go to \"Credentials\", click \"Create Credentials\", choose \"Service "
"Account\"."
msgstr ""
#: templates/index.html:89
msgid "Give it a name and grant it the \"Editor\" role."
msgstr ""
#: templates/index.html:90
msgid "Create a JSON key for the service account and download the file."
msgstr ""
#: templates/index.html:91
msgid ""
"Share your target Google Sheet with the service account's email address "
"(found in the downloaded JSON file, key `client_email`)."
msgstr ""
#: templates/index.html:94
msgid "Service Account Credentials (JSON file):"
msgstr ""
#: templates/index.html:97
msgid "Current Service Account Email:"
msgstr ""
#: templates/index.html:98
msgid "Upload a new file only if you need to change credentials."
msgstr ""
#: templates/index.html:100
msgid "Upload the JSON file downloaded from Google Cloud Console."
msgstr ""
#: templates/index.html:102
msgid "Upload Credentials"
msgstr ""
#: templates/index.html:106
msgid ""
"Enter the URL of the Google Sheet you want to use. The service account "
"email\n"
" (shown above after uploading credentials) must have edit "
"access to this sheet."
msgstr ""
#: templates/index.html:110
msgid "Google Sheet URL:"
msgstr ""
#: templates/index.html:112
msgid "Upload Service Account Credentials first"
msgstr ""
#: templates/index.html:113
msgid "Connect Google Sheets"
msgstr ""
#: templates/index.html:116
#, python-format
msgid "Successfully connected to Google Sheet. Found %(num)s worksheets."
msgstr ""
#: templates/index.html:118
msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed."
msgstr ""
#: templates/index.html:124
msgid "Configure RMS and Google Sheets first"
msgstr ""
#: templates/index.html:125
msgid "Mapping Sheets to OLAP Reports"
msgstr ""
#: templates/index.html:128
msgid "Map Worksheets to OLAP Reports"
msgstr ""
#: templates/index.html:130
msgid ""
"Select which OLAP report from RMS should be rendered into each specific "
"worksheet\n"
" (tab) in your Google Sheet."
msgstr ""
#: templates/index.html:138
msgid "Worksheet (Google Sheets)"
msgstr ""
#: templates/index.html:139
msgid "OLAP-report (RMS)"
msgstr ""
#: templates/index.html:148
msgid "Not set"
msgstr ""
#: templates/index.html:160
msgid "Save Mappings"
msgstr ""
#: templates/index.html:163
msgid ""
"Worksheets and OLAP presets are not loaded. Please configure RMS and "
"Google Sheets first."
msgstr ""
#: templates/index.html:165
msgid "Worksheets are not loaded. Check Google Sheets configuration."
msgstr ""
#: templates/index.html:167
msgid "OLAP presets are not loaded. Check RMS configuration."
msgstr ""
#: templates/index.html:172
msgid "Configure Mappings first"
msgstr ""
#: templates/index.html:173
msgid "Render Reports to Sheets"
msgstr ""
#: templates/index.html:176
msgid "Render Reports"
msgstr ""
#: templates/index.html:178
msgid ""
"Select the date range and click \"Render to sheet\" for each mapping you "
"wish to execute.\n"
" The application will retrieve the OLAP data from RMS for the "
"selected report and period,\n"
" clear the corresponding worksheet in Google Sheets, and write "
"the new data."
msgstr ""
#: templates/index.html:184
msgid "From Date:"
msgstr ""
#: templates/index.html:187
msgid "To Date:"
msgstr ""
#: templates/index.html:193
msgid "Worksheet"
msgstr ""
#: templates/index.html:194
msgid "Mapped OLAP Report"
msgstr ""
#: templates/index.html:195
msgid "Action"
msgstr ""
#: templates/index.html:203
msgid "ID: "
msgstr ""
#: templates/index.html:206
msgid "Unnamed Preset"
msgstr ""
#: templates/index.html:214
msgid "Render to sheet"
msgstr ""
#: templates/index.html:224
msgid "No mappings configured yet."
msgstr ""
#: templates/index.html:225
msgid ""
"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to "
"set up mappings."
msgstr ""
#: templates/index.html:248
msgid "Please,"
msgstr ""
#: templates/index.html:248
msgid "login"
msgstr ""
#: templates/index.html:248
msgid "or"
msgstr ""
#: templates/index.html:248
msgid "register"
msgstr ""
#: templates/login.html:22 templates/register.html:22
msgid "Username:"
msgstr ""
#: templates/login.html:24 templates/register.html:24
msgid "Password:"
msgstr ""
#: templates/login.html:27
msgid "Remember Me"
msgstr ""
#: templates/login.html:31
msgid "Don't have an account?"
msgstr ""
#: templates/login.html:31
msgid "Register here"
msgstr ""
#: templates/register.html:28
msgid "Already have an account?"
msgstr ""
#: templates/register.html:28
msgid "Login here"
msgstr ""

Binary file not shown.

View File

@@ -0,0 +1,556 @@
# Шаблон перевода для olaper.
# Copyright (C) 2025 SERTY
# Этот файл распространяется на условиях той же лицензии, что и проект PROJECT.
# FIRST AUTHOR <serty2005@gmail.com>, 2025.
#
msgid ""
msgstr ""
"Project-Id-Version: PROJECT VERSION\n"
"Report-Msgid-Bugs-To: EMAIL@ADDRESS\n"
"POT-Creation-Date: 2025-07-26 03:16+0300\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Generated-By: Babel 2.17.0\n"
#: app.py:46
msgid "Please log in to access this page."
msgstr "Пожалуйста, войдите для доступа к этой странице."
#: app.py:114
msgid "Invalid username or password"
msgstr "Неверное имя пользователя или пароль"
#: app.py:117
msgid "Login successful!"
msgstr "Вход выполнен успешно!"
#: app.py:130
msgid "Username and password are required."
msgstr "Имя пользователя и пароль обязательны."
#: app.py:133
msgid "Username already exists."
msgstr "Имя пользователя уже существует."
#: app.py:142
msgid "Registration successful! Please log in."
msgstr "Регистрация успешна! Пожалуйста, войдите."
#: app.py:148
msgid "An error occurred during registration. Please try again."
msgstr "Произошла ошибка при регистрации. Пожалуйста, попробуйте снова."
#: app.py:157
msgid "You have been logged out."
msgstr "Вы вышли из системы."
#: app.py:189
msgid "Password is required for the first time."
msgstr "Пароль обязателен при первом подключении."
#: app.py:193
msgid "Host and Login fields must be filled."
msgstr "Поля Хост и Логин должны быть заполнены."
#: app.py:211
#, python-format
msgid "Successfully authorized on RMS server. Received %(num)s presets."
msgstr "Успешная авторизация на сервере RMS. Получено %(num)s пресетов."
#: app.py:214
msgid "Authorization error on RMS server. Check host, login or password."
msgstr "Ошибка авторизации на сервере RMS. Проверьте хост, логин или пароль."
#: app.py:219
#, python-format
msgid "Error configuring RMS: %(error)s"
msgstr "Ошибка настройки RMS: %(error)s"
#: app.py:229
msgid "No file was selected."
msgstr "Файл не был выбран."
#: app.py:248
msgid "Could not find client_email in the credentials file."
msgstr "Не удалось найти client_email в файле учетных данных."
#: app.py:258
#, python-format
msgid "Credentials file successfully uploaded. Email: %(email)s"
msgstr "Файл учетных данных успешно загружен. Email: %(email)s"
#: app.py:262
msgid "Error: Uploaded file is not a valid JSON."
msgstr "Ошибка: Загруженный файл не является валидным JSON."
#: app.py:267
#, python-format
msgid "Error processing credentials: %(error)s"
msgstr "Ошибка обработки учетных данных: %(error)s"
#: app.py:282
msgid "Sheet URL must be provided."
msgstr "Необходимо указать URL таблицы."
#: app.py:289
msgid "Please upload a valid credentials file first."
msgstr "Пожалуйста, сначала загрузите валидный файл учетных данных."
#: app.py:300
#, python-format
msgid ""
"Successfully connected to Google Sheets. Found %(num)s sheets. Settings "
"saved."
msgstr "Успешное подключение к Google Таблицам. Найдено %(num)s листов. Настройки сохранены."
#: app.py:307
#, python-format
msgid ""
"Error connecting to Google Sheets: %(error)s. Check the URL and service "
"account permissions."
msgstr "Ошибка подключения к Google Таблицам: %(error)s. Проверьте URL и права сервисного аккаунта."
#: app.py:333
msgid "Mappings updated successfully."
msgstr "Привязки успешно обновлены."
#: app.py:338
#, python-format
msgid "Error updating mappings: %(error)s"
msgstr "Ошибка обновления привязок: %(error)s"
#: app.py:354
msgid "Error: Could not determine which sheet to render the report for."
msgstr "Ошибка: Не удалось определить, на какой лист выводить отчет."
#: app.py:361
#, python-format
msgid "Error: No report is assigned to sheet \"%(sheet)s\"."
msgstr "Ошибка: Нет отчета, привязанного к листу \"%(sheet)s\"."
#: app.py:366
msgid "Error: RMS or Google Sheets configuration is incomplete."
msgstr "Ошибка: Настройка RMS или Google Таблиц не завершена."
#: app.py:371
#, python-format
msgid "Error: Preset with ID \"%(id)s\" not found in saved configuration."
msgstr "Ошибка: Пресет с ID \"%(id)s\" не найден в сохраненной конфигурации."
#: app.py:387
#, python-format
msgid "Error: Unexpected response format from RMS for report \"%(name)s\"."
msgstr "Ошибка: Неожиданный формат ответа от RMS для отчета \"%(name)s\"."
#: app.py:400
#, python-format
msgid "Report \"%(name)s\" data successfully written to sheet \"%(sheet)s\"."
msgstr "Данные отчета \"%(name)s\" успешно записаны на лист \"%(sheet)s\"."
#: app.py:402
#, python-format
msgid ""
"Report \"%(name)s\" returned no data for the selected period. Sheet "
"\"%(sheet)s\" has been cleared."
msgstr "Отчет \"%(name)s\" не вернул данных за выбранный период. Лист \"%(sheet)s\" был очищен."
#: app.py:404
msgid "Error authorizing on RMS server when trying to get a report."
msgstr "Ошибка авторизации на сервере RMS при попытке получить отчет."
#: app.py:407
#, python-format
msgid "Data Error: %(error)s"
msgstr "Ошибка данных: %(error)s"
#: app.py:410
#, python-format
msgid ""
"Google API Error accessing sheet \"%(sheet)s\". Check service account "
"permissions."
msgstr "Ошибка Google API при доступе к листу \"%(sheet)s\". Проверьте права сервисного аккаунта."
#: app.py:413
#, python-format
msgid "An unexpected error occurred: %(error)s"
msgstr "Произошла непредвиденная ошибка: %(error)s"
#: templates/index.html:6
msgid "MyHoreca OLAPer"
msgstr "MyHoreca OLAPer"
#: templates/index.html:11
msgid "MyHoreca OLAP-to-GoogleSheets"
msgstr "MyHoreca OLAP в Google Таблицы"
#: templates/index.html:15
msgid "Logged in as:"
msgstr "Вход выполнен как:"
#: templates/index.html:16
msgid "Logout"
msgstr "Выйти"
#: templates/index.html:18
msgid "Русский"
msgstr "Русский"
#: templates/index.html:19
msgid "English"
msgstr "English"
#: templates/index.html:24 templates/login.html:4 templates/login.html:13
#: templates/login.html:29
msgid "Login"
msgstr "Вход"
#: templates/index.html:25 templates/register.html:4 templates/register.html:13
#: templates/register.html:26
msgid "Register"
msgstr "Регистрация"
#: templates/index.html:41
msgid "Connection to RMS-server"
msgstr "Подключение к RMS-серверу"
#: templates/index.html:43
msgid "RMS Server Configuration"
msgstr "Настройка RMS сервера"
#: templates/index.html:45
msgid ""
"Enter the details for your RMS server API. This information is used to "
"connect,\n"
" authenticate, and retrieve the list of available OLAP report "
"presets."
msgstr ""
"Введите данные для API вашего RMS сервера. Эта информация используется для "
"подключения,\n"
" аутентификации и получения списка доступных пресетов OLAP отчетов."
#: templates/index.html:49
msgid "RMS-host (e.g., http://your-rms-api.com/resto):"
msgstr "RMS-хост (например, http://your-rms-api.com/resto):"
#: templates/index.html:52
msgid "API Login:"
msgstr "API Логин:"
#: templates/index.html:55
msgid "API Password:"
msgstr "API Пароль:"
#: templates/index.html:58
msgid "Password is saved. Enter a new one only if you need to change it."
msgstr "Пароль сохранен. Введите новый только если нужно его изменить."
#: templates/index.html:60
msgid "Enter the API password for your RMS server."
msgstr "Введите API пароль для вашего RMS сервера."
#: templates/index.html:63
msgid "Check and Save RMS-config"
msgstr "Проверить и сохранить RMS-конфиг"
#: templates/index.html:66 templates/index.html:68 templates/index.html:116
#: templates/index.html:118
msgid "Status:"
msgstr "Статус:"
#: templates/index.html:66
#, python-format
msgid "Successfully connected to RMS. Found %(num)s OLAP presets."
msgstr "Успешное подключение к RMS. Найдено %(num)s OLAP пресетов."
#: templates/index.html:68
msgid "RMS configuration saved. Presets not yet loaded or connection failed."
msgstr "Конфигурация RMS сохранена. Пресеты еще не загружены или подключение не удалось."
#: templates/index.html:73
msgid "Configure RMS first"
msgstr "Сначала настройте RMS"
#: templates/index.html:74 templates/index.html:77
msgid "Google Sheets Configuration"
msgstr "Настройка Google Таблиц"
#: templates/index.html:79
msgid ""
"To allow the application to write to your Google Sheet, you need to "
"provide\n"
" credentials for a Google Service Account. This account will act"
" on behalf\n"
" of the application."
msgstr ""
"Чтобы разрешить приложению запись в вашу Google Таблицу, необходимо "
"предоставить\n"
" учетные данные Google Service Account. Этот аккаунт будет "
"действовать\n"
" от имени приложения."
#: templates/index.html:84
msgid "How to get credentials:"
msgstr "Как получить учетные данные:"
#: templates/index.html:85
msgid "Go to Google Cloud Console."
msgstr "Перейдите в Google Cloud Console."
#: templates/index.html:86
msgid "Create a new project or select an existing one."
msgstr "Создайте новый проект или выберите существующий."
#: templates/index.html:87
msgid "Enable the \"Google Sheets API\" and \"Google Drive API\" for the project."
msgstr "Включите \"Google Sheets API\" и \"Google Drive API\" для проекта."
#: templates/index.html:88
msgid ""
"Go to \"Credentials\", click \"Create Credentials\", choose \"Service "
"Account\"."
msgstr ""
"Перейдите в \"Credentials\", нажмите \"Create Credentials\", выберите "
"\"Service Account\"."
#: templates/index.html:89
msgid "Give it a name and grant it the \"Editor\" role."
msgstr "Дайте ему имя и назначьте роль \"Editor\"."
#: templates/index.html:90
msgid "Create a JSON key for the service account and download the file."
msgstr "Создайте JSON ключ для сервисного аккаунта и скачайте файл."
#: templates/index.html:91
msgid ""
"Share your target Google Sheet with the service account's email address "
"(found in the downloaded JSON file, key `client_email`)."
msgstr ""
"Откройте доступ к вашей Google Таблице для email сервисного аккаунта "
"(указан в скачанном JSON файле, ключ `client_email`)."
#: templates/index.html:94
msgid "Service Account Credentials (JSON file):"
msgstr "Учетные данные сервисного аккаунта (JSON файл):"
#: templates/index.html:97
msgid "Current Service Account Email:"
msgstr "Текущий Email сервисного аккаунта:"
#: templates/index.html:98
msgid "Upload a new file only if you need to change credentials."
msgstr "Загружайте новый файл только если нужно изменить учетные данные."
#: templates/index.html:100
msgid "Upload the JSON file downloaded from Google Cloud Console."
msgstr "Загрузите JSON файл, скачанный из Google Cloud Console."
#: templates/index.html:102
msgid "Upload Credentials"
msgstr "Загрузить учетные данные"
#: templates/index.html:106
msgid ""
"Enter the URL of the Google Sheet you want to use. The service account "
"email\n"
" (shown above after uploading credentials) must have edit "
"access to this sheet."
msgstr ""
"Введите URL Google Таблицы, которую вы хотите использовать. Email "
"сервисного аккаунта\n"
" (показан выше после загрузки учетных данных) должен иметь "
"права на редактирование этой таблицы."
#: templates/index.html:110
msgid "Google Sheet URL:"
msgstr "URL Google Таблицы:"
#: templates/index.html:112
msgid "Upload Service Account Credentials first"
msgstr "Сначала загрузите учетные данные сервисного аккаунта"
#: templates/index.html:113
msgid "Connect Google Sheets"
msgstr "Подключить Google Таблицы"
#: templates/index.html:116
#, python-format
msgid "Successfully connected to Google Sheet. Found %(num)s worksheets."
msgstr "Успешное подключение к Google Таблице. Найдено %(num)s листов."
#: templates/index.html:118
msgid "Google Sheet URL saved. Worksheets not yet loaded or connection failed."
msgstr "URL Google Таблицы сохранен. Листы еще не загружены или подключение не удалось."
#: templates/index.html:124
msgid "Configure RMS and Google Sheets first"
msgstr "Сначала настройте RMS и Google Таблицы"
#: templates/index.html:125
msgid "Mapping Sheets to OLAP Reports"
msgstr "Привязка листов к OLAP отчетам"
#: templates/index.html:128
msgid "Map Worksheets to OLAP Reports"
msgstr "Сопоставить листы с OLAP отчетами"
#: templates/index.html:130
msgid ""
"Select which OLAP report from RMS should be rendered into each specific "
"worksheet\n"
" (tab) in your Google Sheet."
msgstr ""
"Выберите, какой OLAP отчет из RMS должен выводиться на каждый конкретный "
"лист\n"
" (вкладку) в вашей Google Таблице."
#: templates/index.html:138
msgid "Worksheet (Google Sheets)"
msgstr "Лист (Google Таблицы)"
#: templates/index.html:139
msgid "OLAP-report (RMS)"
msgstr "OLAP-отчет (RMS)"
#: templates/index.html:148
msgid "Not set"
msgstr "Не задано"
#: templates/index.html:160
msgid "Save Mappings"
msgstr "Сохранить привязки"
#: templates/index.html:163
msgid ""
"Worksheets and OLAP presets are not loaded. Please configure RMS and "
"Google Sheets first."
msgstr ""
"Листы и OLAP пресеты не загружены. Пожалуйста, сначала настройте RMS и "
"Google Таблицы."
#: templates/index.html:165
msgid "Worksheets are not loaded. Check Google Sheets configuration."
msgstr "Листы не загружены. Проверьте настройку Google Таблиц."
#: templates/index.html:167
msgid "OLAP presets are not loaded. Check RMS configuration."
msgstr "OLAP пресеты не загружены. Проверьте настройку RMS."
#: templates/index.html:172
msgid "Configure Mappings first"
msgstr "Сначала настройте привязки"
#: templates/index.html:173
msgid "Render Reports to Sheets"
msgstr "Вывод отчетов в листы"
#: templates/index.html:176
msgid "Render Reports"
msgstr "Сформировать отчеты"
#: templates/index.html:178
msgid ""
"Select the date range and click \"Render to sheet\" for each mapping you "
"wish to execute.\n"
" The application will retrieve the OLAP data from RMS for the "
"selected report and period,\n"
" clear the corresponding worksheet in Google Sheets, and write "
"the new data."
msgstr ""
"Выберите диапазон дат и нажмите \"Вывести на лист\" для каждой привязки, "
"которую хотите выполнить.\n"
" Приложение получит OLAP данные из RMS для выбранного отчета и "
"периода,\n"
" очистит соответствующий лист в Google Таблицах и запишет новые "
"данные."
#: templates/index.html:184
msgid "From Date:"
msgstr "С даты:"
#: templates/index.html:187
msgid "To Date:"
msgstr "По дату:"
#: templates/index.html:193
msgid "Worksheet"
msgstr "Лист"
#: templates/index.html:194
msgid "Mapped OLAP Report"
msgstr "Привязанный OLAP отчет"
#: templates/index.html:195
msgid "Action"
msgstr "Действие"
#: templates/index.html:203
msgid "ID: "
msgstr "ID: "
#: templates/index.html:206
msgid "Unnamed Preset"
msgstr "Безымянный пресет"
#: templates/index.html:214
msgid "Render to sheet"
msgstr "Вывести на лист"
#: templates/index.html:224
msgid "No mappings configured yet."
msgstr "Привязки еще не настроены."
#: templates/index.html:225
msgid ""
"Please go to the \"Mapping Sheets to OLAP Reports\" section (Step 3) to "
"set up mappings."
msgstr ""
"Пожалуйста, перейдите в раздел \"Привязка листов к OLAP отчетам\" (Шаг "
"3) для настройки привязок."
#: templates/index.html:248
msgid "Please,"
msgstr "Пожалуйста,"
#: templates/index.html:248
msgid "login"
msgstr "войдите"
#: templates/index.html:248
msgid "or"
msgstr "или"
#: templates/index.html:248
msgid "register"
msgstr "зарегистрируйтесь"
#: templates/login.html:22 templates/register.html:22
msgid "Username:"
msgstr "Имя пользователя:"
#: templates/login.html:24 templates/register.html:24
msgid "Password:"
msgstr "Пароль:"
#: templates/login.html:27
msgid "Remember Me"
msgstr "Запомнить меня"
#: templates/login.html:31
msgid "Don't have an account?"
msgstr "Нет аккаунта?"
#: templates/login.html:31
msgid "Register here"
msgstr "Зарегистрируйтесь здесь"
#: templates/register.html:28
msgid "Already have an account?"
msgstr "Уже есть аккаунт?"
#: templates/register.html:28
msgid "Login here"
msgstr "Войдите здесь"

186
utils.py
View File

@@ -1,55 +1,49 @@
import json
import logging
from jinja2 import Template
from datetime import datetime
from datetime import datetime, date, timedelta
from dateutil.relativedelta import relativedelta
# Настройка логирования
logger = logging.getLogger(__name__)
# Уровень логирования уже должен быть настроен в app.py или основном модуле
# logger.setLevel(logging.DEBUG) # Можно убрать, если настраивается глобально
# Функция load_temps удалена, так как пресеты загружаются из API RMS
def generate_template_from_preset(preset):
"""
Генерирует один шаблон запроса OLAP на основе пресета,
подставляя плейсхолдеры для дат в соответствующий фильтр.
Генерирует один шаблон запроса OLAP на основе пресета из RMS API.
Функция заменяет существующие фильтры по дате на универсальные плейсхолдеры
Jinja2 (`{{ from_date }}` и `{{ to_date }}`).
Args:
preset (dict): Словарь с пресетом OLAP-отчета из API RMS.
Returns:
dict: Словарь, представляющий шаблон запроса OLAP, готовый для рендеринга.
Возвращает None, если входной preset некорректен.
Raises:
ValueError: Если preset не является словарем или не содержит необходимых ключей.
ValueError: Если пресет некорректен (не словарь или отсутствуют ключи).
Exception: Другие непредвиденные ошибки при обработке.
"""
if not isinstance(preset, dict):
logger.error("Ошибка генерации шаблона: входной 'preset' не является словарем.")
raise ValueError("Preset должен быть словарем.")
if not all(k in preset for k in ["reportType", "groupByRowFields", "aggregateFields", "filters"]):
logger.error(f"Ошибка генерации шаблона: пресет {preset.get('id', 'N/A')} не содержит всех необходимых ключей.")
raise ValueError("Пресет не содержит необходимых ключей (reportType, groupByRowFields, aggregateFields, filters).")
raise ValueError("Пресет должен быть словарем.")
required_keys = ["reportType", "groupByRowFields", "aggregateFields", "filters"]
if not all(k in preset for k in required_keys):
logger.error(f"Ошибка генерации шаблона: пресет {preset.get('id', 'N/A')} не содержит необходимых ключей.")
raise ValueError("Пресет не содержит ключей: reportType, groupByRowFields, aggregateFields, filters.")
try:
# Копируем основные поля из пресета
template = {
"reportType": preset["reportType"],
"groupByRowFields": preset.get("groupByRowFields", []), # Используем get для необязательных полей
"aggregateFields": preset.get("aggregateFields", []),
"filters": preset.get("filters", {}) # Работаем с копией фильтров
}
# Создаем глубокую копию, чтобы не изменять оригинальный объект пресета
template = json.loads(json.dumps(preset))
# --- Обработка фильтров дат ---
# Создаем копию словаря фильтров, чтобы безопасно удалять элементы
current_filters = dict(template.get("filters", {})) # Используем get с default
# Удаляем ненужные для запроса поля, которые приходят из API
template.pop('id', None)
template.pop('name', None)
current_filters = template.get("filters", {})
filters_to_remove = []
date_filter_found_and_modified = False
# Сначала найдем и удалим все существующие фильтры типа DateRange
# Находим и запоминаем все существующие фильтры типа DateRange для удаления
for key, value in current_filters.items():
if isinstance(value, dict) and value.get("filterType") == "DateRange":
filters_to_remove.append(key)
@@ -58,28 +52,23 @@ def generate_template_from_preset(preset):
del current_filters[key]
logger.debug(f"Удален существующий DateRange фильтр '{key}' из пресета {preset.get('id', 'N/A')}.")
# Теперь добавляем правильный фильтр дат в зависимости от типа отчета
# Определяем правильный ключ для фильтра по дате на основе типа отчета
report_type = template["reportType"]
date_filter_key = None
if report_type in ["SALES", "DELIVERIES"]:
# Для отчетов SALES и DELIVERIES используем "OpenDate.Typed"
# См. https://ru.iiko.help/articles/api-documentations/olap-2/a/h3__951638809
# Для отчетов по продажам и доставкам используется "OpenDate.Typed"
date_filter_key = "OpenDate.Typed"
logger.debug(f"Для отчета {report_type} ({preset.get('id', 'N/A')}) будет использован фильтр '{date_filter_key}'.")
current_filters[date_filter_key] = {
"filterType": "DateRange",
"from": "{{ from_date }}",
"to": "{{ to_date }}",
"includeLow": True,
"includeHigh": True
}
date_filter_found_and_modified = True # Считаем, что мы успешно добавили нужный фильтр
elif report_type == "TRANSACTIONS":
# Для отчетов по проводкам (TRANSACTIONS) используем "DateTime.DateTyped"
# См. комментарий пользователя и общие практики iiko API
# Для отчетов по проводкам используется "DateTime.DateTyped"
date_filter_key = "DateTime.DateTyped"
logger.debug(f"Для отчета {report_type} ({preset.get('id', 'N/A')}) будет использован фильтр '{date_filter_key}'.")
else:
logger.warning(
f"Для типа отчета '{report_type}' (пресет {preset.get('id', 'N/A')}) нет стандартного ключа даты. "
f"Фильтр по дате не будет добавлен автоматически."
)
if date_filter_key:
logger.debug(f"Для отчета {report_type} будет использован фильтр '{date_filter_key}'.")
current_filters[date_filter_key] = {
"filterType": "DateRange",
"from": "{{ from_date }}",
@@ -87,73 +76,46 @@ def generate_template_from_preset(preset):
"includeLow": True,
"includeHigh": True
}
date_filter_found_and_modified = True # Считаем, что мы успешно добавили нужный фильтр
else:
# Для ВСЕХ ОСТАЛЬНЫХ типов отчетов:
# Пытаемся найти *любой* ключ, который может содержать дату (логика по умолчанию).
# Это менее надежно, чем явное указание ключей для SALES/DELIVERIES/TRANSACTIONS.
# Если в пресете для других типов отчетов нет стандартного поля даты,
# или оно называется иначе, этот блок может не сработать корректно.
# Мы уже удалили все DateRange фильтры. Если для этого типа отчета
# нужен был какой-то специфический DateRange фильтр, он был удален.
# Это потенциальная проблема, если неизвестные типы отчетов полагаются
# на предопределенные DateRange фильтры с другими ключами.
# Пока оставляем так: если тип отчета неизвестен, DateRange фильтр не добавляется.
logger.warning(f"Для неизвестного типа отчета '{report_type}' ({preset.get('id', 'N/A')}) не удалось автоматически определить стандартный ключ фильтра даты. "
f"Фильтр по дате не будет добавлен автоматически. Если он нужен, пресет должен содержать его с другим filterType или его нужно добавить вручную.")
# В этом случае date_filter_found_and_modified останется False
# Обновляем фильтры в шаблоне
template["filters"] = current_filters
# Логируем результат
if date_filter_found_and_modified:
logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.")
else:
logger.warning(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') сгенерирован, но фильтр даты не был добавлен/модифицирован (тип отчета: {report_type}).")
template["filters"] = current_filters
return template
except Exception as e:
logger.error(f"Непредвиденная ошибка при генерации шаблона из пресета {preset.get('id', 'N/A')}: {str(e)}", exc_info=True)
raise # Перевыбрасываем ошибку
raise
def render_temp(template_dict, context):
"""
Рендерит шаблон (представленный словарем) с использованием Jinja2.
Рендерит шаблон (представленный словарем) с использованием Jinja2,
подставляя значения из контекста (например, даты).
Args:
template_dict (dict): Словарь, представляющий шаблон OLAP-запроса.
template_dict (dict): Словарь-шаблон OLAP-запроса.
context (dict): Словарь с переменными для рендеринга (например, {'from_date': '...', 'to_date': '...'}).
Returns:
dict: Словарь с отрендеренным OLAP-запросом.
dict: Словарь с отрендеренным OLAP-запросом, готовый к отправке.
Raises:
Exception: Ошибки при рендеринге или парсинге JSON.
"""
try:
# Преобразуем словарь шаблона в строку JSON для Jinja
# Преобразуем словарь шаблона в строку JSON
template_str = json.dumps(template_dict)
# Рендерим строку с помощью Jinja
# Рендерим строку с помощью Jinja, подставляя переменные из context
rendered_str = Template(template_str).render(context)
# Преобразуем отрендеренную строку обратно в словарь Python
rendered_dict = json.loads(rendered_str)
logger.info('Шаблон OLAP-запроса успешно отрендерен.')
logger.info('Шаблон OLAP-запроса успешно отрендерен с датами.')
return rendered_dict
except Exception as e:
logger.error(f"Ошибка рендеринга шаблона: {str(e)}", exc_info=True)
raise
def get_dates(start_date, end_date):
"""
Проверяет даты на корректность и формат YYYY-MM-DD.
Проверяет и форматирует даты.
Args:
start_date (str): Дата начала в формате 'YYYY-MM-DD'.
@@ -169,8 +131,8 @@ def get_dates(start_date, end_date):
try:
start = datetime.strptime(start_date, date_format)
end = datetime.strptime(end_date, date_format)
except ValueError:
logger.error(f"Некорректный формат дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.")
except (ValueError, TypeError):
logger.error(f"Некорректный формат или тип дат: start='{start_date}', end='{end_date}'. Ожидается YYYY-MM-DD.")
raise ValueError("Некорректный формат даты. Используйте YYYY-MM-DD.")
if start > end:
@@ -178,3 +140,63 @@ def get_dates(start_date, end_date):
raise ValueError("Дата начала не может быть позже даты окончания.")
return start_date, end_date
def _parse_cron_string(cron_str):
"""Парсит строку cron в словарь для APScheduler."""
if not isinstance(cron_str, str):
raise TypeError("cron_str must be a string.")
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)}
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}")