Compare commits
4 Commits
2f2cd7d578
...
ddd0ffbcb0
| Author | SHA1 | Date | |
|---|---|---|---|
| ddd0ffbcb0 | |||
| 36a8548562 | |||
| f5cf4c32da | |||
| 019e4f90c7 |
605
app.py
605
app.py
@@ -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 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("Database tables created successfully.")
|
||||
|
||||
return app
|
||||
|
||||
# --- Точка входа для запуска ---
|
||||
app = create_app()
|
||||
|
||||
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."""
|
||||
db.create_all()
|
||||
print('Initialized the database.')
|
||||
|
||||
# --- Main Execution ---
|
||||
if __name__ == '__main__':
|
||||
# Ensure the database exists before running
|
||||
with app.app_context():
|
||||
db.create_all() # Create tables if they don't exist
|
||||
# Run Flask app
|
||||
# Set debug=False for production!
|
||||
app.run(debug=False, host='0.0.0.0', port=int(os.environ.get("PORT", 5005))) # Listen on all interfaces if needed
|
||||
# Для прямого запуска через `python app.py` (удобно для отладки)
|
||||
app.run(host='0.0.0.0', port=int(os.environ.get("PORT", 5005)))
|
||||
3
babel.cfg
Normal file
3
babel.cfg
Normal file
@@ -0,0 +1,3 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.i18n
|
||||
11
extensions.py
Normal file
11
extensions.py
Normal 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()
|
||||
136
google_sheets.py
136
google_sheets.py
@@ -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}'.")
|
||||
logger.error(f"Лист '{sheet_name}' не найден в таблице '{self.spreadsheet.title}'.")
|
||||
raise
|
||||
except Exception:
|
||||
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
529
messages.pot
Normal 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 ""
|
||||
|
||||
106
models.py
106
models.py
@@ -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:
|
||||
try:
|
||||
ENCRYPTION_KEY = encryption_key_str.encode('utf-8')
|
||||
# Простая проверка, что ключ валидный для Fernet
|
||||
Fernet(ENCRYPTION_KEY)
|
||||
logger.info("Successfully loaded ENCRYPTION_KEY from environment variable.")
|
||||
except Exception as e:
|
||||
logger.error(f"Invalid ENCRYPTION_KEY format in environment variable: {e}")
|
||||
raise ValueError("Invalid ENCRYPTION_KEY format.") from e
|
||||
# 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(encryption_key)
|
||||
logger.info("Ключ шифрования ENCRYPTION_KEY успешно загружен.")
|
||||
except Exception as 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?
|
||||
return {
|
||||
'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
49
prompt
Normal 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, пути, названия), всегда уточняй их у меня.
|
||||
Качество кода: Пиши чистый, поддерживаемый код, готовый к дальнейшему расширению функционала.
|
||||
@@ -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:
|
||||
self.token = response.text
|
||||
logger.info(f'Получен токен: {self.token}')
|
||||
return True
|
||||
elif response.status_code == 401:
|
||||
logger.error(f'Ошибка авторизации. {response.text}')
|
||||
raise Exception('Unauthorized')
|
||||
response.raise_for_status() # Вызовет исключение для статусов 4xx/5xx
|
||||
self.token = response.text
|
||||
logger.info(f'Успешно получен токен: {self.token[:8]}...') # Логируем только часть токена
|
||||
return True
|
||||
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:
|
||||
return response.json()
|
||||
else:
|
||||
logger.error(f'Не удалось получить кастомный OLAP. Status code: {response.status_code} \nText: {response.text}')
|
||||
raise Exception('Request failed')
|
||||
response.raise_for_status() # Проверка на HTTP ошибки
|
||||
return response.json()
|
||||
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:
|
||||
presets = response.json()
|
||||
logger.info('Пресеты переданы в генератор шаблонов')
|
||||
return presets
|
||||
else:
|
||||
logger.error(f"Не удалось получить пресеты. {response.text}")
|
||||
raise Exception('Take presets failed')
|
||||
response.raise_for_status() # Проверка на HTTP ошибки
|
||||
presets = response.json()
|
||||
logger.info(f"Успешно получено {len(presets)} пресетов OLAP-отчетов.")
|
||||
return presets
|
||||
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
|
||||
432
routes.py
Normal file
432
routes.py
Normal file
@@ -0,0 +1,432 @@
|
||||
# routes.py
|
||||
import os
|
||||
import json
|
||||
import shutil
|
||||
from flask import (
|
||||
Blueprint, render_template, request, redirect, url_for, flash, g, session, current_app
|
||||
)
|
||||
from flask_login import login_user, login_required, logout_user, current_user
|
||||
from flask_babel import _
|
||||
from werkzeug.utils import secure_filename
|
||||
import gspread
|
||||
|
||||
# --- ИМПОРТ РАСШИРЕНИЙ И МОДЕЛЕЙ ---
|
||||
# Импортируем экземпляры расширений, созданные в app.py
|
||||
from extensions import db, login_manager
|
||||
# Импортируем наши классы и утилиты
|
||||
from models import User, UserConfig
|
||||
from google_sheets import GoogleSheets
|
||||
from request_module import ReqModule
|
||||
from utils import get_dates, generate_template_from_preset, render_temp
|
||||
|
||||
|
||||
# --- Создание блюпринта ---
|
||||
main_bp = Blueprint('main', __name__)
|
||||
|
||||
|
||||
# --- Регистрация обработчиков для расширений ---
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
"""Загружает пользователя из БД для управления сессией."""
|
||||
return db.session.get(User, int(user_id))
|
||||
|
||||
|
||||
@main_bp.before_app_request
|
||||
def load_user_specific_data():
|
||||
"""Загружает конфигурацию пользователя в глобальный объект `g` для текущего запроса."""
|
||||
g.user_config = None
|
||||
if current_user.is_authenticated:
|
||||
g.user_config = get_user_config()
|
||||
|
||||
|
||||
# --- Вспомогательные функции, специфичные для маршрутов ---
|
||||
|
||||
def get_user_config():
|
||||
"""Получает конфиг для текущего пользователя, создавая его при необходимости."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
config = UserConfig.query.filter_by(user_id=current_user.id).first()
|
||||
if not config:
|
||||
config = UserConfig(user_id=current_user.id)
|
||||
db.session.add(config)
|
||||
return config
|
||||
|
||||
def get_user_upload_path(filename=""):
|
||||
"""Возвращает путь для загрузки файлов для текущего пользователя."""
|
||||
if not current_user.is_authenticated:
|
||||
return None
|
||||
user_dir = os.path.join(current_app.config['DATA_DIR'], str(current_user.id))
|
||||
os.makedirs(user_dir, exist_ok=True)
|
||||
return os.path.join(user_dir, secure_filename(filename))
|
||||
|
||||
|
||||
# --- Маршруты ---
|
||||
|
||||
@main_bp.route('/language/<language>')
|
||||
def set_language(language=None):
|
||||
session['language'] = language
|
||||
return redirect(request.referrer or url_for('.index'))
|
||||
|
||||
@main_bp.route('/login', methods=['GET', 'POST'])
|
||||
def login():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user is None or not user.check_password(password):
|
||||
flash(_('Invalid username or password'), 'error')
|
||||
return redirect(url_for('.login'))
|
||||
login_user(user, remember=request.form.get('remember'))
|
||||
flash(_('Login successful!'), 'success')
|
||||
next_page = request.args.get('next')
|
||||
return redirect(next_page or url_for('.index'))
|
||||
return render_template('login.html')
|
||||
|
||||
@main_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('.index'))
|
||||
if request.method == 'POST':
|
||||
username = request.form.get('username')
|
||||
password = request.form.get('password')
|
||||
if not username or not password:
|
||||
flash(_('Username and password are required.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
if User.query.filter_by(username=username).first():
|
||||
flash(_('Username already exists.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
user = User(username=username)
|
||||
user.set_password(password)
|
||||
user.config = UserConfig()
|
||||
db.session.add(user)
|
||||
try:
|
||||
db.session.commit()
|
||||
flash(_('Registration successful! Please log in.'), 'success')
|
||||
return redirect(url_for('.login'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('An error occurred during registration. Please try again.'), 'error')
|
||||
return redirect(url_for('.register'))
|
||||
|
||||
return render_template('register.html')
|
||||
|
||||
@main_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
flash(_('You have been logged out.'), 'success')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/')
|
||||
@login_required
|
||||
def index():
|
||||
config = g.user_config
|
||||
return render_template(
|
||||
'index.html',
|
||||
rms_config=config.get_rms_dict(),
|
||||
google_config=config.get_google_dict(),
|
||||
presets=config.presets,
|
||||
sheets=config.sheets,
|
||||
mappings=config.mappings,
|
||||
client_email=config.google_client_email
|
||||
)
|
||||
|
||||
@main_bp.route('/configure_rms', methods=['POST'])
|
||||
@login_required
|
||||
def configure_rms():
|
||||
config = g.user_config
|
||||
try:
|
||||
host = request.form.get('host', '').strip()
|
||||
login = request.form.get('login', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
|
||||
if not config.rms_password and not password:
|
||||
flash(_('Password is required for the first time.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not host or not login:
|
||||
flash(_('Host and Login fields must be filled.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
effective_password = password if password else config.rms_password
|
||||
|
||||
req_module = ReqModule(host, login, effective_password)
|
||||
if req_module.login():
|
||||
presets_data = req_module.take_presets()
|
||||
req_module.logout()
|
||||
|
||||
config.rms_host = host
|
||||
config.rms_login = login
|
||||
if password:
|
||||
config.rms_password = password
|
||||
config.presets = presets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully authorized on RMS server. Received %(num)s presets.', num=len(presets_data)), 'success')
|
||||
else:
|
||||
flash(_('Authorization error on RMS server. Check host, login or password.'), 'error')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error configuring RMS: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/upload_credentials', methods=['POST'])
|
||||
@login_required
|
||||
def upload_credentials():
|
||||
config = g.user_config
|
||||
if 'cred_file' not in request.files or request.files['cred_file'].filename == '':
|
||||
flash(_('No file was selected.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
cred_file = request.files['cred_file']
|
||||
filename = cred_file.filename
|
||||
# Получаем путь для сохранения файла в папке пользователя
|
||||
user_cred_path = get_user_upload_path(filename)
|
||||
temp_path = None
|
||||
|
||||
try:
|
||||
# Сначала сохраняем файл во временную директорию для проверки
|
||||
temp_dir = os.path.join(current_app.config['DATA_DIR'], "temp")
|
||||
os.makedirs(temp_dir, exist_ok=True)
|
||||
temp_path = os.path.join(temp_dir, f"temp_{current_user.id}_{filename}")
|
||||
cred_file.save(temp_path)
|
||||
|
||||
with open(temp_path, 'r', encoding='utf-8') as f:
|
||||
cred_data = json.load(f)
|
||||
client_email = cred_data.get('client_email')
|
||||
|
||||
if not client_email:
|
||||
flash(_('Could not find client_email in the credentials file.'), 'error')
|
||||
# Не забываем удалить временный файл при ошибке
|
||||
if os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Если все хорошо, перемещаем файл из временной папки в постоянную
|
||||
shutil.move(temp_path, user_cred_path)
|
||||
|
||||
# Сохраняем путь к файлу и email в базу данных
|
||||
config.google_cred_file_path = user_cred_path
|
||||
config.google_client_email = client_email
|
||||
config.sheets = [] # Сбрасываем список листов при смене credentials
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Credentials file successfully uploaded. Email: %(email)s', email=client_email), 'success')
|
||||
|
||||
except json.JSONDecodeError:
|
||||
flash(_('Error: Uploaded file is not a valid JSON.'), 'error')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error processing credentials: %(error)s', error=str(e)), 'error')
|
||||
finally:
|
||||
# Гарантированно удаляем временный файл, если он еще существует
|
||||
if temp_path and os.path.exists(temp_path):
|
||||
os.remove(temp_path)
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/configure_google', methods=['POST'])
|
||||
@login_required
|
||||
def configure_google():
|
||||
config = g.user_config
|
||||
sheet_url = request.form.get('sheet_url', '').strip()
|
||||
|
||||
if not sheet_url:
|
||||
flash(_('Sheet URL must be provided.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
config.google_sheet_url = sheet_url
|
||||
|
||||
cred_path = config.google_cred_file_path
|
||||
if not cred_path or not os.path.isfile(cred_path):
|
||||
flash(_('Please upload a valid credentials file first.'), 'warning')
|
||||
config.sheets = []
|
||||
db.session.commit()
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
try:
|
||||
gs_client = GoogleSheets(cred_path, sheet_url)
|
||||
sheets_data = gs_client.get_sheets()
|
||||
config.sheets = sheets_data
|
||||
|
||||
db.session.commit()
|
||||
flash(_('Successfully connected to Google Sheets. Found %(num)s sheets. Settings saved.', num=len(sheets_data)), 'success')
|
||||
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
config.sheets = []
|
||||
flash(_('Error connecting to Google Sheets: %(error)s. Check the URL and service account permissions.', error=str(e)), 'error')
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/mapping_set', methods=['POST'])
|
||||
@login_required
|
||||
def mapping_set():
|
||||
config = g.user_config
|
||||
try:
|
||||
new_mappings = {}
|
||||
for sheet in config.sheets:
|
||||
report_key = f"sheet_{sheet['id']}"
|
||||
selected_report_id = request.form.get(report_key)
|
||||
if selected_report_id:
|
||||
new_mappings[sheet['title']] = selected_report_id
|
||||
|
||||
config.mappings = new_mappings
|
||||
db.session.commit()
|
||||
|
||||
flash(_('Mappings updated successfully.'), 'success')
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash(_('Error updating mappings: %(error)s', error=str(e)), 'error')
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
@main_bp.route('/render_olap', methods=['POST'])
|
||||
@login_required
|
||||
def render_olap():
|
||||
config = g.user_config
|
||||
sheet_title = None
|
||||
req_module = None
|
||||
|
||||
try:
|
||||
from_date, to_date = get_dates(request.form.get('start_date'), request.form.get('end_date'))
|
||||
sheet_title = next((key for key in request.form if key.startswith('render_')), '').replace('render_', '')
|
||||
if not sheet_title:
|
||||
flash(_('Error: Could not determine which sheet to render the report for.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_id = config.mappings.get(sheet_title)
|
||||
if not report_id:
|
||||
flash(_('Error: No report is assigned to sheet "%(sheet)s".', sheet=sheet_title), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
if not all([config.rms_host, config.rms_login, config.rms_password, config.google_cred_file_path, config.google_sheet_url]):
|
||||
flash(_('Error: RMS or Google Sheets configuration is incomplete.'), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
preset = next((p for p in config.presets if p.get('id') == report_id), None)
|
||||
if not preset:
|
||||
flash(_('Error: Preset with ID "%(id)s" not found in saved configuration.', id=report_id), 'error')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
template = generate_template_from_preset(preset)
|
||||
json_body = render_temp(template, {"from_date": from_date, "to_date": to_date})
|
||||
|
||||
req_module = ReqModule(config.rms_host, config.rms_login, config.rms_password)
|
||||
gs_client = GoogleSheets(config.google_cred_file_path, config.google_sheet_url)
|
||||
|
||||
if req_module.login():
|
||||
result = req_module.take_olap(json_body)
|
||||
|
||||
# --- НАЧАЛО НОВОЙ УЛУЧШЕННОЙ ЛОГИКИ ОБРАБОТКИ ДАННЫХ ---
|
||||
|
||||
if 'data' not in result or not isinstance(result['data'], list):
|
||||
flash(_('Error: Unexpected response format from RMS for report "%(name)s".', name=preset.get('name', report_id)), 'error')
|
||||
current_app.logger.error(f"Unexpected API response for report {report_id} ('{preset.get('name')}'). Response: {result}")
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
report_data = result['data']
|
||||
|
||||
# Если отчет пуст, очищаем лист и уведомляем пользователя.
|
||||
if not report_data:
|
||||
gs_client.clear_and_write_data(sheet_title, [])
|
||||
flash(_('Report "%(name)s" returned no data for the selected period. Sheet "%(sheet)s" has been cleared.', name=preset.get('name', report_id), sheet=sheet_title), 'warning')
|
||||
return redirect(url_for('.index'))
|
||||
|
||||
# Здесь будет храниться наш итоговый "плоский" список словарей
|
||||
processed_data = []
|
||||
|
||||
# Проверяем структуру отчета: сводный (pivoted) или простой (flat)
|
||||
first_item = report_data[0]
|
||||
is_pivoted = isinstance(first_item, dict) and 'row' in first_item and 'cells' in first_item
|
||||
|
||||
if is_pivoted:
|
||||
current_app.logger.info(f"Processing a pivoted report: {preset.get('name', report_id)}")
|
||||
# "Разворачиваем" (unpivot) данные в плоский список словарей
|
||||
for row_item in report_data:
|
||||
row_values = row_item.get('row', {})
|
||||
cells = row_item.get('cells', [])
|
||||
if not cells:
|
||||
# Обрабатываем строки, у которых может не быть данных в ячейках
|
||||
processed_data.append(row_values.copy())
|
||||
else:
|
||||
for cell in cells:
|
||||
new_flat_row = row_values.copy()
|
||||
new_flat_row.update(cell.get('col', {}))
|
||||
new_flat_row.update(cell.get('values', {}))
|
||||
processed_data.append(new_flat_row)
|
||||
else:
|
||||
current_app.logger.info(f"Processing a simple flat report: {preset.get('name', report_id)}")
|
||||
# Данные уже в виде плоского списка, просто присваиваем
|
||||
processed_data = [item for item in report_data if isinstance(item, dict)]
|
||||
|
||||
# --- Универсальное формирование заголовков и данных ---
|
||||
|
||||
# 1. Собираем все уникальные ключи из всех строк для гарантии целостности.
|
||||
all_keys = set()
|
||||
for row in processed_data:
|
||||
all_keys.update(row.keys())
|
||||
|
||||
# 2. Создаем упорядоченный список заголовков для лучшей читаемости.
|
||||
# Используем поля из пресета для определения логического порядка.
|
||||
row_group_fields = preset.get('groupByRowFields', [])
|
||||
col_group_fields = preset.get('groupByColFields', [])
|
||||
agg_fields = preset.get('aggregateFields', [])
|
||||
|
||||
ordered_headers = []
|
||||
# Сначала добавляем известные поля из пресета в логической последовательности.
|
||||
for field in row_group_fields + col_group_fields + agg_fields:
|
||||
if field in all_keys:
|
||||
ordered_headers.append(field)
|
||||
all_keys.remove(field)
|
||||
# Добавляем любые другие (неожиданные) поля, отсортировав их по алфавиту.
|
||||
ordered_headers.extend(sorted(list(all_keys)))
|
||||
|
||||
# 3. Собираем итоговый список списков для Google Sheets, приводя все значения к строкам.
|
||||
data_to_insert = [ordered_headers]
|
||||
for row in processed_data:
|
||||
row_data = []
|
||||
for header in ordered_headers:
|
||||
value_str = str(row.get(header, ''))
|
||||
if value_str.startswith(('=', '+', '-', '@')):
|
||||
row_data.append("'" + value_str)
|
||||
else:
|
||||
row_data.append(value_str)
|
||||
# Преобразуем None в пустую строку, а все остальное в строковое представление.
|
||||
# Это предотвращает потенциальные ошибки типов со стороны Google Sheets API.
|
||||
data_to_insert.append(row_data)
|
||||
|
||||
|
||||
gs_client.clear_and_write_data(sheet_title, data_to_insert)
|
||||
|
||||
rows_count = len(data_to_insert) - 1
|
||||
flash(_('Report "%(name)s" data (%(rows)s rows) successfully written to sheet "%(sheet)s".',
|
||||
name=preset.get('name', report_id),
|
||||
rows=rows_count,
|
||||
sheet=sheet_title), 'success')
|
||||
else:
|
||||
flash(_('Error authorizing on RMS server when trying to get a report.'), 'error')
|
||||
|
||||
except ValueError as ve:
|
||||
flash(_('Data Error: %(error)s', error=str(ve)), 'error')
|
||||
except gspread.exceptions.APIError as api_err:
|
||||
flash(_('Google API Error accessing sheet "%(sheet)s". Check service account permissions.', sheet=sheet_title or _('Unknown')), 'error')
|
||||
current_app.logger.error(f"Google API Error for sheet '{sheet_title}': {api_err}", exc_info=True)
|
||||
except Exception as e:
|
||||
flash(_('An unexpected error occurred: %(error)s', error=str(e)), 'error')
|
||||
current_app.logger.error(f"Unexpected error in render_olap: {e}", exc_info=True)
|
||||
finally:
|
||||
if req_module and req_module.token:
|
||||
req_module.logout()
|
||||
|
||||
return redirect(url_for('.index'))
|
||||
@@ -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 */
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
BIN
translations/en/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
530
translations/en/LC_MESSAGES/messages.po
Normal file
530
translations/en/LC_MESSAGES/messages.po
Normal 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 ""
|
||||
|
||||
BIN
translations/ru/LC_MESSAGES/messages.mo
Normal file
BIN
translations/ru/LC_MESSAGES/messages.mo
Normal file
Binary file not shown.
556
translations/ru/LC_MESSAGES/messages.po
Normal file
556
translations/ru/LC_MESSAGES/messages.po
Normal 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 "Войдите здесь"
|
||||
127
utils.py
127
utils.py
@@ -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
|
||||
|
||||
# Обновляем фильтры в шаблоне
|
||||
logger.info(f"Шаблон для пресета {preset.get('id', 'N/A')} ('{preset.get('name', '')}') успешно сгенерирован с фильтром даты.")
|
||||
|
||||
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}).")
|
||||
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user