Compare commits

..

2 Commits

Author SHA1 Message Date
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
18 changed files with 2435 additions and 931 deletions

603
app.py
View File

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

11
extensions.py Normal file
View File

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

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

361
routes.py Normal file
View File

@@ -0,0 +1,361 @@
# routes.py
import os
import json
import shutil
from flask import (
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app
)
from flask_login import login_user, login_required, logout_user, current_user
from flask_babel import _
from werkzeug.utils import secure_filename
import gspread
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
# Импортируем экземпляры расширений, созданные в app.py
from extensions import db, login_manager
# Импортируем наши классы и утилиты
from models import User, UserConfig
from google_sheets import GoogleSheets
from request_module import ReqModule
from utils import get_dates, generate_template_from_preset, render_temp
# --- Создание блюпринта ---
main_bp = Blueprint('main', __name__)
# --- Регистрация обработчиков для расширений ---
@login_manager.user_loader
def load_user(user_id):
"""Загружает пользователя из БД для управления сессией."""
return db.session.get(User, int(user_id))
@main_bp.before_app_request
def load_user_specific_data():
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
g.user_config = None
if current_user.is_authenticated:
g.user_config = get_user_config()
# --- Вспомогательные функции, специфичные для маршрутов ---
def get_user_config():
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
if not current_user.is_authenticated:
return None
config = UserConfig.query.filter_by(user_id=current_user.id).first()
if not config:
config = UserConfig(user_id=current_user.id)
db.session.add(config)
return config
def get_user_upload_path(filename=""):
"""Возвращает путь для загрузки файлов для текущего пользователя."""
if not current_user.is_authenticated:
return None
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
os.makedirs(user_dir, exist_ok=True)
return os.path.join(user_dir, secure_filename(filename))
# --- Маршруты ---
@main_bp.route('/language/<language>')
def set_language(language=None):
session['language'] = language
return redirect(request.referrer or url_for('.index'))
@main_bp.route('/login', methods=['GET', 'POST'])
def login():
if current_user.is_authenticated:
return redirect(url_for('.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
user = User.query.filter_by(username=username).first()
if user is None or not user.check_password(password):
flash(_('Invalid username or password'), 'error')
return redirect(url_for('.login'))
login_user(user, remember=request.form.get('remember'))
flash(_('Login successful!'), 'success')
next_page = request.args.get('next')
return redirect(next_page or url_for('.index'))
return render_template('login.html')
@main_bp.route('/register', methods=['GET', 'POST'])
def register():
if current_user.is_authenticated:
return redirect(url_for('.index'))
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if not username or not password:
flash(_('Username and password are required.'), 'error')
return redirect(url_for('.register'))
if User.query.filter_by(username=username).first():
flash(_('Username already exists.'), 'error')
return redirect(url_for('.register'))
user = User(username=username)
user.set_password(password)
user.config = UserConfig()
db.session.add(user)
try:
db.session.commit()
flash(_('Registration successful! Please log in.'), 'success')
return redirect(url_for('.login'))
except Exception as e:
db.session.rollback()
flash(_('An error occurred during registration. Please try again.'), 'error')
return redirect(url_for('.register'))
return render_template('register.html')
@main_bp.route('/logout')
@login_required
def logout():
logout_user()
flash(_('You have been logged out.'), 'success')
return redirect(url_for('.index'))
@main_bp.route('/')
@login_required
def index():
config = g.user_config
return render_template(
'index.html',
rms_config=config.get_rms_dict(),
google_config=config.get_google_dict(),
presets=config.presets,
sheets=config.sheets,
mappings=config.mappings,
client_email=config.google_client_email
)
@main_bp.route('/configure_rms', methods=['POST'])
@login_required
def configure_rms():
config = g.user_config
try:
host = request.form.get('host', '').strip()
login = request.form.get('login', '').strip()
password = request.form.get('password', '')
if not config.rms_password and not password:
flash(_('Password is required for the first time.'), 'error')
return redirect(url_for('.index'))
if not host or not login:
flash(_('Host and Login fields must be filled.'), 'error')
return redirect(url_for('.index'))
effective_password = password if password else config.rms_password
req_module = ReqModule(host, login, effective_password)
if req_module.login():
presets_data = req_module.take_presets()
req_module.logout()
config.rms_host = host
config.rms_login = login
if password:
config.rms_password = password
config.presets = presets_data
db.session.commit()
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
else:
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
except Exception as e:
db.session.rollback()
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
return redirect(url_for('.index'))
@main_bp.route('/upload_credentials', methods=['POST'])
@login_required
def upload_credentials():
config = g.user_config
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
flash(_('No file was selected.'), 'error')
return redirect(url_for('.index'))
cred_file = request.files['cred_file']
filename = cred_file.filename
# Получаем путь для сохранения файла в папке пользователя
user_cred_path = get_user_upload_path(filename)
temp_path = None
try:
# Сначала сохраняем файл во временную директорию для проверки
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
cred_file.save(temp_path)
with open(temp_path, 'r', encoding='utf-8') as f:
cred_data = json.load(f)
client_email = cred_data.get('client_email')
if not client_email:
flash(_('Could not find client_email in the credentials file.'), 'error')
# Не забываем удалить временный файл при ошибке
if os.path.exists(temp_path):
os.remove(temp_path)
return redirect(url_for('.index'))
# Если все хорошо, перемещаем файл из временной папки в постоянную
shutil.move(temp_path, user_cred_path)
# Сохраняем путь к файлу и email в базу данных
config.google_cred_file_path = user_cred_path
config.google_client_email = client_email
config.sheets = [] # Сбрасываем список листов при смене credentials
db.session.commit()
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
except json.JSONDecodeError:
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
except Exception as e:
db.session.rollback()
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
finally:
# Гарантированно удаляем временный файл, если он еще существует
if temp_path and os.path.exists(temp_path):
os.remove(temp_path)
return redirect(url_for('.index'))
@main_bp.route('/configure_google', methods=['POST'])
@login_required
def configure_google():
config = g.user_config
sheet_url = request.form.get('sheet_url', '').strip()
if not sheet_url:
flash(_('Sheet URL must be provided.'), 'error')
return redirect(url_for('.index'))
config.google_sheet_url = sheet_url
cred_path = config.google_cred_file_path
if not cred_path or not os.path.isfile(cred_path):
flash(_('Please upload a valid credentials file first.'), 'warning')
config.sheets = []
db.session.commit()
return redirect(url_for('.index'))
try:
gs_client = GoogleSheets(cred_path, sheet_url)
sheets_data = gs_client.get_sheets()
config.sheets = sheets_data
db.session.commit()
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
except Exception as e:
db.session.rollback()
config.sheets = []
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
try:
db.session.commit()
except Exception:
db.session.rollback()
return redirect(url_for('.index'))
@main_bp.route('/mapping_set', methods=['POST'])
@login_required
def mapping_set():
config = g.user_config
try:
new_mappings = {}
for sheet in config.sheets:
report_key = f"sheet_{sheet['id']}"
selected_report_id = request.form.get(report_key)
if selected_report_id:
new_mappings[sheet['title']] = selected_report_id
config.mappings = new_mappings
db.session.commit()
flash(_('Mappings updated successfully.'), 'success')
except Exception as e:
db.session.rollback()
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
return redirect(url_for('.index'))
@main_bp.route('/render_olap', methods=['POST'])
@login_required
def render_olap():
config = g.user_config
sheet_title = None
req_module = None
try:
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
if not sheet_title:
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
return redirect(url_for('.index'))
report_id = config.mappings.get(sheet_title)
if not report_id:
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
return redirect(url_for('.index'))
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error')
return redirect(url_for('.index'))
preset = next((p for p in config.presets if p.get('id') == report_id), None)
if not preset:
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
return redirect(url_for('.index'))
template = generate_template_from_preset(preset)
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
if req_module.login():
result = req_module.take_olap(json_body)
if 'data' not in result or not isinstance(result['data'], list):
flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error')
return redirect(url_for('.index'))
data_to_insert = []
if result['data']:
headers = list(result['data'][0].keys())
data_to_insert.append(headers)
for item in result['data']:
data_to_insert.append([item.get(h) for h in headers])
gs_client.clear_and_write_data(sheet_title, data_to_insert)
if len(data_to_insert) > 1:
flash(_('Report "%(name)s" data successfully written to sheet "%(sheet)s".', name=preset.get('name', report_id), sheet=sheet_title), 'success')
else:
flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning')
else:
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
except ValueError as ve:
flash(_('Data Error: %(error)s', error=str(ve)), 'error')
except gspread.exceptions.APIError as api_err:
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title), 'error')
except Exception as e:
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
finally:
if req_module and req_module.token:
req_module.logout()
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 */

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,9 +144,8 @@
<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 %}>
{{ preset['name'] }} ({{ preset['id'] }})
@@ -154,58 +157,53 @@
{% 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 #}
{% if 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 #}
{% set preset_name = _('ID: ') + report_id %}
{% if matching_presets %}
{% set preset = matching_presets[0] %}
{% set preset_name = preset.get('name', 'Unnamed Preset') %}
{% set preset_name = preset.get('name', _('Unnamed Preset')) %}
{% endif %}
<tr>
@@ -213,7 +211,7 @@
<td>{{ preset_name }}</td>
<td>
<button type="submit" name="render_{{ sheet.title }}">
Render to sheet
{{ _('Render to sheet') }}
</button>
</td>
</tr>
@@ -223,8 +221,8 @@
</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>
@@ -235,56 +233,27 @@
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 }};
// const collapsibles = document.getElementsByClassName("collapsible");
// if (rmsConfigured && !googleCredsExist) {
// // Find and click Google Sheets collapsible
// for (let i = 0; i < collapsibles.length; i++) {
// if (collapsibles[i].innerText.includes("Google Sheets Configuration")) {
// collapsibles[i].click();
// break;
// }
// }
// } else if (rmsConfigured && googleCredsExist && googleSheetUrlSet && presetsLoaded && sheetsLoaded && !mappingsExist) {
// // Find and click Mapping collapsible
// for (let i = 0; i in collapsibles.length; i++) { // <-- Potential typo here, should be <
// if (collapsibles[i].innerText.includes("Mapping Sheets to OLAP Reports")) {
// collapsibles[i].click();
// break;
// }
// }
// }
// // Add more conditions as needed
// });
</script>
{% else %}
<p style="text-align: center; margin-top: 50px;">Please, <a href="{{ url_for('login') }}">login</a> or <a href="{{ url_for('register') }}">register</a></p>
<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 "Войдите здесь"

123
utils.py
View File

@@ -5,51 +5,44 @@ from datetime import datetime
# Настройка логирования
logger = logging.getLogger(__name__)
# Уровень логирования уже должен быть настроен в app.py или основном модуле
# logger.setLevel(logging.DEBUG) # Можно убрать, если настраивается глобально
# Функция load_temps удалена, так как пресеты загружаются из API RMS
def generate_template_from_preset(preset):
"""
Генерирует один шаблон запроса OLAP на основе пресета,
подставляя плейсхолдеры для дат в соответствующий фильтр.
Генерирует один шаблон запроса 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 +51,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 +75,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 +130,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: